From 8c3394910b7314ebad28f2f49776c7c804708638 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 18:53:18 -0800 Subject: [PATCH 01/11] test(Resizable): createResizable drives a plain handle element, with one separator derivation - createResizable on a plain element: the separator it makes, keeping a role, name and tab stop the element has, label and collapsedLabel, a pointer drag that holds its pointer (an iframe's element included), bounds and snap points, right-to-left and a reversed handle, the keys, Enter and a double-click, disabled input that leaves the tab order, saving through a drag, and a destroy that leaves nothing behind. - separatorAttributes: an open, collapsed, disabled and unbounded region, and no region. - ResizeHandle and a factory driving a plain element announce the same separator attributes, enabled, disabled and collapsed. - The region's tests and the configuration-object form's stand-in take the factory's handle argument, null for a region with no handle. They fail here: createResizable takes no handle, and separatorAttributes does not exist. --- .../core/src/Resizable/ResizeHandle.test.tsx | 44 +- .../src/Resizable/createResizable.test.ts | 6 +- .../Resizable/createResizableHandle.test.ts | 472 ++++++++++++++++++ .../src/Resizable/separatorAttributes.test.ts | 75 +++ .../src/Resizable/useResizableConfig.test.tsx | 22 +- 5 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/Resizable/createResizableHandle.test.ts create mode 100644 packages/core/src/Resizable/separatorAttributes.test.ts diff --git a/packages/core/src/Resizable/ResizeHandle.test.tsx b/packages/core/src/Resizable/ResizeHandle.test.tsx index 3872c76778262..44e1b92f8c7cf 100644 --- a/packages/core/src/Resizable/ResizeHandle.test.tsx +++ b/packages/core/src/Resizable/ResizeHandle.test.tsx @@ -2,7 +2,7 @@ /** * @file ResizeHandle.test.tsx - * @input Uses vitest, @testing-library/react, react-dom, useResizable, ResizeHandle + * @input vitest, testing-library, react-dom, useResizable, ResizeHandle, createResizable * @output Unit tests for ResizeHandle keyboard operability, ARIA state and hydration * @position Testing; validates ResizeHandle.tsx implementation * @@ -22,6 +22,7 @@ import {hydrateRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; import {ResizeHandle} from './ResizeHandle'; import type {ResizeHandleProps} from './ResizeHandle'; +import {createResizable} from './createResizable'; import {useResizable} from './useResizable'; import type {ResizableProps, UseResizableSingleConfig} from './useResizable'; @@ -1262,6 +1263,47 @@ describe('ResizeHandle', () => { }, ); + it.each([ + ['enabled', {}, false], + ['disabled', {}, true], + ['collapsed', {collapsible: true, defaultIsCollapsed: true}, false], + ] as const)( + 'announces the same separator attributes as a factory driving a plain element when %s', + (_, extra, isDisabled) => { + const config = {defaultSize: 200, minSize: 100, maxSize: 400, ...extra}; + const view = render( + , + ); + const handle = getSeparator(); + const element = document.createElement('div'); + document.body.append(element); + const resizable = createResizable(element, {...config, isDisabled}); + const names = [ + 'role', + 'tabindex', + 'aria-label', + 'aria-orientation', + 'aria-valuenow', + 'aria-valuemin', + 'aria-valuemax', + 'aria-valuetext', + 'aria-disabled', + ]; + const read = (node: Element) => + Object.fromEntries(names.map(name => [name, node.getAttribute(name)])); + try { + expect(read(element)).toEqual(read(handle)); + } finally { + resizable.destroy(); + element.remove(); + view.unmount(); + } + }, + ); + it('starts a drag from the Grip pill as from the Grab zone', () => { const resizable = makeResizable(); render(); diff --git a/packages/core/src/Resizable/createResizable.test.ts b/packages/core/src/Resizable/createResizable.test.ts index fb65709aaee3c..adfaf4b0a6138 100644 --- a/packages/core/src/Resizable/createResizable.test.ts +++ b/packages/core/src/Resizable/createResizable.test.ts @@ -22,7 +22,7 @@ import type {ResizeGestureModel} from './resizeGesture'; const controllers: ResizableController[] = []; function create(options?: CreateResizableOptions): ResizableController { - const controller = createResizable(options); + const controller = createResizable(null, options); controllers.push(controller); return controller; } @@ -341,7 +341,7 @@ describe('createResizable: persistence', () => { describe('createResizable: destroy', () => { it('ends a gesture in flight and keeps the size it reached, then answers nothing', () => { const onSizeChange = vi.fn(); - const resizable = createResizable({ + const resizable = createResizable(null, { defaultSize: 200, autoSaveId: 'mid-gesture', onSizeChange, @@ -376,7 +376,7 @@ describe('createResizable: destroy', () => { const container = document.createElement('div'); document.body.append(container); Object.defineProperty(container, 'clientWidth', {get: () => 600}); - const resizable = createResizable({ + const resizable = createResizable(null, { containerRef: {current: container}, maxSize: '50%', }); diff --git a/packages/core/src/Resizable/createResizableHandle.test.ts b/packages/core/src/Resizable/createResizableHandle.test.ts new file mode 100644 index 0000000000000..7fc96398ab11c --- /dev/null +++ b/packages/core/src/Resizable/createResizableHandle.test.ts @@ -0,0 +1,472 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file createResizableHandle.test.ts + * @input vitest (jsdom), createResizable on plain elements + * @output Checks the factory on a handle: the separator it makes, the pointer + * drag and its capture, bounds and snap points, right-to-left and reversal, + * the keyboard, collapse, collapsedLabel, disabled input, saving through a + * drag, and a destroy that leaves nothing behind + * @position Unit tests for createResizable.ts's element path + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + createResizable, + type CreateResizableOptions, + type ResizableController, +} from './createResizable'; + +const controllers: ResizableController[] = []; + +function create( + handle: HTMLElement | null, + options?: CreateResizableOptions, +): ResizableController { + const controller = createResizable(handle, options); + controllers.push(controller); + return controller; +} + +/** A plain element that records pointer capture, as jsdom does not. */ +function makeHandle(): {handle: HTMLElement; captured: () => number | null} { + const handle = document.createElement('div'); + document.body.append(handle); + let captured: number | null = null; + handle.setPointerCapture = (id: number) => { + captured = id; + }; + handle.releasePointerCapture = (id: number) => { + if (captured === id) { + captured = null; + } + }; + handle.hasPointerCapture = (id: number) => captured === id; + return {handle, captured: () => captured}; +} + +function pointer( + target: Element, + type: string, + {x = 0, y = 0, id = 1}: {x?: number; y?: number; id?: number} = {}, +): PointerEvent { + const event = new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId: id, + pointerType: 'mouse', + isPrimary: true, + clientX: x, + clientY: y, + button: 0, + buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, + }); + target.dispatchEvent(event); + return event; +} + +function key( + target: Element, + name: string, + init: KeyboardEventInit = {}, +): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key: name, + bubbles: true, + cancelable: true, + ...init, + }); + target.dispatchEvent(event); + return event; +} + +function drag(target: Element, from: number, to: number) { + pointer(target, 'pointerdown', {x: from}); + pointer(target, 'pointermove', {x: to}); + pointer(target, 'pointerup', {x: to}); +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + for (const controller of controllers.splice(0)) { + controller.destroy(); + } + document.body.innerHTML = ''; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('createResizable on a plain element', () => { + it('makes a plain element a window splitter: role, orientation, its value and a tab stop', () => { + const {handle} = makeHandle(); + create(handle, {defaultSize: 200, minSize: 100, maxSize: 400}); + expect(handle.getAttribute('role')).toBe('separator'); + // A horizontal region resizes along the inline axis: a vertical splitter. + expect(handle.getAttribute('aria-orientation')).toBe('vertical'); + expect(handle.getAttribute('aria-valuenow')).toBe('200'); + expect(handle.getAttribute('aria-valuemin')).toBe('100'); + expect(handle.getAttribute('aria-valuemax')).toBe('400'); + expect(handle.tabIndex).toBe(0); + }); + + it('keeps a role, a name and a tab stop the element already has', () => { + const {handle} = makeHandle(); + handle.setAttribute('aria-label', 'Sidebar width'); + handle.tabIndex = -1; + create(handle, {defaultSize: 200, label: 'Ignored'}); + expect(handle.getAttribute('aria-label')).toBe('Sidebar width'); + expect(handle.tabIndex).toBe(-1); + }); + + it('names the element with `label` when it has no name', () => { + const {handle} = makeHandle(); + create(handle, {defaultSize: 200, label: 'Sidebar width'}); + expect(handle.getAttribute('aria-label')).toBe('Sidebar width'); + }); + + it('resizes on a pointer drag, reporting each size, and holds the pointer for the whole drag', () => { + const {handle, captured} = makeHandle(); + const onSizeChange = vi.fn(); + const resizable = create(handle, { + defaultSize: 200, + minSize: 100, + maxSize: 400, + onSizeChange, + }); + pointer(handle, 'pointerdown', {x: 10}); + expect(captured()).toBe(1); + pointer(handle, 'pointermove', {x: 60}); + expect(onSizeChange).toHaveBeenLastCalledWith(250); + expect(resizable.size).toBe(250); + expect(handle.getAttribute('aria-valuenow')).toBe('250'); + pointer(handle, 'pointerup', {x: 60}); + expect(captured()).toBeNull(); + }); + + it('marks the element while a drag is in flight', () => { + const {handle} = makeHandle(); + create(handle, {defaultSize: 200}); + pointer(handle, 'pointerdown', {x: 0}); + expect(handle.hasAttribute('data-resizing')).toBe(true); + pointer(handle, 'pointerup', {x: 0}); + expect(handle.hasAttribute('data-resizing')).toBe(false); + }); + + it('clamps a drag to its bounds, and rests only on a snap point when it has them', () => { + const {handle} = makeHandle(); + const resizable = create(handle, { + defaultSize: 200, + minSize: 100, + maxSize: 400, + }); + drag(handle, 0, 500); + expect(resizable.size).toBe(400); + drag(handle, 0, -500); + expect(resizable.size).toBe(100); + + const snapped = makeHandle(); + const snapping = create(snapped.handle, { + defaultSize: 150, + minSize: 100, + maxSize: 400, + snaps: [150, 300], + }); + drag(snapped.handle, 0, 100); + expect(snapping.size).toBe(300); + }); + + it('follows the pointer under right-to-left, and a reversed handle grows the other way', () => { + const {handle} = makeHandle(); + handle.style.direction = 'rtl'; + const rtl = create(handle, {defaultSize: 200, minSize: 100, maxSize: 400}); + drag(handle, 0, 50); + expect(rtl.size).toBe(150); + + const reversed = makeHandle(); + const end = create(reversed.handle, { + defaultSize: 200, + minSize: 100, + maxSize: 400, + isReversed: true, + }); + drag(reversed.handle, 0, 50); + expect(end.size).toBe(150); + }); + + it('resizes from the keyboard: arrows step 10, Shift steps 50, Home and End reach the bounds', () => { + const {handle} = makeHandle(); + const resizable = create(handle, { + defaultSize: 200, + minSize: 100, + maxSize: 400, + }); + expect(key(handle, 'ArrowRight').defaultPrevented).toBe(true); + expect(resizable.size).toBe(210); + key(handle, 'ArrowLeft', {shiftKey: true}); + expect(resizable.size).toBe(160); + key(handle, 'End'); + expect(resizable.size).toBe(400); + key(handle, 'Home'); + expect(resizable.size).toBe(100); + }); + + it('announces collapsedLabel while collapsed instead of the catalog message, and takes a new one through update()', () => { + const {handle} = makeHandle(); + const options = { + defaultSize: 200, + collapsible: true, + collapsedLabel: 'Sidebar hidden', + }; + const resizable = create(handle, options); + resizable.collapse(); + expect(handle.getAttribute('aria-valuetext')).toBe('Sidebar hidden'); + resizable.update({...options, collapsedLabel: 'Panneau masqué'}); + expect(handle.getAttribute('aria-valuetext')).toBe('Panneau masqué'); + // Left out, it is the shipped English catalog's message again. + resizable.update({defaultSize: 200, collapsible: true}); + expect(handle.getAttribute('aria-valuetext')).toBe('Collapsed'); + }); + + it('collapses a collapsible region on Enter and on a double-click, and restores its size', () => { + const {handle} = makeHandle(); + const onCollapseChange = vi.fn(); + const resizable = create(handle, { + defaultSize: 200, + minSize: 100, + maxSize: 400, + collapsible: true, + onCollapseChange, + }); + key(handle, 'Enter'); + expect(resizable.isCollapsed).toBe(true); + expect(resizable.size).toBe(0); + expect(onCollapseChange).toHaveBeenLastCalledWith(true); + // A collapsed pane announces its minimum and says it is collapsed. + expect(handle.getAttribute('aria-valuenow')).toBe('100'); + expect(handle.getAttribute('aria-valuetext')).toBe('Collapsed'); + key(handle, 'Enter'); + expect(resizable.isCollapsed).toBe(false); + expect(resizable.size).toBe(200); + expect(handle.hasAttribute('aria-valuetext')).toBe(false); + + handle.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(resizable.isCollapsed).toBe(true); + handle.dispatchEvent(new MouseEvent('dblclick', {bubbles: true})); + expect(resizable.size).toBe(200); + }); + + it('collapses a collapsible region dragged under its collapse size', () => { + const {handle} = makeHandle(); + const resizable = create(handle, { + defaultSize: 200, + minSize: 100, + collapsible: true, + collapsedSize: 40, + }); + drag(handle, 200, 20); + expect(resizable.isCollapsed).toBe(true); + expect(resizable.size).toBe(0); + }); + + it('ignores pointer and keyboard input while disabled, says so, and leaves the tab order as ResizeHandle does', () => { + const {handle} = makeHandle(); + const resizable = create(handle, {defaultSize: 200, isDisabled: true}); + expect(handle.getAttribute('aria-disabled')).toBe('true'); + expect(handle.tabIndex).toBe(-1); + drag(handle, 0, 80); + key(handle, 'ArrowRight'); + expect(resizable.size).toBe(200); + }); + + it('holds the pointer for a handle in another document, such as an iframe, and puts the drag cursor on the page', () => { + const frame = document.createElement('iframe'); + document.body.append(frame); + const frameDocument = frame.contentDocument as Document; + const handle = frameDocument.createElement('div'); + frameDocument.body.append(handle); + let captured: number | null = null; + handle.setPointerCapture = (id: number) => { + captured = id; + }; + handle.releasePointerCapture = (id: number) => { + if (captured === id) { + captured = null; + } + }; + handle.hasPointerCapture = (id: number) => captured === id; + const sizes: number[] = []; + create(handle, {defaultSize: 200, onSizeChange: size => sizes.push(size)}); + pointer(handle, 'pointerdown', {x: 0}); + expect(captured).toBe(1); + expect(document.body.style.cursor).toBe('col-resize'); + pointer(handle, 'pointermove', {x: 30}); + pointer(handle, 'pointerup', {x: 30}); + expect(sizes.at(-1)).toBe(230); + expect(captured).toBeNull(); + expect(document.body.style.cursor).toBe(''); + }); + + it('takes new options with update()', () => { + const {handle} = makeHandle(); + const resizable = create(handle, { + defaultSize: 300, + minSize: 100, + maxSize: 400, + }); + resizable.update({defaultSize: 300, minSize: 100, maxSize: 250}); + expect(resizable.size).toBe(250); + expect(handle.getAttribute('aria-valuemax')).toBe('250'); + }); + + it('takes the full set of options in update(): the region keeps its size and collapse state, and an option left out returns to its default', () => { + const {handle} = makeHandle(); + const options = { + defaultSize: 300, + minSize: 200, + maxSize: 400, + collapsible: true, + label: 'Sidebar width', + }; + const resizable = create(handle, options); + resizable.resize(350); + resizable.collapse(); + resizable.update({...options, isDisabled: true}); + expect(resizable.isCollapsed).toBe(true); + expect(handle.getAttribute('aria-valuemin')).toBe('200'); + expect(handle.getAttribute('aria-label')).toBe('Sidebar width'); + expect(handle.getAttribute('aria-disabled')).toBe('true'); + resizable.update(options); + resizable.expand(); + expect(resizable.size).toBe(350); + // Left out, as on a new render: the bounds and the name return to their + // defaults, and a region that cannot collapse is open. + resizable.update({defaultSize: 300}); + expect(handle.getAttribute('aria-valuemin')).toBe('50'); + expect(handle.getAttribute('aria-label')).toBe('Resize handle'); + }); + + it('resizes a vertical region from vertical moves and the up and down arrows', () => { + const {handle} = makeHandle(); + const resizable = create(handle, { + direction: 'vertical', + defaultSize: 200, + minSize: 100, + maxSize: 400, + }); + expect(handle.getAttribute('aria-orientation')).toBe('horizontal'); + pointer(handle, 'pointerdown', {y: 0}); + pointer(handle, 'pointermove', {y: 30}); + pointer(handle, 'pointerup', {y: 30}); + expect(resizable.size).toBe(230); + key(handle, 'ArrowUp'); + expect(resizable.size).toBe(220); + }); +}); + +describe('createResizable on a plain element: persistence', () => { + it('saves its size once, when a drag ends, and a new factory on the same key restores it', () => { + const {handle} = makeHandle(); + create(handle, {defaultSize: 200, autoSaveId: 'sidebar'}); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + pointer(handle, 'pointerdown', {x: 0}); + pointer(handle, 'pointermove', {x: 20}); + pointer(handle, 'pointermove', {x: 40}); + pointer(handle, 'pointermove', {x: 60}); + expect(setItem).not.toHaveBeenCalled(); + pointer(handle, 'pointerup', {x: 60}); + expect(setItem).toHaveBeenCalledTimes(1); + expect( + JSON.parse(localStorage.getItem('astryx-resizable:sidebar') ?? ''), + ).toEqual({ + size: 260, + isCollapsed: false, + }); + + const next = makeHandle(); + expect( + create(next.handle, {defaultSize: 200, autoSaveId: 'sidebar'}).size, + ).toBe(260); + }); + + it('a drag the browser cancels ends there: the pointer is freed and the size it reached is saved once', () => { + const {handle, captured} = makeHandle(); + const resizable = create(handle, {defaultSize: 200, autoSaveId: 'pane'}); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + pointer(handle, 'pointerdown', {x: 0}); + pointer(handle, 'pointermove', {x: 30}); + pointer(handle, 'pointercancel', {x: 30}); + expect(captured()).toBeNull(); + expect(resizable.size).toBe(230); + expect(document.body.style.cursor).toBe(''); + expect(setItem).toHaveBeenCalledTimes(1); + }); +}); + +describe('createResizable on a plain element: destroy', () => { + /** Live listeners added through `target`, by type. */ + function trackListeners(target: EventTarget): Map { + const live = new Map(); + const add = target.addEventListener.bind(target); + const remove = target.removeEventListener.bind(target); + vi.spyOn(target, 'addEventListener').mockImplementation( + (type, listener, options) => { + live.set(type, (live.get(type) ?? 0) + 1); + add(type, listener, options); + }, + ); + vi.spyOn(target, 'removeEventListener').mockImplementation( + (type, listener, options) => { + live.set(type, (live.get(type) ?? 0) - 1); + remove(type, listener, options); + }, + ); + return live; + } + + function expectNone(live: Map) { + for (const [type, count] of live) { + expect({type, count: Math.max(count, 0)}).toEqual({type, count: 0}); + } + } + + it('leaves no listener, capture, attribute or body style behind, even mid-drag', () => { + const {handle, captured} = makeHandle(); + const before = handle.outerHTML; + const onHandle = trackListeners(handle); + const onDocument = trackListeners(document); + const onWindow = trackListeners(window); + const onSizeChange = vi.fn(); + const resizable = createResizable(handle, { + defaultSize: 200, + autoSaveId: 'mid-drag', + onSizeChange, + }); + pointer(handle, 'pointerdown', {x: 0}); + pointer(handle, 'pointermove', {x: 25}); + expect(document.body.style.cursor).not.toBe(''); + onSizeChange.mockClear(); + + resizable.destroy(); + expectNone(onHandle); + expectNone(onDocument); + expectNone(onWindow); + expect(captured()).toBeNull(); + expect(document.body.style.cursor).toBe(''); + expect(document.body.style.userSelect).toBe(''); + expect(handle.outerHTML).toBe(before); + // The size the interrupted drag reached is kept. + expect( + JSON.parse(localStorage.getItem('astryx-resizable:mid-drag') ?? ''), + ).toEqual({size: 225, isCollapsed: false}); + // Nothing answers afterwards, and a second destroy does nothing. + pointer(handle, 'pointermove', {x: 90}); + key(handle, 'ArrowRight'); + expect(onSizeChange).not.toHaveBeenCalled(); + resizable.destroy(); + }); +}); diff --git a/packages/core/src/Resizable/separatorAttributes.test.ts b/packages/core/src/Resizable/separatorAttributes.test.ts new file mode 100644 index 0000000000000..4c7f768c80028 --- /dev/null +++ b/packages/core/src/Resizable/separatorAttributes.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file separatorAttributes.test.ts + * @input vitest, separatorAttributes + * @output Checks the separator's ARIA attributes and tab stop for an open, + * collapsed, disabled and unbounded region, and for no region + * @position Unit tests for separatorAttributes.ts + */ + +import {describe, expect, it} from 'vitest'; +import {separatorAttributes} from './separatorAttributes'; + +const value = { + size: 240, + minPx: 100, + maxPx: 400, + isCollapsed: false, + collapsedText: 'Collapsed', +}; + +describe('separatorAttributes', () => { + it('announces an open region: its orientation, value, bounds and a tab stop', () => { + expect( + separatorAttributes({direction: 'horizontal', isDisabled: false, value}), + ).toEqual({ + 'aria-orientation': 'vertical', + 'aria-valuenow': 240, + 'aria-valuemin': 100, + 'aria-valuemax': 400, + 'aria-valuetext': undefined, + 'aria-disabled': undefined, + tabIndex: 0, + }); + }); + + it('announces a collapsed region at its minimum, with its collapsed text', () => { + const aria = separatorAttributes({ + direction: 'vertical', + isDisabled: false, + value: {...value, size: 0, isCollapsed: true}, + }); + expect(aria['aria-orientation']).toBe('horizontal'); + expect(aria['aria-valuenow']).toBe(100); + expect(aria['aria-valuetext']).toBe('Collapsed'); + }); + + it('takes a disabled separator out of the tab order and says it is disabled', () => { + const aria = separatorAttributes({ + direction: 'horizontal', + isDisabled: true, + value, + }); + expect(aria.tabIndex).toBe(-1); + expect(aria['aria-disabled']).toBe(true); + }); + + it('announces no maximum for an unbounded region, and no value without a region', () => { + expect( + separatorAttributes({ + direction: 'horizontal', + isDisabled: false, + value: {...value, maxPx: Infinity}, + })['aria-valuemax'], + ).toBeUndefined(); + const bare = separatorAttributes({ + direction: 'horizontal', + isDisabled: false, + }); + expect(bare['aria-valuenow']).toBeUndefined(); + expect(bare['aria-valuemin']).toBeUndefined(); + expect(bare['aria-valuemax']).toBeUndefined(); + expect(bare.tabIndex).toBe(0); + }); +}); diff --git a/packages/core/src/Resizable/useResizableConfig.test.tsx b/packages/core/src/Resizable/useResizableConfig.test.tsx index 52e4b1c9723ff..e584db2a364f3 100644 --- a/packages/core/src/Resizable/useResizableConfig.test.tsx +++ b/packages/core/src/Resizable/useResizableConfig.test.tsx @@ -26,13 +26,19 @@ interface FakeController extends ResizableController { setState(next: {size?: number; isCollapsed?: boolean}): void; } -const created: {options: CreateResizableOptions}[] = []; +const created: { + element: HTMLElement | null; + options: CreateResizableOptions; +}[] = []; let fake: FakeController; vi.mock('./createResizable', async importOriginal => { const original = await importOriginal(); - const createStandIn = (options: CreateResizableOptions = {}) => { - created.push({options}); + const createStandIn = ( + element: HTMLElement | null, + options: CreateResizableOptions = {}, + ) => { + created.push({element, options}); const listeners = new Set<() => void>(); let state = {size: 123, isCollapsed: false}; fake = { @@ -67,8 +73,11 @@ vi.mock('./createResizable', async importOriginal => { createResizable: vi.fn(createStandIn), // useResizable's own entry, which names its warnings. createResizableAs: vi.fn( - (_source: string, options?: CreateResizableOptions) => - createStandIn(options), + ( + _source: string, + element: HTMLElement | null, + options?: CreateResizableOptions, + ) => createStandIn(element, options), ), }; }); @@ -92,7 +101,7 @@ function ConfigSplit({ } describe('useResizable(config) builds on createResizable too, so the region state has one copy (spec:AST-107 FR6; architecture:public-component-api INV22)', () => { - it('creates one factory from the configuration, updates it, and destroys it on unmount', () => { + it('creates one factory, with no handle, from the configuration, updates it, and destroys it on unmount', () => { const first: UseResizableSingleConfig = { defaultSize: 200, minSize: 100, @@ -100,6 +109,7 @@ describe('useResizable(config) builds on createResizable too, so the region stat }; const view = render( {}} />); expect(created).toHaveLength(1); + expect(created[0].element).toBeNull(); expect(created[0].options).toMatchObject(first); const second: UseResizableSingleConfig = { From 57b4060e7058bba45ebcc8b79b06764372530d49 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 18:53:56 -0800 Subject: [PATCH 02/11] feat(Resizable): createResizable drives a plain handle element, and ResizeHandle and the factory derive the separator's attributes once createResizable(handle, options) takes a handle element, or null for a region with no handle yet. On an element it gives role="separator", a tab stop and a name when the element has none, its orientation and value, and the window splitter's input from resizeGesture.ts: a pointer drag that holds its pointer, the keys, Enter and a double-click. Its options add label, collapsedLabel, isReversed and isDisabled, and destroy takes all of it away. Its name and collapsed text are the shipped English messages, and collapsedLabel replaces the collapsed one. separatorAttributes derives the separator's ARIA and tab stop once. ResizeHandle renders it and the factory writes it on a plain element, so a disabled handle leaves the tab order in both. The configuration-object useResizable creates its factory with no handle. The factory stays internal, with a [chore] changeset. --- .changeset/create-resizable-plain-handle.md | 7 + packages/core/src/Resizable/ResizeHandle.tsx | 44 ++- .../core/src/Resizable/createResizable.ts | 269 ++++++++++++++++-- .../core/src/Resizable/separatorAttributes.ts | 72 +++++ packages/core/src/Resizable/useResizable.ts | 4 +- 5 files changed, 342 insertions(+), 54 deletions(-) create mode 100644 .changeset/create-resizable-plain-handle.md create mode 100644 packages/core/src/Resizable/separatorAttributes.ts diff --git a/.changeset/create-resizable-plain-handle.md b/.changeset/create-resizable-plain-handle.md new file mode 100644 index 0000000000000..d2496785edf24 --- /dev/null +++ b/.changeset/create-resizable-plain-handle.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] The internal resize factory can drive a plain handle element: it gives the element the separator's role, name, value and tab stop, and answers its pointer drag, keys, Enter and double-click. ResizeHandle and the factory derive the separator's attributes from one helper, with no behavior change. + +@vjeux diff --git a/packages/core/src/Resizable/ResizeHandle.tsx b/packages/core/src/Resizable/ResizeHandle.tsx index 8da9443be086d..f73da1158c6ed 100644 --- a/packages/core/src/Resizable/ResizeHandle.tsx +++ b/packages/core/src/Resizable/ResizeHandle.tsx @@ -35,6 +35,7 @@ import { type ResizeGesture, type ResizeGestureModel, } from './resizeGesture'; +import {separatorAttributes} from './separatorAttributes'; import {themeProps} from '../utils/themeProps'; import {useDevWarning} from '../hooks/useDevWarning'; @@ -529,19 +530,8 @@ export function ResizeHandle({ }, [hasRegion, regionGesture]); // --- ARIA --- - // When collapsed the panel's real size (0) sits below aria-valuemin, which - // is invalid per WCAG 4.1.2. Clamp aria-valuenow to the minimum and announce - // the true state via aria-valuetext instead; the valuetext is removed as - // soon as the panel expands so the numeric value reads again. const sizeNow = resizable?._size; const minNow = resizable?._minSizePx; - const ariaValueNow = - sizeNow == null || minNow == null - ? undefined - : isCollapsed - ? Math.max(sizeNow, minNow) - : sizeNow; - const ariaValueMin = minNow; // A separator with a value needs a maximum (WAI-ARIA, APG window splitter). // Unbounded regions report the basis they can fill; a hand-built props // object without it reports only a configured maximum. @@ -549,14 +539,22 @@ export function ResizeHandle({ ? (getEffectiveMaxPx(resizable) ?? (resizable._maxSizePx !== Infinity ? resizable._maxSizePx : undefined)) : undefined; - const ariaValueMax = - effectiveMax != null && Number.isFinite(effectiveMax) - ? effectiveMax - : undefined; - const ariaValueText = - resizable != null && isCollapsed - ? t('@astryx.resizable.collapsed') - : undefined; + // The attributes createResizable writes on a plain element, from the same + // derivation. + const aria = separatorAttributes({ + direction, + isDisabled, + value: + sizeNow == null || minNow == null + ? undefined + : { + size: sizeNow, + minPx: minNow, + maxPx: effectiveMax, + isCollapsed, + collapsedText: t('@astryx.resizable.collapsed'), + }, + }); // The Grip pill takes the pointer as the Grab zone does, so a pill drawn // outside the zone still resizes. @@ -591,14 +589,8 @@ export function ResizeHandle({
{ if (!isDisabled) { gesture?.doubleClick(); diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index 817586944b690..4a1570e099f46 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -2,14 +2,18 @@ /** * @file createResizable.ts - * @input A region's options + * @input A handle element, or null for a region with no handle yet, and the + * region's options * @output createResizable; readInitialResizableState for the hook's first - * render; createResizableAs and the internal channel for the hook - * @position The Resizable family's region, internal to the package. The - * configuration-object useResizable builds on it, and ResizeHandle drives - * it through resizeGesture.ts's model. Importing it does no DOM work + * render; createResizableAs and the internal channel for the hooks + * @position The Resizable family's factory, internal to the package. Its + * input is resizeGesture.ts: it listens with it on a plain handle, and + * ResizeHandle drives the region through its model. The + * configuration-object useResizable builds on it. No DOM work on import */ +import {registerEnglishFallback} from '../i18n/englishFallback'; +import english from '../i18n/generated-english/Resizable/createResizable.generated'; import {observeResize} from '../utils/sharedResizeObserver'; import {devWarn} from '../utils/devWarning'; import type {SizeValue} from '../utils/types'; @@ -29,9 +33,26 @@ import { type PersistedResizableState, type ResizableDirection, } from './resizableModel'; -import type {ResizeGestureModel, ResizeGestureState} from './resizeGesture'; +import { + createResizeGesture, + type ResizeGesture, + type ResizeGestureModel, + type ResizeGestureState, +} from './resizeGesture'; +import {separatorAttributes} from './separatorAttributes'; import type {ResizableSize} from './utils'; +// The English for the two messages it names, and nothing more of the catalog. +registerEnglishFallback(english); + +/** + * The catalog's messages for a plain handle: its name and what it announces + * while collapsed, when the options give neither `label` nor + * `collapsedLabel`. The factory uses the shipped English messages. + */ +const LABEL_KEY = '@astryx.resizable.handle.label'; +const COLLAPSED_LABEL_KEY = '@astryx.resizable.collapsed'; + export interface CreateResizableOptions { /** * Initial size. A number or an exact `Npx` is pixels, and an exact `N%` is @@ -72,6 +93,31 @@ export interface CreateResizableOptions { onSizeChange?: (size: number) => void; /** Called when an interaction collapses or expands the region. */ onCollapseChange?: (isCollapsed: boolean) => void; + /** + * The handle sits after the panel it resizes (the panel is at the end), so + * a drag toward the start grows it. + * @default false + */ + isReversed?: boolean; + /** + * Ignores pointer and keyboard input and says so; a plain element keeps its + * tab stop. + * @default false + */ + isDisabled?: boolean; + /** + * A plain element's accessible name, when it has none of its own. Without + * it, the factory uses the message catalog's English message. + * @default 'Resize handle' + */ + label?: string; + /** + * Replaces the catalog's "Collapsed" that the handle announces as its + * `aria-valuetext` while the region is collapsed. Absent, the factory + * announces the shipped English message. + * @default 'Collapsed' + */ + collapsedLabel?: string; } export interface ResizableController { @@ -96,7 +142,10 @@ export interface ResizableController { * that stops it. */ subscribe(listener: () => void): () => void; - /** Stops every observer the factory started; a gesture in flight ends. */ + /** + * Removes every listener, observer and attribute the factory added and the + * body's drag cursor; a drag in flight ends as cancelled. + */ destroy(): void; } @@ -117,8 +166,8 @@ function readWindowExtent(direction: ResizableDirection): number | null { const QUIET = {current: true}; /** - * The state a region with `options` starts in: the server's render and a - * hook's first render. A container is not measured + * The state a region with `options` starts in, before its handle exists: the + * server's render and a hook's first render. A container is not measured * yet, so a percentage of it resolves against the deterministic 1200px basis * until the factory measures it. `'server'` reads the state the server * rendered, for a render that hydrates it: the 1200px basis for a viewport @@ -239,16 +288,16 @@ interface ResizableInternals { syncContainer(): void; /** * Lets go of the page and keeps the region. A gesture in flight ends as - * cancelled, and the observers go. Collapse, expand and resize still apply - * and report. For a React caller whose component unmounts or an - * `` hides. + * cancelled, and the handle's listeners, its attributes and the observers + * go. Collapse, expand and resize still apply and report. For a React caller + * whose component unmounts or an `` hides. */ suspend(): void; - /** Takes the page back after `suspend`: the observers. */ + /** Takes the page back after `suspend`: the handle, and the observers. */ resume(): void; /** - * The region's side of a ResizeHandle's gesture: the drag and keys of a - * ResizeHandle given the configuration object's props. + * The region's side of a gesture the factory does not own: the drag and + * keys of a ResizeHandle given the configuration object's props. */ model: ResizeGestureModel; } @@ -262,16 +311,64 @@ export function readResizableInternals( return internals.get(controller); } +/** An attribute's value as the DOM takes it; undefined removes it. */ +function toAttribute( + value: string | number | boolean | undefined, +): string | null { + return value == null ? null : String(value); +} + +/** The attributes the factory writes on a handle, restored by `destroy`. */ +const MANAGED_ATTRIBUTES = [ + 'role', + 'tabindex', + 'aria-label', + 'aria-orientation', + 'aria-valuenow', + 'aria-valuemin', + 'aria-valuemax', + 'aria-valuetext', + 'aria-disabled', + 'data-resizing', +] as const; + /** - * A resizable region, without React. The factory never sizes the panel: it - * reports the size, and the caller applies it. It resolves, clamps, snaps, - * collapses and saves the size, and a percentage follows its basis. - * `destroy` stops its observers. + * Make `handle` a window splitter that resizes a region. The factory never + * sizes the panel: it reports the size, and the caller applies it. + * + * It gives the handle `role="separator"`, its orientation and its value. It + * adds a tab stop and an accessible name when the handle has none. It answers + * a pointer drag, the arrow keys, Home and End, and holds the pointer for a + * whole drag. Enter and a double-click collapse and restore. A horizontal + * region resizes along the inline axis, and under right-to-left the drag + * follows the pointer. `destroy` takes all of it away. With a null handle the + * region still answers collapse, expand, resize and subscribe, and touches no + * element. Outside a provider, its name and its collapsed text are the + * shipped English messages. `label` names it, and `collapsedLabel` replaces + * its collapsed text in another language. + * + * @example + * ``` + * const sidebar = document.querySelector('#sidebar'); + * const resizable = createResizable(document.querySelector('#divider'), { + * defaultSize: 260, + * minSize: 180, + * maxSize: 480, + * collapsible: true, + * autoSaveId: 'sidebar', + * }); + * const paint = () => { + * sidebar.style.width = `${resizable.size}px`; + * }; + * paint(); + * resizable.subscribe(paint); + * ``` */ export function createResizable( + handle: HTMLElement | null, options: CreateResizableOptions = {}, ): ResizableController { - return createResizableAs('createResizable', options); + return createResizableAs('createResizable', handle, options); } /** @@ -287,6 +384,7 @@ export type ResizableWarningSource = 'createResizable' | 'useResizable'; */ export function createResizableAs( source: ResizableWarningSource, + handle: HTMLElement | null, options: CreateResizableOptions = {}, ): ResizableController { // The configuration-object useResizable keeps its warnings word for word. @@ -296,6 +394,8 @@ export function createResizableAs( // Let go of the page by `suspend`, with the region kept. let isSuspended = false; const listeners = new Set<() => void>(); + const isReversed = (): boolean => opts.isReversed ?? false; + const isDisabled = (): boolean => opts.isDisabled ?? false; const didWarn = { min: {current: false}, max: {current: false}, @@ -330,7 +430,7 @@ export function createResizableAs( let uncontrolledIsCollapsed = persisted?.isCollapsed ?? opts.defaultIsCollapsed ?? false; - // What the region last computed, notified and wrote. + // What the region last computed, notified, wrote, and announced. let derived = { size: 0, isCollapsed: false, @@ -518,13 +618,13 @@ export function createResizableAs( derived = {size, isCollapsed, min, max, effectiveMax, isBasisMeasured}; } - /** Recompute, then bring the observers, storage and subscribers along. */ + /** Recompute, then bring the observers, the handle, storage and subscribers along. */ function commit(): void { if (isDestroyed) { return; } if (isSuspended) { - // No observers: the last measured basis stands. + // No observers and no handle: the last measured basis stands. compute(); } else { // The observation a computation needs can change with it (a default @@ -542,6 +642,9 @@ export function createResizableAs( } } } + if (handle != null && !isSuspended) { + announce(); + } persist(); const reported = { size: derived.isCollapsed ? 0 : derived.size, @@ -606,6 +709,59 @@ export function createResizableAs( } } + const original = new Map(); + if (handle != null) { + for (const name of MANAGED_ATTRIBUTES) { + original.set(name, handle.getAttribute(name)); + } + } + const hasOwnName = + handle != null && + (handle.hasAttribute('aria-label') || + handle.hasAttribute('aria-labelledby')); + + const setAttribute = (name: string, value: string | null) => { + if (handle == null) { + return; + } + if (value == null) { + handle.removeAttribute(name); + } else if (handle.getAttribute(name) !== value) { + handle.setAttribute(name, value); + } + }; + + function announce(): void { + if (original.get('role') == null) { + setAttribute('role', 'separator'); + } + if (!hasOwnName) { + setAttribute('aria-label', opts.label ?? english[LABEL_KEY]); + } + const {size, isCollapsed, min, effectiveMax} = derived; + // The attributes ResizeHandle renders, from the same derivation. + const aria = separatorAttributes({ + direction: direction(), + isDisabled: isDisabled(), + value: { + size: isCollapsed ? 0 : size, + minPx: min, + maxPx: effectiveMax, + isCollapsed, + collapsedText: opts.collapsedLabel ?? english[COLLAPSED_LABEL_KEY], + }, + }); + if (original.get('tabindex') == null) { + setAttribute('tabindex', String(aria.tabIndex)); + } + setAttribute('aria-orientation', aria['aria-orientation']); + setAttribute('aria-valuenow', toAttribute(aria['aria-valuenow'])); + setAttribute('aria-valuemin', toAttribute(aria['aria-valuemin'])); + setAttribute('aria-valuemax', toAttribute(aria['aria-valuemax'])); + setAttribute('aria-valuetext', toAttribute(aria['aria-valuetext'])); + setAttribute('aria-disabled', toAttribute(aria['aria-disabled'])); + } + const setCollapsed = (value: boolean) => { if (!isControlled()) { uncontrolledIsCollapsed = value; @@ -736,8 +892,60 @@ export function createResizableAs( commit(); } - /** The observers go; a gesture in flight has ended first. */ + const gesture: ResizeGesture = createResizeGesture( + () => handle, + model, + () => ({ + direction: direction(), + isReversed: isReversed(), + isDisabled: isDisabled(), + }), + dragging => { + setAttribute('data-resizing', dragging ? '' : null); + }, + ); + + // --- Listeners on the handle --- + const onPointerDown = (event: PointerEvent) => { + gesture.pointerDown(event); + }; + const onPointerMove = (event: PointerEvent) => { + gesture.pointerMove(event); + }; + const onPointerUp = (event: PointerEvent) => { + gesture.pointerUp(event); + }; + const onPointerCancel = (event: PointerEvent) => { + gesture.pointerCancel(event); + }; + const onKeyDown = (event: KeyboardEvent) => { + gesture.keyDown(event); + }; + const onDoubleClick = () => { + gesture.doubleClick(); + }; + function bindHandle(): void { + handle?.addEventListener('pointerdown', onPointerDown); + handle?.addEventListener('pointermove', onPointerMove); + handle?.addEventListener('pointerup', onPointerUp); + handle?.addEventListener('pointercancel', onPointerCancel); + handle?.addEventListener('lostpointercapture', onPointerCancel); + handle?.addEventListener('keydown', onKeyDown); + handle?.addEventListener('dblclick', onDoubleClick); + } + + /** + * The handle's listeners and the observers go, and the handle gets its own + * attributes back; a gesture in flight has ended first. + */ function letGoOfPage(): void { + handle?.removeEventListener('pointerdown', onPointerDown); + handle?.removeEventListener('pointermove', onPointerMove); + handle?.removeEventListener('pointerup', onPointerUp); + handle?.removeEventListener('pointercancel', onPointerCancel); + handle?.removeEventListener('lostpointercapture', onPointerCancel); + handle?.removeEventListener('keydown', onKeyDown); + handle?.removeEventListener('dblclick', onDoubleClick); stopObservingContainer?.(); stopObservingContainer = null; containerElement = null; @@ -745,8 +953,12 @@ export function createResizableAs( window.removeEventListener('resize', onViewportResize); isObservingViewport = false; } + for (const [name, value] of original) { + setAttribute(name, value); + } } + bindHandle(); commit(); const controller: ResizableController = { @@ -780,8 +992,11 @@ export function createResizableAs( if (isDestroyed) { return; } - // A gesture a ResizeHandle drove through the region's model ends first, - // so the size it reached is saved. + // A drag in flight ends first, as cancelled, so the size it reached is + // saved and the pointer and the body's cursor are released. + gesture.interrupt(); + // A gesture a ResizeHandle drove through the region's model, rather + // than the factory's own, ends the same way. endGesture(); isDestroyed = true; if (!isSuspended) { @@ -804,6 +1019,7 @@ export function createResizableAs( if (isDestroyed || isSuspended) { return; } + gesture.interrupt(); endGesture(); isSuspended = true; letGoOfPage(); @@ -813,6 +1029,7 @@ export function createResizableAs( return; } isSuspended = false; + bindHandle(); commit(); }, syncContainer() { diff --git a/packages/core/src/Resizable/separatorAttributes.ts b/packages/core/src/Resizable/separatorAttributes.ts new file mode 100644 index 0000000000000..acfe0f6dfbb36 --- /dev/null +++ b/packages/core/src/Resizable/separatorAttributes.ts @@ -0,0 +1,72 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file separatorAttributes.ts + * @input A window splitter's axis and disabled state, and its region's size, + * bounds, collapse state and collapsed text + * @output separatorAttributes: the separator's ARIA attributes and tab stop, + * which ResizeHandle renders and createResizable writes on a plain element + * @position Internal to the Resizable family. No React, and no DOM work on + * import + */ + +import type {ResizableDirection} from './resizableModel'; + +/** The region a separator resizes, as it announces it. */ +export interface SeparatorValue { + /** The region's size in px, 0 while it is collapsed. */ + readonly size: number; + readonly minPx: number; + /** The maximum to announce, or undefined when the region has none. */ + readonly maxPx: number | undefined; + readonly isCollapsed: boolean; + /** The value text a collapsed separator announces. */ + readonly collapsedText: string; +} + +export interface SeparatorState { + readonly direction: ResizableDirection; + readonly isDisabled: boolean; + /** The region's value; a separator with no region has none. */ + readonly value?: SeparatorValue; +} + +export interface SeparatorAttributes { + readonly 'aria-orientation': 'horizontal' | 'vertical'; + readonly 'aria-valuenow': number | undefined; + readonly 'aria-valuemin': number | undefined; + readonly 'aria-valuemax': number | undefined; + readonly 'aria-valuetext': string | undefined; + readonly 'aria-disabled': true | undefined; + readonly tabIndex: 0 | -1; +} + +/** + * The separator's ARIA attributes and tab stop. A collapsed region's real + * size, 0, sits below aria-valuemin, which is invalid. So a collapsed + * separator announces its minimum, and its value text says it is collapsed. + * A disabled separator leaves the tab order. + */ +export function separatorAttributes( + state: SeparatorState, +): SeparatorAttributes { + const {value} = state; + return { + 'aria-orientation': + state.direction === 'horizontal' ? 'vertical' : 'horizontal', + 'aria-valuenow': + value == null + ? undefined + : value.isCollapsed + ? Math.max(value.size, value.minPx) + : value.size, + 'aria-valuemin': value?.minPx, + 'aria-valuemax': + value?.maxPx != null && Number.isFinite(value.maxPx) + ? value.maxPx + : undefined, + 'aria-valuetext': value?.isCollapsed ? value.collapsedText : undefined, + 'aria-disabled': state.isDisabled || undefined, + tabIndex: state.isDisabled ? -1 : 0, + }; +} diff --git a/packages/core/src/Resizable/useResizable.ts b/packages/core/src/Resizable/useResizable.ts index 1192dd762afca..9b65f62accf94 100644 --- a/packages/core/src/Resizable/useResizable.ts +++ b/packages/core/src/Resizable/useResizable.ts @@ -521,7 +521,7 @@ function useResizableController( return; } const passed = optionsRef.current; - const resizable = createResizableAs('useResizable', passed); + const resizable = createResizableAs('useResizable', null, passed); slot.set({resizable, passed}); }, [slot]); useIsomorphicLayoutEffect(() => { @@ -598,7 +598,7 @@ function useResizableController( return entry.resizable; } const passed = optionsRef.current; - const resizable = createResizableAs('useResizable', passed); + const resizable = createResizableAs('useResizable', null, passed); readResizableInternals(resizable)?.suspend(); slot.set({resizable, passed}); return resizable; From cfa444d48a4964fa00019549c882f66046674d38 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 18:53:56 -0800 Subject: [PATCH 03/11] docs(i18n): architecture:internationalization's INV3a evidence gap narrows to the labels key A factory test now covers a flat option: the factory announces collapsedLabel in place of the catalog's "Collapsed" on a plain handle. The gap that remains is a labels key. --- docs/architecture/internationalization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/internationalization.md b/docs/architecture/internationalization.md index 6528a4688b2a4..19217974ebfcb 100644 --- a/docs/architecture/internationalization.md +++ b/docs/architecture/internationalization.md @@ -440,7 +440,7 @@ callers remain conformance work and do not relax INV1a, INV8, INV9, or INV10. | INV1, INV1a, INV7 | hardcoded-string lint, catalog tests, component translation tests with a non-English provider | Astryx-owned text bypasses the catalog, an announcement or count is assembled from fragments or a hand-rolled plural, or non-string output reaches visible/AT attributes | | INV2, INV13, INV14 | export drift checks, public i18n tests, compatibility review | A parallel/replacement runtime lands without migration, or client hooks claim server behavior | | INV3–INV5 | resolver and provider rerender tests | no-provider output depends on the host, locale swaps stay stale, fallback order changes, or malformed-locale behavior is described as graceful | -| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a flat option or `labels` key yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent | +| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a `labels` key yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent | | INV6 | `check:i18n-catalog`, key-format lint, and focused mutation tests | stale/malformed keys, malformed ICU, or source/translation runtime-contract drift passes CI | | INV15 | generator tests, `check:i18n-runtime`, package-export drift check, provider/resolver runtime-catalog tests; per-module English resolution-parity test (every key each Core module names, in every shipped locale’s fallback path, its own map versus the whole catalog); import-cost check (component bundles carry only the English they name; public paths keep the whole catalog) | a generated module carries descriptions or is committed, a malformed entry projects instead of failing, a rich export path changes, or a `RuntimeCatalog` input is rejected or leaks a non-rich context shape, a key a module names resolves differently from the whole catalog, a component bundle carries the whole catalog, or a public path stops resolving a shipped key | | INV16 | build-plugin unit and real-build tests | a rich or generated locale import fails to resolve through the standard plugin | From e3b52e490488b7d3936f70b3cdbf968dd0e23d60 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 22:08:00 -0800 Subject: [PATCH 04/11] test(Resizable): a disabled handle keeps its tab stop, on ResizeHandle and on a factory's plain element component:Resizable FR3 keeps a disabled Handle's tab stop, as spec:AST-085 FR1 does for every disabled control, so keyboard users can reach it and hear that it is disabled. ResizeHandle, the factory on a plain element and separatorAttributes each keep tabindex 0 while disabled. They fail here: a disabled separator leaves the tab order. --- packages/core/src/Resizable/ResizeHandle.test.tsx | 4 ++-- packages/core/src/Resizable/createResizableHandle.test.ts | 4 ++-- packages/core/src/Resizable/separatorAttributes.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Resizable/ResizeHandle.test.tsx b/packages/core/src/Resizable/ResizeHandle.test.tsx index 44e1b92f8c7cf..a7aa2d8318ebc 100644 --- a/packages/core/src/Resizable/ResizeHandle.test.tsx +++ b/packages/core/src/Resizable/ResizeHandle.test.tsx @@ -896,10 +896,10 @@ describe('ResizeHandle', () => { // --- Disabled guard --- - it('ignores keyboard input when disabled', () => { + it('ignores keyboard input when disabled, and keeps its tab stop', () => { render(); const separator = getSeparator(); - expect(separator).toHaveAttribute('tabindex', '-1'); + expect(separator).toHaveAttribute('tabindex', '0'); fireEvent.keyDown(separator, {key: 'ArrowRight'}); expect(separator).toHaveAttribute('aria-valuenow', '200'); }); diff --git a/packages/core/src/Resizable/createResizableHandle.test.ts b/packages/core/src/Resizable/createResizableHandle.test.ts index 7fc96398ab11c..c675b2c90fe07 100644 --- a/packages/core/src/Resizable/createResizableHandle.test.ts +++ b/packages/core/src/Resizable/createResizableHandle.test.ts @@ -273,11 +273,11 @@ describe('createResizable on a plain element', () => { expect(resizable.size).toBe(0); }); - it('ignores pointer and keyboard input while disabled, says so, and leaves the tab order as ResizeHandle does', () => { + it('ignores pointer and keyboard input while disabled, says so, and keeps its tab stop, as ResizeHandle does', () => { const {handle} = makeHandle(); const resizable = create(handle, {defaultSize: 200, isDisabled: true}); expect(handle.getAttribute('aria-disabled')).toBe('true'); - expect(handle.tabIndex).toBe(-1); + expect(handle.tabIndex).toBe(0); drag(handle, 0, 80); key(handle, 'ArrowRight'); expect(resizable.size).toBe(200); diff --git a/packages/core/src/Resizable/separatorAttributes.test.ts b/packages/core/src/Resizable/separatorAttributes.test.ts index 4c7f768c80028..0bbae066e0480 100644 --- a/packages/core/src/Resizable/separatorAttributes.test.ts +++ b/packages/core/src/Resizable/separatorAttributes.test.ts @@ -45,13 +45,13 @@ describe('separatorAttributes', () => { expect(aria['aria-valuetext']).toBe('Collapsed'); }); - it('takes a disabled separator out of the tab order and says it is disabled', () => { + it('keeps a disabled separator in the tab order and says it is disabled', () => { const aria = separatorAttributes({ direction: 'horizontal', isDisabled: true, value, }); - expect(aria.tabIndex).toBe(-1); + expect(aria.tabIndex).toBe(0); expect(aria['aria-disabled']).toBe(true); }); From 12b02f52f43ce57e5668de7bbb5e467c1a5ce594 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 22:10:00 -0800 Subject: [PATCH 05/11] fix(Resizable): a disabled handle keeps its tab stop, as component:Resizable FR3 says separatorAttributes gave a disabled separator tabIndex -1, so a disabled ResizeHandle left the tab order and the factory wrote the same gap onto a plain element. FR3, after spec:AST-085 FR1, keeps a disabled Handle's tab stop so keyboard users can reach it and hear that it is disabled. The derivation now keeps tabIndex 0, and both still ignore input and say aria-disabled. A [fix] changeset records it. --- .changeset/resize-handle-disabled-tab-stop.md | 7 +++++++ packages/core/src/Resizable/separatorAttributes.ts | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .changeset/resize-handle-disabled-tab-stop.md diff --git a/.changeset/resize-handle-disabled-tab-stop.md b/.changeset/resize-handle-disabled-tab-stop.md new file mode 100644 index 0000000000000..18a949a0c0ca3 --- /dev/null +++ b/.changeset/resize-handle-disabled-tab-stop.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A disabled `ResizeHandle` keeps its tab stop, so keyboard users can reach it and hear that it is disabled. It still ignores pointer and keyboard input and says so with `aria-disabled`. + +@vjeux diff --git a/packages/core/src/Resizable/separatorAttributes.ts b/packages/core/src/Resizable/separatorAttributes.ts index acfe0f6dfbb36..38f67409feade 100644 --- a/packages/core/src/Resizable/separatorAttributes.ts +++ b/packages/core/src/Resizable/separatorAttributes.ts @@ -38,14 +38,15 @@ export interface SeparatorAttributes { readonly 'aria-valuemax': number | undefined; readonly 'aria-valuetext': string | undefined; readonly 'aria-disabled': true | undefined; - readonly tabIndex: 0 | -1; + readonly tabIndex: 0; } /** * The separator's ARIA attributes and tab stop. A collapsed region's real * size, 0, sits below aria-valuemin, which is invalid. So a collapsed * separator announces its minimum, and its value text says it is collapsed. - * A disabled separator leaves the tab order. + * A disabled separator keeps its tab stop, so keyboard users can reach it and + * hear that it is disabled. */ export function separatorAttributes( state: SeparatorState, @@ -67,6 +68,6 @@ export function separatorAttributes( : undefined, 'aria-valuetext': value?.isCollapsed ? value.collapsedText : undefined, 'aria-disabled': state.isDisabled || undefined, - tabIndex: state.isDisabled ? -1 : 0, + tabIndex: 0, }; } From 7ba6a7c34f24f4db89b5b760ef7e5a3efeef6980 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 22:10:00 -0800 Subject: [PATCH 06/11] docs(Resizable): the records drop the closed tab-stop gap, and INV3a's evidence gap names the provider case component:Resizable no longer lists a disabled Handle leaving the tab order: its Current-state line goes, the disabled state row no longer calls the kept tab stop a gap, and the AST-085 relation no longer says FR3 records one. architecture:internationalization's INV3a evidence gap now reads: no factory test covers a labels key, or a flat option under a provider, yet. --- docs/architecture/internationalization.md | 2 +- docs/specs/components/Resizable/Resizable.spec.md | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/architecture/internationalization.md b/docs/architecture/internationalization.md index 19217974ebfcb..a1f4c8c692f8d 100644 --- a/docs/architecture/internationalization.md +++ b/docs/architecture/internationalization.md @@ -440,7 +440,7 @@ callers remain conformance work and do not relax INV1a, INV8, INV9, or INV10. | INV1, INV1a, INV7 | hardcoded-string lint, catalog tests, component translation tests with a non-English provider | Astryx-owned text bypasses the catalog, an announcement or count is assembled from fragments or a hand-rolled plural, or non-string output reaches visible/AT attributes | | INV2, INV13, INV14 | export drift checks, public i18n tests, compatibility review | A parallel/replacement runtime lands without migration, or client hooks claim server behavior | | INV3–INV5 | resolver and provider rerender tests | no-provider output depends on the host, locale swaps stay stale, fallback order changes, or malformed-locale behavior is described as graceful | -| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a `labels` key yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent | +| INV3a | factory unit tests: a flat option or `labels` key replaces its message, including under a provider, and outside a provider its absence announces or shows shipped English; wrapper tests: under a non-English provider its absence gives the translation. Evidence gap: no factory test covers a `labels` key, or a flat option under a provider, yet | a factory announces or shows Astryx text that no option or `labels` key can replace, groups more than one string outside one `labels` object, takes a translator, ignores a given string, or gives something other than the catalog message when it is absent | | INV6 | `check:i18n-catalog`, key-format lint, and focused mutation tests | stale/malformed keys, malformed ICU, or source/translation runtime-contract drift passes CI | | INV15 | generator tests, `check:i18n-runtime`, package-export drift check, provider/resolver runtime-catalog tests; per-module English resolution-parity test (every key each Core module names, in every shipped locale’s fallback path, its own map versus the whole catalog); import-cost check (component bundles carry only the English they name; public paths keep the whole catalog) | a generated module carries descriptions or is committed, a malformed entry projects instead of failing, a rich export path changes, or a `RuntimeCatalog` input is rejected or leaks a non-rich context shape, a key a module names resolves differently from the whole catalog, a component bundle carries the whole catalog, or a public path stops resolving a shipped key | | INV16 | build-plugin unit and real-build tests | a rich or generated locale import fails to resolve through the standard plugin | diff --git a/docs/specs/components/Resizable/Resizable.spec.md b/docs/specs/components/Resizable/Resizable.spec.md index dcb33663d87f4..078cca69e009b 100644 --- a/docs/specs/components/Resizable/Resizable.spec.md +++ b/docs/specs/components/Resizable/Resizable.spec.md @@ -89,9 +89,6 @@ Consumer migration instructions belong in consumer docs and release notes. does not reflect it, so a theme rule for one direction never matches, where `architecture:component-theming-surface` INV6 keeps declared state on the owning target. -- **Disabled Handle leaves the tab order (FR3, AR1).** A disabled Handle sets - `tabIndex={-1}`, so keyboard users cannot reach it to hear that it is - disabled, where `spec:AST-085` FR1 keeps a disabled control's tab stop. - **Window-splitter gaps (AR1).** The Handle names the pane it resizes with `aria-controls` only when its caller passes one; an unbounded Handle in a container that no percentage bound observes carries no `aria-valuemax`; and @@ -175,7 +172,7 @@ Consumer migration instructions belong in consumer docs and release notes. | Custom handle content | Handle and Grab zone remain; caller content replaces the pill. | Caller content. | | Horizontal or vertical | Interaction follows the axis; ARIA orientation is the divider's. | Size, reversal, pill placement. | | Collapsed | `aria-valuenow` at the minimum and "Collapsed" value text. | Pill side. | -| Disabled | `aria-disabled`; no resize; tab stop kept (FR3 gap). | Disabled paint. | +| Disabled | `aria-disabled`; no resize; tab stop kept. | Disabled paint. | ### Transformation and precedence order @@ -258,7 +255,7 @@ Consumer migration instructions belong in consumer docs and release notes. - `spec:AST-010` owns structured sizing; FR5 and FR6 apply it. - `spec:AST-020` FR15 adopts the APG Window Splitter for the Handle; AR1 applies it and records its gaps. -- `spec:AST-085` owns disabled-control behavior; FR3 records the tab-order gap. +- `spec:AST-085` owns disabled-control behavior. - `architecture:component-theming-surface` owns target mapping and state reflection; FR4 records the `direction` gap. - `architecture:interaction-modality` owns shared modality and focus From bad653278d108c7fd47fcec42c70b775643f9ddf Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 22:24:39 -0800 Subject: [PATCH 07/11] docs(Resizable): the tab-stop changeset names the old behavior, the new one and its target An accessibility [fix] names what changed from what, and toward what: before, a disabled ResizeHandle left the tab order; now it keeps its tab stop, as a disabled control does. --- .changeset/resize-handle-disabled-tab-stop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/resize-handle-disabled-tab-stop.md b/.changeset/resize-handle-disabled-tab-stop.md index 18a949a0c0ca3..96752d81bd1b8 100644 --- a/.changeset/resize-handle-disabled-tab-stop.md +++ b/.changeset/resize-handle-disabled-tab-stop.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A disabled `ResizeHandle` keeps its tab stop, so keyboard users can reach it and hear that it is disabled. It still ignores pointer and keyboard input and says so with `aria-disabled`. +[fix] A disabled `ResizeHandle` keeps its tab stop. Before, it left the tab order, so keyboard users could not reach it to hear that it is disabled. Now it stays in the tab order, as a disabled control keeps its tab stop, and it still ignores pointer and keyboard input and says so with `aria-disabled`. @vjeux From 6cc767a93c5406b01bc5e2f9ae93602c24fd1376 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 00:45:20 -0800 Subject: [PATCH 08/11] docs(Resizable): FR3 drops the disabled tab-stop gap, and the disabled state's summary keeps it in the Tab order The fix below made FR3's implementation gap false: a disabled Handle keeps tabindex 0. FR3 drops that sentence, and its verification row no longer lists the disabled tab stop as untested, since ResizeHandle.test.tsx covers it. The disabled binding state's summary says the handle stays in the Tab order. A comment on the first-render read says why it starts as warned. --- docs/specs/components/Resizable/Resizable.spec.md | 4 ++-- .../core/src/Resizable/__tests__/ResizeHandle.a11y.states.ts | 2 +- packages/core/src/Resizable/createResizable.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/components/Resizable/Resizable.spec.md b/docs/specs/components/Resizable/Resizable.spec.md index 078cca69e009b..4ef0676b3c069 100644 --- a/docs/specs/components/Resizable/Resizable.spec.md +++ b/docs/specs/components/Resizable/Resizable.spec.md @@ -144,7 +144,7 @@ Consumer migration instructions belong in consumer docs and release notes. | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | FR1 | ResizeHandle renders one focusable Handle and one enlarged Grab zone (a `--spacing-4` strip on the resize axis); the default render also contains a Grip pill, and caller `children` replace the pill. The grab zone is centred on the Handle for every `pillPlacement`, and the painted pill is its own grab target. On a pane too short for the full strip, the zone shrinks so the pane keeps at least half of itself. A pill shown only on hover also takes the pointer across the gap between it and the Grab zone, never past the pill's far side. | Current source, docs, tests, and an owner ruling | | FR2 | The Handle carries `resize-handle`, the Grip pill carries `resize-handle-pill`, and the Grab zone carries no public target. | Current source and docs | -| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by ResizeHandle and useResizable; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. Implementation gap: a disabled Handle sets `tabIndex={-1}` (Current-state impact). | Current source, docs, and tests | +| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by ResizeHandle and useResizable; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. | Current source, docs, and tests | | FR4 | The Handle reflects the documented `direction` visual state on its target (`architecture:component-theming-surface` INV6). Implementation gap: it reflects no `direction` (Current-state impact). | Current source and docs | | FR5 | A `percent(value, {min: pixel(value)})` or `percent(value, {max: pixel(value)})` resolves against one basis — the supplied container, or the viewport when no `containerRef` is given (`spec:AST-010` FR1, FR2); as a default it selects pixels once, and as a bound it stays live and clamps the selected pixels. A descriptor with both or neither bound is invalid. Selected state, persistence, callbacks, paint, and ARIA are pixels. | `spec:AST-010`; current source and tests | | FR6 | `minSize` and `maxSize` are the bound props; numeric values are pixels. When resolved bounds conflict, the maximum wins. | `spec:AST-010`; current source and tests | @@ -269,7 +269,7 @@ Consumer migration instructions belong in consumer docs and release notes. | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | | FR1 | `ResizeHandle.test.tsx`; `grabZone.interaction.browser.spec.ts` for the zone's place and its short-pane limit; stable visual baseline `core-resizable--horizontal-split` | Default, collapsed, both axes | Handle and Grab zone regressions fail focused tests; a changed default handle comes back changed for review. Gap: no test renders `children` to prove they replace the Grip pill. | `audit:Resizable/anatomy` | | FR2 | `themingTargets.test.ts`; source review | Handle, Grip pill, Grab zone | A removed target fails the inventory. `themingTargets.test.ts` catches only a renamed or undocumented target; no check fails if the Handle or Grip pill stops carrying its target. | `audit:Resizable/theming` | -| FR3 | `ResizeHandle.test.tsx` and `useResizable.test.ts`; storage timing: `ResizeHandle.test.tsx` ("saves the size once, when a drag ends on …", "saves the dragged size once when the split unmounts mid-drag", "still saves each keyboard step at once") and `useResizable.test.ts` ("writes the size a drag reached once, after … releases it", "writes the final size once when the release comes before the last move renders", "keeps the size a region was dragged to when it unmounts mid-drag", "writes only the re-clamped size when the container shrank mid-drag") | Pointer, keyboard, snap, collapse, persistence; RTL drag; disabled keyboard | A changed transformation, an RTL drag that moves against the pointer, keyboard input that resizes a disabled Handle, or a per-move, stale or missing final storage write fails. Gap: no test covers disabled pointer input or the disabled tab stop. | `audit:Resizable/behavior` | +| FR3 | `ResizeHandle.test.tsx` and `useResizable.test.ts`; storage timing: `ResizeHandle.test.tsx` ("saves the size once, when a drag ends on …", "saves the dragged size once when the split unmounts mid-drag", "still saves each keyboard step at once") and `useResizable.test.ts` ("writes the size a drag reached once, after … releases it", "writes the final size once when the release comes before the last move renders", "keeps the size a region was dragged to when it unmounts mid-drag", "writes only the re-clamped size when the container shrank mid-drag") | Pointer, keyboard, snap, collapse, persistence; RTL drag; disabled keyboard | A changed transformation, an RTL drag that moves against the pointer, keyboard input that resizes a disabled Handle, or a per-move, stale or missing final storage write fails. Gap: no test covers disabled pointer input. | `audit:Resizable/behavior` | | FR4 | Source and docs comparison | Horizontal and vertical Handle | Gap: no test detects that the documented `direction` is not reflected. | `audit:Resizable/theming` | | FR5, ORD1–ORD3 | `useResizable.test.ts`, `utils.test.ts`, typecheck | Floor, ceiling, default; invalid shapes; basis changes; numeric container parity | Wrong resolution, a rescaled default, a stale clamp, an observer leak, or state, paint, storage, and ARIA that disagree fails. | `audit:Resizable/behavior` | | FR6 | `useResizable.test.ts`; typecheck | Numeric bounds; conflicting bounds | A removed alias that type-checks, a numeric bound that is not pixels, or a minimum that wins a conflict fails. | `audit:Resizable/behavior` | diff --git a/packages/core/src/Resizable/__tests__/ResizeHandle.a11y.states.ts b/packages/core/src/Resizable/__tests__/ResizeHandle.a11y.states.ts index 7b33f6e8458e8..d05c4863e8a08 100644 --- a/packages/core/src/Resizable/__tests__/ResizeHandle.a11y.states.ts +++ b/packages/core/src/Resizable/__tests__/ResizeHandle.a11y.states.ts @@ -165,7 +165,7 @@ export const RESIZE_HANDLE_BINDING_STATES = [ }, { id: 'bare-disabled', - summary: 'a disabled ResizeHandle: out of the Tab order and unmoving', + summary: 'a disabled ResizeHandle: in the Tab order and unmoving', storyId: story('bare-disabled'), component: 'ResizeHandle', label: 'Resize details pane', diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index 4a1570e099f46..ecc5a7f1d1861 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -162,7 +162,7 @@ function readWindowExtent(direction: ResizableDirection): number | null { return direction === 'vertical' ? window.innerHeight : window.innerWidth; } -/** A warning already given, so a resolution repeated silently stays quiet. */ +/** Starts as warned: the factory warns, and this first-render read does not. */ const QUIET = {current: true}; /** From 278bea055f80fa5b84a02a7caac9282fc91b5824 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 02:24:11 -0800 Subject: [PATCH 09/11] chore(Resizable): drop a createResizable divider that restates the code under it --- packages/core/src/Resizable/createResizable.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index ecc5a7f1d1861..7d036e21091c6 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -905,7 +905,6 @@ export function createResizableAs( }, ); - // --- Listeners on the handle --- const onPointerDown = (event: PointerEvent) => { gesture.pointerDown(event); }; From 275a75dd2275cbda62d37ae892979271e550e38d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 03:43:41 -0800 Subject: [PATCH 10/11] chore(Resizable): drop a createResizable comment that restates the names below it --- packages/core/src/Resizable/createResizable.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index 7d036e21091c6..e6d6f6cf9000d 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -430,7 +430,6 @@ export function createResizableAs( let uncontrolledIsCollapsed = persisted?.isCollapsed ?? opts.defaultIsCollapsed ?? false; - // What the region last computed, notified, wrote, and announced. let derived = { size: 0, isCollapsed: false, From 3705e5877e43fcff83f4c0f254bdd2449cbe7aea Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Tue, 3 Nov 2026 10:02:15 -0800 Subject: [PATCH 11/11] docs(Resizable): isReversed says the handle sits before a panel at the end A reversed handle controls the panel on its end side (ResizeHandle maps isReversed to an end panel), so the handle sits before that panel, not after it. --- packages/core/src/Resizable/createResizable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Resizable/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index e6d6f6cf9000d..f6fa41164410b 100644 --- a/packages/core/src/Resizable/createResizable.ts +++ b/packages/core/src/Resizable/createResizable.ts @@ -94,7 +94,7 @@ export interface CreateResizableOptions { /** Called when an interaction collapses or expands the region. */ onCollapseChange?: (isCollapsed: boolean) => void; /** - * The handle sits after the panel it resizes (the panel is at the end), so + * The handle sits before the panel it resizes (the panel is at the end), so * a drag toward the start grows it. * @default false */