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/.changeset/resize-handle-disabled-tab-stop.md b/.changeset/resize-handle-disabled-tab-stop.md new file mode 100644 index 0000000000000..96752d81bd1b8 --- /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. 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 diff --git a/docs/architecture/internationalization.md b/docs/architecture/internationalization.md index 6528a4688b2a4..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 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, 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..4ef0676b3c069 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 @@ -147,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 | @@ -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 @@ -272,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/ResizeHandle.test.tsx b/packages/core/src/Resizable/ResizeHandle.test.tsx index 3872c76778262..a7aa2d8318ebc 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'; @@ -895,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'); }); @@ -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/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/__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.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/createResizable.ts b/packages/core/src/Resizable/createResizable.ts index 817586944b690..f6fa41164410b 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 before 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; } @@ -113,12 +162,12 @@ 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}; /** - * 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,6 @@ export function createResizableAs( let uncontrolledIsCollapsed = persisted?.isCollapsed ?? opts.defaultIsCollapsed ?? false; - // What the region last computed, notified and wrote. let derived = { size: 0, isCollapsed: false, @@ -518,13 +617,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 +641,9 @@ export function createResizableAs( } } } + if (handle != null && !isSuspended) { + announce(); + } persist(); const reported = { size: derived.isCollapsed ? 0 : derived.size, @@ -606,6 +708,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 +891,59 @@ 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); + }, + ); + + 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 +951,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 +990,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 +1017,7 @@ export function createResizableAs( if (isDestroyed || isSuspended) { return; } + gesture.interrupt(); endGesture(); isSuspended = true; letGoOfPage(); @@ -813,6 +1027,7 @@ export function createResizableAs( return; } isSuspended = false; + bindHandle(); commit(); }, syncContainer() { diff --git a/packages/core/src/Resizable/createResizableHandle.test.ts b/packages/core/src/Resizable/createResizableHandle.test.ts new file mode 100644 index 0000000000000..c675b2c90fe07 --- /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 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(0); + 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..0bbae066e0480 --- /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('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(0); + 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/separatorAttributes.ts b/packages/core/src/Resizable/separatorAttributes.ts new file mode 100644 index 0000000000000..38f67409feade --- /dev/null +++ b/packages/core/src/Resizable/separatorAttributes.ts @@ -0,0 +1,73 @@ +// 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; +} + +/** + * 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 keeps its tab stop, so keyboard users can reach it and + * hear that it is disabled. + */ +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: 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; 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 = {