diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 03a6d297e4d..9bb0d4e28e0 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -410,7 +410,7 @@ export const ResizablePaneWithCustomPersistence: StoryFn = () => { { + persist: width => { setWidthConfig(prev => ({...prev, default: `${width}px`})) localStorage.setItem(key, width.toString()) }, @@ -457,7 +457,7 @@ export const ResizablePaneWithNumberWidth: StoryFn = () => { { + persist: newWidth => { setWidth(newWidth) localStorage.setItem(key, newWidth.toString()) }, diff --git a/packages/react/src/PageLayout/index.ts b/packages/react/src/PageLayout/index.ts index 9535085c9dd..a395672c243 100644 --- a/packages/react/src/PageLayout/index.ts +++ b/packages/react/src/PageLayout/index.ts @@ -1,7 +1,7 @@ export * from './PageLayout' export type { - NoPersistConfig, - CustomPersistConfig, + PersistConfig, + PersistFunction, SaveOptions, ResizableConfig, PaneWidth, diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index 5d8b3a1994b..3883e534e6e 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -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 @@ -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(() => @@ -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(() => @@ -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(() => @@ -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(() => @@ -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(() => @@ -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) }) }) }) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index eab9c6eccb4..0dd65fc3a86 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -24,30 +24,32 @@ 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 /** - * 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 +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' } /** @@ -55,9 +57,10 @@ export const isCustomPersistConfig = (config: ResizableConfig): config is Custom * - `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 @@ -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) } /** @@ -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