Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ export const ResizablePaneWithCustomPersistence: StoryFn = () => {
<PageLayout.Pane
width={widthConfig}
resizable={{
save: width => {
persist: width => {
setWidthConfig(prev => ({...prev, default: `${width}px`}))
localStorage.setItem(key, width.toString())
},
Expand Down Expand Up @@ -457,7 +457,7 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => {
<PageLayout.Pane
width={width}
resizable={{
save: newWidth => {
persist: newWidth => {
setWidth(newWidth)
localStorage.setItem(key, newWidth.toString())
},
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/PageLayout/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * from './PageLayout'
export type {
NoPersistConfig,
CustomPersistConfig,
PersistConfig,
PersistFunction,
SaveOptions,
ResizableConfig,
PaneWidth,
Expand Down
92 changes: 60 additions & 32 deletions packages/react/src/PageLayout/usePaneWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import {
SSR_DEFAULT_MAX_WIDTH,
ARROW_KEY_STEP,
isResizableEnabled,
isNoPersistConfig,
isCustomPersistConfig,
type CustomPersistConfig,
isPersistConfig,
isCustomPersistFunction,
type PersistConfig,
type PersistFunction,
} from './usePaneWidth'

// Mock refs for hook testing
Expand Down Expand Up @@ -227,9 +228,29 @@ describe('usePaneWidth', () => {
localStorage.setItem = originalSetItem
})

it('should use localStorage when {persist: "localStorage"} is provided', () => {
const refs = createMockRefs()
const {result} = renderHook(() =>
usePaneWidth({
width: 'medium',
minWidth: 256,
resizable: {persist: 'localStorage'},
widthStorageKey: 'test-explicit-localstorage',
...refs,
}),
)

act(() => {
result.current.saveWidth(450)
})

expect(result.current.currentWidth).toBe(450)
expect(localStorage.getItem('test-explicit-localstorage')).toBe('450')
})

it('should call custom save function with width and options', () => {
const customSave = vi.fn()
const customPersister: CustomPersistConfig = {save: customSave}
const customPersister: PersistConfig = {persist: customSave}
const refs = createMockRefs()

const {result} = renderHook(() =>
Expand All @@ -254,7 +275,7 @@ describe('usePaneWidth', () => {

it('should handle async custom save function', async () => {
const customSave = vi.fn().mockResolvedValue(undefined)
const customPersister: CustomPersistConfig = {save: customSave}
const customPersister: PersistConfig = {persist: customSave}
const refs = createMockRefs()

const {result} = renderHook(() =>
Expand All @@ -279,7 +300,7 @@ describe('usePaneWidth', () => {
const customSave = vi.fn(() => {
throw new Error('Sync storage error')
})
const customPersister: CustomPersistConfig = {save: customSave}
const customPersister: PersistConfig = {persist: customSave}
const refs = createMockRefs()

const {result} = renderHook(() =>
Expand All @@ -303,7 +324,7 @@ describe('usePaneWidth', () => {

it('should handle async rejection from custom save gracefully', async () => {
const customSave = vi.fn().mockRejectedValue(new Error('Async storage error'))
const customPersister: CustomPersistConfig = {save: customSave}
const customPersister: PersistConfig = {persist: customSave}
const refs = createMockRefs()

const {result} = renderHook(() =>
Expand Down Expand Up @@ -331,7 +352,7 @@ describe('usePaneWidth', () => {

it('should not read from localStorage when custom save is provided', () => {
localStorage.setItem('test-pane', '500')
const customPersister: CustomPersistConfig = {save: vi.fn()}
const customPersister: PersistConfig = {persist: vi.fn() as PersistFunction}
const refs = createMockRefs()

const {result} = renderHook(() =>
Expand Down Expand Up @@ -999,52 +1020,59 @@ describe('type guards', () => {
expect(isResizableEnabled({persist: false})).toBe(true)
})

it('should return true for {save: fn} (custom persistence)', () => {
expect(isResizableEnabled({save: () => {}})).toBe(true)
it('should return true for {persist: "localStorage"}', () => {
expect(isResizableEnabled({persist: 'localStorage'})).toBe(true)
})

it('should return true for {persist: fn} (custom persistence)', () => {
expect(isResizableEnabled({persist: () => {}})).toBe(true)
})
})

describe('isNoPersistConfig', () => {
describe('isPersistConfig', () => {
it('should return true for {persist: false}', () => {
expect(isNoPersistConfig({persist: false})).toBe(true)
expect(isPersistConfig({persist: false})).toBe(true)
})

it('should return true for {persist: "localStorage"}', () => {
expect(isPersistConfig({persist: 'localStorage'})).toBe(true)
})

it('should return true for {persist: fn}', () => {
expect(isPersistConfig({persist: () => {}})).toBe(true)
})

it('should return false for boolean true', () => {
expect(isNoPersistConfig(true)).toBe(false)
expect(isPersistConfig(true)).toBe(false)
})

it('should return false for boolean false', () => {
expect(isNoPersistConfig(false)).toBe(false)
expect(isPersistConfig(false)).toBe(false)
})

it('should return false for objects without persist: false', () => {
it('should return false for objects without persist property', () => {
// @ts-expect-error - testing runtime behavior with arbitrary object
expect(isNoPersistConfig({other: 'value'})).toBe(false)
})

it('should return false for custom persist config', () => {
expect(isNoPersistConfig({save: () => {}})).toBe(false)
expect(isPersistConfig({other: 'value'})).toBe(false)
})
})

describe('isCustomPersistConfig', () => {
it('should return true for objects with save function', () => {
expect(isCustomPersistConfig({save: () => {}})).toBe(true)
expect(isCustomPersistConfig({save: async () => {}})).toBe(true)
describe('isCustomPersistFunction', () => {
it('should return true for function', () => {
const fn = () => {}
expect(isCustomPersistFunction(fn)).toBe(true)
})

it('should return false for boolean values', () => {
expect(isCustomPersistConfig(true)).toBe(false)
expect(isCustomPersistConfig(false)).toBe(false)
it('should return true for async function', () => {
const fn = async () => {}
expect(isCustomPersistFunction(fn)).toBe(true)
})

it('should return false for {persist: false}', () => {
expect(isCustomPersistConfig({persist: false})).toBe(false)
it('should return false for false', () => {
expect(isCustomPersistFunction(false)).toBe(false)
})

it('should return false for null', () => {
// @ts-expect-error - testing runtime behavior
expect(isCustomPersistConfig(null)).toBe(false)
it('should return false for "localStorage"', () => {
expect(isCustomPersistFunction('localStorage')).toBe(false)
})
})
})
51 changes: 27 additions & 24 deletions packages/react/src/PageLayout/usePaneWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,40 +24,43 @@ export type PaneWidth = 'small' | 'medium' | 'large'
export type PaneWidthValue = PaneWidth | number | CustomWidthOptions

/**
* Configuration for resizable without persistence.
* Use this to enable resizing without storing the width anywhere.
* Options passed to custom persist function.
*/
export type NoPersistConfig = {persist: false}
export type SaveOptions = {widthStorageKey: string}

/**
* Options passed to custom save function.
* Custom persist function type.
*/
export type SaveOptions = {widthStorageKey: string}
export type PersistFunction = (width: number, options: SaveOptions) => void | Promise<void>

/**
* Configuration for custom persistence.
* Provide your own save function to persist width to server, IndexedDB, etc.
* Configuration object for resizable pane.
* - `persist: false` - Enable resizing without any persistence
* - `persist: 'localStorage'` - Enable resizing with localStorage persistence
* - `persist: fn` - Enable resizing with custom persistence function
*/
export type CustomPersistConfig = {
save: (width: number, options: SaveOptions) => void | Promise<void>
export type PersistConfig = {
persist: false | 'localStorage' | PersistFunction
}

/**
* Type guard to check if resizable config has a custom save function
* Type guard to check if persist value is a custom function
*/
export const isCustomPersistConfig = (config: ResizableConfig): config is CustomPersistConfig => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- config could be null at runtime despite types
return typeof config === 'object' && config !== null && 'save' in config && typeof config.save === 'function'
export const isCustomPersistFunction = (
persist: false | 'localStorage' | PersistFunction,
): persist is PersistFunction => {
return typeof persist === 'function'
}

/**
* Resizable configuration options.
* - `true`: Enable resizing with default localStorage persistence (may cause hydration mismatch)
* - `false`: Disable resizing
* - `{persist: false}`: Enable resizing without any persistence
* - `{save: fn}`: Enable resizing with custom persistence
* - `{persist: 'localStorage'}`: Enable resizing with localStorage persistence
* - `{persist: fn}`: Enable resizing with custom persistence function
*/
export type ResizableConfig = boolean | NoPersistConfig | CustomPersistConfig
export type ResizableConfig = boolean | PersistConfig

export type UsePaneWidthOptions = {
width: PaneWidthValue
Expand Down Expand Up @@ -138,18 +141,18 @@ export const getDefaultPaneWidth = (w: PaneWidthValue): number => {
}

/**
* Type guard to check if resizable config is {persist: false}
* Type guard to check if resizable config is a PersistConfig object
*/
export const isNoPersistConfig = (config: ResizableConfig): config is NoPersistConfig => {
export const isPersistConfig = (config: ResizableConfig): config is PersistConfig => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- config could be null at runtime despite types
return typeof config === 'object' && config !== null && 'persist' in config && config.persist === false
return typeof config === 'object' && config !== null && 'persist' in config
}

/**
* Check if resizing is enabled (boolean true, {persist: false}, or {save: fn})
* Check if resizing is enabled (boolean true or {persist: ...})
*/
export const isResizableEnabled = (config: ResizableConfig): boolean => {
return config === true || isNoPersistConfig(config) || isCustomPersistConfig(config)
return config === true || isPersistConfig(config)
}

/**
Expand Down Expand Up @@ -294,12 +297,12 @@ export function usePaneWidth({

const config = resizableRef.current

// Handle localStorage persistence: resizable === true
if (config === true) {
// Handle localStorage persistence: resizable === true or {persist: 'localStorage'}
if (config === true || (isPersistConfig(config) && config.persist === 'localStorage')) {
localStoragePersister.save(widthStorageKeyRef.current, value)
} else if (isCustomPersistConfig(config)) {
} else if (isPersistConfig(config) && isCustomPersistFunction(config.persist)) {
try {
const result = config.save(value, {widthStorageKey: widthStorageKeyRef.current})
const result = config.persist(value, {widthStorageKey: widthStorageKeyRef.current})
// Handle async rejections silently
if (result instanceof Promise) {
// eslint-disable-next-line github/no-then
Expand Down
Loading