From a7976dcb9d124258755a67de21db62f144e9cfe5 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 11:48:56 +0200 Subject: [PATCH 01/60] Add accessible filter dialog behavior and search focus --- Source/Filter/CheckboxListFilter.tsx | 15 +++- .../FilterPanel.accessibility.stories.tsx | 46 +++++++++++ Source/Filter/FilterPanel.tsx | 33 +++++++- .../when_naming_and_focusing_search.tsx | 63 ++++++++++++++ .../when_auto_focusing_group_search.tsx | 82 +++++++++++++++++++ .../when_dismissing_with_escape.tsx | 75 +++++++++++++++++ .../when_rendering_accessible_names.tsx | 78 ++++++++++++++++++ Source/Filter/types.ts | 4 + 8 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 Source/Filter/FilterPanel.accessibility.stories.tsx create mode 100644 Source/Filter/for_CheckboxListFilter/when_naming_and_focusing_search.tsx create mode 100644 Source/Filter/for_FilterPanel/when_auto_focusing_group_search.tsx create mode 100644 Source/Filter/for_FilterPanel/when_dismissing_with_escape.tsx create mode 100644 Source/Filter/for_FilterPanel/when_rendering_accessible_names.tsx diff --git a/Source/Filter/CheckboxListFilter.tsx b/Source/Filter/CheckboxListFilter.tsx index 897d5c7a..82402279 100644 --- a/Source/Filter/CheckboxListFilter.tsx +++ b/Source/Filter/CheckboxListFilter.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { useId, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import type { FilterOption } from './types'; import { useOptionListOverflow } from './useOptionListOverflow'; @@ -29,6 +29,10 @@ export interface CheckboxListFilterProps { searchable?: boolean; /** Placeholder text for the search input. Defaults to 'Search…'. */ searchPlaceholder?: string; + /** Accessible name for the search input. Falls back to its placeholder, then 'Search'. */ + searchAriaLabel?: string; + /** Focus the search when visible. FilterPanel enables this only for expanded groups. Defaults to false. */ + autoFocusSearch?: boolean; /** Shown in place of the list when there are no options at all. */ emptyMessage?: string; /** Shown in place of the list when a search matches nothing. */ @@ -75,6 +79,8 @@ export function CheckboxListFilter({ onToggle, searchable, searchPlaceholder = 'Search…', + searchAriaLabel, + autoFocusSearch = false, emptyMessage = 'Nothing to choose from.', noMatchesMessage = 'No matches.', name, @@ -82,6 +88,7 @@ export function CheckboxListFilter({ const [search, setSearch] = useState(''); const containerRef = useRef(null); const mirrorRef = useRef(null); + const searchInputRef = useRef(null); const generatedName = useId(); const groupName = name ?? generatedName; @@ -89,6 +96,10 @@ export function CheckboxListFilter({ const overflows = useOptionListOverflow(containerRef, mirrorRef, autoDetect); const showSearch = searchable === true || (autoDetect && overflows); + useEffect(() => { + if (autoFocusSearch && showSearch) searchInputRef.current?.focus(); + }, [autoFocusSearch, showSearch]); + const normalized = search.trim().toLowerCase(); const visibleOptions = useMemo( () => @@ -107,8 +118,10 @@ export function CheckboxListFilter({ {showSearch && (
setSearch(event.target.value)} /> diff --git a/Source/Filter/FilterPanel.accessibility.stories.tsx b/Source/Filter/FilterPanel.accessibility.stories.tsx new file mode 100644 index 00000000..23899547 --- /dev/null +++ b/Source/Filter/FilterPanel.accessibility.stories.tsx @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useRef, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from 'storybook/test'; +import { FilterPanel } from './FilterPanel'; +import type { FilterDefinition } from './types'; + +const meta: Meta = { title: 'Filter/FilterPanel/Accessibility', component: FilterPanel }; +export default meta; +type Story = StoryObj; + +const filters: FilterDefinition[] = [{ + key: 'status', label: 'Status', searchable: true, autoFocus: true, + searchAriaLabel: 'Find a status', + options: [{ key: 'active', label: 'Active', value: 'active' }], +}]; + +export const FocusSearchAndDismiss: Story = { + name: 'Focus search on expansion and dismiss with Escape', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + await userEvent.click(canvas.getByRole('button', { name: 'Filters' })); + await userEvent.click(body.getByRole('button', { name: 'Status' })); + const search = body.getByRole('searchbox', { name: 'Find a status' }); + await expect(search).toHaveFocus(); + await expect(body.getByRole('dialog', { name: 'Filter choices' })).toBeTruthy(); + await userEvent.keyboard('{Escape}'); + await expect(canvas.getByRole('button', { name: 'Filters' })).toHaveFocus(); + }, + render: () => { + const anchorRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [expandedFilterKey, setExpandedFilterKey] = useState(null); + return
+ + setIsOpen(false)} onExpandedFilterChange={setExpandedFilterKey} + onFilterToggle={() => undefined} onFilterClear={() => undefined} + onRangeChange={() => undefined} /> +
; + }, +}; diff --git a/Source/Filter/FilterPanel.tsx b/Source/Filter/FilterPanel.tsx index 065eb05f..10955324 100644 --- a/Source/Filter/FilterPanel.tsx +++ b/Source/Filter/FilterPanel.tsx @@ -48,6 +48,10 @@ export interface FilterPanelProps { search?: string; /** Placeholder text for the search input. Defaults to 'Search…'. */ searchPlaceholder?: string; + /** Accessible name for the non-modal dialog. Defaults to 'Filters'. */ + 'aria-label'?: string; + /** Accessible name for the panel search. Falls back to its placeholder, then 'Search'. */ + searchAriaLabel?: string; /** Accessible name for a clear-filter button. Override to localize. Defaults to 'Clear filter'. */ clearFilterAriaLabel?: string; /** Accessible name for a clear-range button. Override to localize. Defaults to 'Clear range'. */ @@ -173,6 +177,7 @@ interface OptionListProps { onFilterToggle: (filterKey: string, optionKey: string, multi: boolean) => void; /** Falls back to the panel-level search placeholder when the filter group has none of its own. */ searchPlaceholder?: string; + isExpanded: boolean; } /** Adapts a `FilterDefinition`'s string/option shape onto the reusable {@link CheckboxListFilter}. */ @@ -181,7 +186,8 @@ function OptionList({ selections, onFilterToggle, searchPlaceholder, -}: Omit) { + isExpanded, +}: OptionListProps) { return ( onFilterToggle(filter.key, optionKey, filter.multi ?? false) @@ -217,6 +225,8 @@ export function FilterPanel({ customValues, search, searchPlaceholder = 'Search…', + 'aria-label': ariaLabel = 'Filters', + searchAriaLabel, clearFilterAriaLabel = 'Clear filter', clearRangeAriaLabel = 'Clear range', expandedFilterKey, @@ -297,6 +307,23 @@ export function FilterPanel({ }; }, [isOpen, anchorRef, onClose]); + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + const focused = document.activeElement; + if (focused && (panelRef.current?.contains(focused) || anchorRef.current?.contains(focused))) { + event.preventDefault(); + onClose(); + anchorRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, anchorRef, onClose]); + if (!isBrowser) return null; return createPortal( @@ -304,6 +331,8 @@ export function FilterPanel({ {isOpen && ( onSearchChange(event.target.value) @@ -459,6 +489,7 @@ export function FilterPanel({ selections={selections} onFilterToggle={onFilterToggle} searchPlaceholder={searchPlaceholder} + isExpanded={isExpanded} /> )}
diff --git a/Source/Filter/for_CheckboxListFilter/when_naming_and_focusing_search.tsx b/Source/Filter/for_CheckboxListFilter/when_naming_and_focusing_search.tsx new file mode 100644 index 00000000..85e0b94b --- /dev/null +++ b/Source/Filter/for_CheckboxListFilter/when_naming_and_focusing_search.tsx @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { CheckboxListFilter } from '../CheckboxListFilter'; +import type { CheckboxListFilterInTheDom } from './given/a_checkbox_list_filter_in_the_dom'; +import { render, stubOptionListLayoutMeasurement, unmount } from './given/a_checkbox_list_filter_in_the_dom'; + +const options = [{ key: 'a', label: 'Active', value: 'a' }]; + +describe('when a checkbox list search is shown', () => { + let mounted: CheckboxListFilterInTheDom; + + afterEach(async () => { + await unmount(mounted); + }); + + it('should use its explicit accessible name ahead of the placeholder', async () => { + mounted = await render( undefined} + searchable searchPlaceholder='Placeholder' searchAriaLabel='Find options' />); + expect(mounted.container.querySelector('input[type="search"]')?.getAttribute('aria-label')).to.equal('Find options'); + }); + + it('should fall back to the placeholder', async () => { + mounted = await render( undefined} + searchable searchPlaceholder='Search options' />); + expect(mounted.container.querySelector('input[type="search"]')?.getAttribute('aria-label')).to.equal('Search options'); + }); + + it('should fall back to English when the placeholder is empty', async () => { + mounted = await render( undefined} + searchable searchPlaceholder='' />); + expect(mounted.container.querySelector('input[type="search"]')?.getAttribute('aria-label')).to.equal('Search'); + }); +}); + +describe('when search appears after overflow measurement', () => { + let mounted: CheckboxListFilterInTheDom; + let restoreMeasurement: () => void; + + beforeEach(() => { + restoreMeasurement = stubOptionListLayoutMeasurement(600, '224px'); + }); + + afterEach(async () => { + await unmount(mounted); + restoreMeasurement(); + }); + + it('should focus the newly created search if requested', async () => { + mounted = await render( undefined} + autoFocusSearch />); + expect(document.activeElement).to.equal(mounted.container.querySelector('input[type="search"]')); + }); + + it('should not focus the search by default', async () => { + mounted = await render( undefined} />); + expect(document.activeElement).not.to.equal(mounted.container.querySelector('input[type="search"]')); + }); +}); diff --git a/Source/Filter/for_FilterPanel/when_auto_focusing_group_search.tsx b/Source/Filter/for_FilterPanel/when_auto_focusing_group_search.tsx new file mode 100644 index 00000000..e0659575 --- /dev/null +++ b/Source/Filter/for_FilterPanel/when_auto_focusing_group_search.tsx @@ -0,0 +1,82 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { FilterPanel } from '../FilterPanel'; +import type { FilterDefinition } from '../types'; +import { stubOptionListLayoutMeasurement } from '../for_CheckboxListFilter/given/a_checkbox_list_filter_in_the_dom'; + +const filters: FilterDefinition[] = [ + { key: 'first', label: 'First', searchable: true, options: [{ key: 'a', label: 'A', value: 'a' }] }, + { key: 'second', label: 'Second', searchable: true, autoFocus: true, options: [{ key: 'b', label: 'B', value: 'b' }] }, +]; + +describe('when a group requests search auto focus', () => { + let container: HTMLDivElement; + let root: Root; + const anchorRef = createRef(); + const render = async (expandedFilterKey: string | null, definitions = filters) => { + await act(async () => root.render( + undefined} onFilterToggle={() => undefined} + onFilterClear={() => undefined} onRangeChange={() => undefined} + onExpandedFilterChange={() => undefined} />, + )); + }; + + beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('should focus the expanded group search on open', async () => { + await render('second'); + expect(document.activeElement).to.equal(document.querySelectorAll('.pv-filter-group-search input')[1]); + }); + + it('should not focus a collapsed group search even though it remains mounted', async () => { + await render('first'); + expect(document.querySelectorAll('.pv-filter-group-search input')).to.have.length(2); + expect(document.activeElement).not.to.equal(document.querySelectorAll('.pv-filter-group-search input')[1]); + }); + + it('should focus the group search on expansion', async () => { + await render('first'); + await render('second'); + expect(document.activeElement).to.equal(document.querySelectorAll('.pv-filter-group-search input')[1]); + }); + + it('should not focus the search of a group without autoFocus', async () => { + await render('first'); + expect(document.activeElement).not.to.equal(document.querySelectorAll('.pv-filter-group-search input')[0]); + }); + + it('should focus its search after overflow measurement adds the input', async () => { + const restoreMeasurement = stubOptionListLayoutMeasurement(600, '224px'); + try { + await render('second', [{ ...filters[1], searchable: undefined }]); + expect(document.activeElement).to.equal(document.querySelector('.pv-filter-group-search input')); + } finally { + restoreMeasurement(); + } + }); + + it('should not focus a group that has no search input', async () => { + await render('second', [{ ...filters[1], searchable: false }]); + expect(document.activeElement).not.to.equal(document.querySelector('.pv-filter-toggle')); + expect(document.querySelector('.pv-filter-group-search input')).to.equal(null); + }); +}); diff --git a/Source/Filter/for_FilterPanel/when_dismissing_with_escape.tsx b/Source/Filter/for_FilterPanel/when_dismissing_with_escape.tsx new file mode 100644 index 00000000..d126e3b1 --- /dev/null +++ b/Source/Filter/for_FilterPanel/when_dismissing_with_escape.tsx @@ -0,0 +1,75 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it, vi } from 'vitest'; +import { FilterPanel } from '../FilterPanel'; + +describe('when dismissing an open filter panel', () => { + let container: HTMLDivElement; + let root: Root; + let anchor: HTMLButtonElement; + let outside: HTMLButtonElement; + let onClose: ReturnType; + + beforeEach(async () => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + const anchorRef = createRef(); + onClose = vi.fn(); + await act(async () => { + root.render(<> + + + undefined} + anchorRef={anchorRef} onClose={onClose} onFilterToggle={() => undefined} + onFilterClear={() => undefined} onRangeChange={() => undefined} + onExpandedFilterChange={() => undefined} /> + ); + }); + anchor = anchorRef.current!; + outside = container.querySelectorAll('button')[1]; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('should close and return focus to the anchor on Escape from within the panel', () => { + const input = document.querySelector('.pv-search input')!; + input.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(onClose.mock.calls.length).to.equal(1); + expect(document.activeElement).to.equal(anchor); + }); + + it('should close and retain focus on the anchor on Escape from the anchor', () => { + anchor.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(onClose.mock.calls.length).to.equal(1); + expect(document.activeElement).to.equal(anchor); + }); + + it('should leave the panel open and focus unchanged on Escape elsewhere', () => { + outside.focus(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(onClose.mock.calls.length).to.equal(0); + expect(document.activeElement).to.equal(outside); + }); + + it('should close without moving focus on outside mousedown', async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + outside.focus(); + document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(onClose.mock.calls.length).to.equal(1); + expect(document.activeElement).to.equal(outside); + }); +}); diff --git a/Source/Filter/for_FilterPanel/when_rendering_accessible_names.tsx b/Source/Filter/for_FilterPanel/when_rendering_accessible_names.tsx new file mode 100644 index 00000000..1f2227d4 --- /dev/null +++ b/Source/Filter/for_FilterPanel/when_rendering_accessible_names.tsx @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { act, createRef } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { expect } from 'chai'; +import { afterEach, beforeEach, describe, it } from 'vitest'; +import { FilterPanel } from '../FilterPanel'; +import type { FilterDefinition } from '../types'; + +const filters: FilterDefinition[] = [ + { key: 'named', label: 'Named', searchable: true, searchAriaLabel: 'Find status', searchPlaceholder: 'Type a status', options: [{ key: 'a', label: 'Active', value: 'a' }] }, + { key: 'placeholder', label: 'Placeholder', searchable: true, searchPlaceholder: 'Find category', options: [{ key: 'b', label: 'Category', value: 'b' }] }, +]; + +describe('when rendering filter panel accessible names', () => { + let container: HTMLDivElement; + let root: Root; + const anchorRef = createRef(); + const render = async (label?: string, searchAriaLabel?: string, searchPlaceholder?: string) => { + await act(async () => root.render( + undefined} anchorRef={anchorRef} + onClose={() => undefined} onFilterToggle={() => undefined} + onFilterClear={() => undefined} onRangeChange={() => undefined} + onExpandedFilterChange={() => undefined} />, + )); + }; + + beforeEach(async () => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await render(); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('should expose a non-modal named dialog rather than a complementary landmark', () => { + const panel = document.querySelector('.pv-filter-dropdown')!; + expect(panel.getAttribute('role')).to.equal('dialog'); + expect(panel.getAttribute('aria-modal')).to.equal(null); + expect(panel.getAttribute('aria-label')).to.equal('Filters'); + }); + + it('should honor an explicit dialog name', async () => { + await render('Choose filters'); + expect(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')).to.equal('Choose filters'); + }); + + it('should use the placeholder for the panel search name when no label is given', async () => { + await render(undefined, undefined, 'Find filters'); + expect(document.querySelector('.pv-search input')?.getAttribute('aria-label')).to.equal('Find filters'); + }); + + it('should honor a separate panel search name', async () => { + await render(undefined, 'Search all filters', 'Find filters'); + expect(document.querySelector('.pv-search input')?.getAttribute('aria-label')).to.equal('Search all filters'); + }); + + it('should use English search fallback without a placeholder', async () => { + await render(undefined, undefined, ''); + expect(document.querySelector('.pv-search input')?.getAttribute('aria-label')).to.equal('Search'); + }); + + it('should name each group search from its own label or placeholder', () => { + const inputs = document.querySelectorAll('.pv-filter-group-search input'); + expect(inputs[0].getAttribute('aria-label')).to.equal('Find status'); + expect(inputs[1].getAttribute('aria-label')).to.equal('Find category'); + }); +}); diff --git a/Source/Filter/types.ts b/Source/Filter/types.ts index 8f1c24d7..1be2b2ee 100644 --- a/Source/Filter/types.ts +++ b/Source/Filter/types.ts @@ -67,6 +67,10 @@ export interface FilterDefinition { searchable?: boolean; /** Placeholder shown in the inline search box. Defaults to 'Search…'. */ searchPlaceholder?: string; + /** Accessible name for this group's search. Falls back to its placeholder, then 'Search'. */ + searchAriaLabel?: string; + /** Focus this group's search when expanded and the search input exists. Defaults to false. */ + autoFocus?: boolean; } /** Selected string/option values for each filter, keyed by FilterDefinition.key. */ From 4a1a20aa69ccc8f8adff4468b7640fb244f098c7 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 11:48:56 +0200 Subject: [PATCH 02/60] Document filter dialog accessibility and group search focus --- Documentation/Filter/index.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Documentation/Filter/index.md b/Documentation/Filter/index.md index ce0082d5..ba7cf711 100644 --- a/Documentation/Filter/index.md +++ b/Documentation/Filter/index.md @@ -153,7 +153,9 @@ Uses the same range slider and histogram as `type: 'number'`, configured through ### Searching long option lists -An option group shows a search box when its options do not fit in the group's box. Set `searchable: true` on the `FilterDefinition` to always show it, or `searchable: false` to never show it, and `searchPlaceholder` to change its placeholder. +An option group shows a search box when its options do not fit in the group's box. Set `searchable: true` on the `FilterDefinition` to always show it, or `searchable: false` to never show it. Set `searchPlaceholder` to change its placeholder and `searchAriaLabel` to give the input a distinct accessible name. Without `searchAriaLabel`, its accessible name uses the effective placeholder, then the English default `'Search'` if the placeholder is empty. Standalone `CheckboxListFilter` accepts the same search props. + +Set `autoFocus: true` on a `FilterDefinition` to focus its inline search when that group becomes expanded, including if overflow measurement adds the input after rendering. The default is `false`; `autoFocus` has no effect if the group is collapsed or has no search input. For a standalone `CheckboxListFilter`, use `autoFocusSearch` to request focus when its search becomes visible. ### Custom editor (`type: 'custom'`) @@ -211,6 +213,8 @@ Custom filter editors should not implement their own clear buttons; the header c | `customValues` | `CustomFilterValues` | — | Values for custom-editor filters | | `search` | `string` | — | Current search-box value | | `searchPlaceholder` | `string` | — | Placeholder for the panel's search input (default: `'Search…'`). Also the fallback placeholder for a searchable filter group that does not declare its own `searchPlaceholder`. | +| `aria-label` | `string` | — | Accessible name of the non-modal dialog (English default: `'Filters'`) | +| `searchAriaLabel` | `string` | — | Accessible name of the panel search input; falls back to `searchPlaceholder`, then the English default `'Search'` | | `clearFilterAriaLabel` | `string` | — | Accessible name and tooltip for a string/custom filter's clear button (default: `'Clear filter'`) | | `clearRangeAriaLabel` | `string` | — | Accessible name and tooltip for a numeric/date filter's clear button (default: `'Clear range'`) | | `expandedFilterKey` | `string \| null` | — | Which filter group is open | @@ -312,10 +316,11 @@ The first filter group starts expanded. The hook re-syncs its state when the set ## Accessibility and keyboard - The panel is rendered into `document.body` at a fixed position below `anchorRef`, and follows the anchor on scroll and resize. -- It closes when the user presses the mouse outside both the panel and the anchor. It does not close on Escape, and it does not move focus when it opens; keyboard users open and close it with the trigger button. Set `aria-expanded` on your trigger, as in the Quick Start. +- The panel is a named, non-modal dialog (`role="dialog"`) rather than a complementary (`aside`) landmark. Override its English default name with `aria-label` for your locale. It does not trap focus or move focus on open (except when an expanded group's `autoFocus` is enabled). Set `aria-expanded` on your trigger, as in the Quick Start. +- Escape closes the panel and returns focus to `anchorRef` if focus is inside the panel or on its anchor. Escape with focus elsewhere does nothing. Outside mousedown closes without moving focus. - Each group header is a button with `aria-expanded`. The clear button is named by `clearFilterAriaLabel` or `clearRangeAriaLabel`. - Range sliders are named by `minimumAriaLabel` and `maximumAriaLabel` and respond to Arrow, Home, and End keys. -- The panel's search box and the option-list search boxes have a placeholder but no accessible name. +- The panel and group search inputs have accessible names: `searchAriaLabel` on `FilterPanel`, `FilterDefinition`, or standalone `CheckboxListFilter` overrides each input's placeholder, which itself falls back to the English default `'Search'` when empty. ## Importing From f92fba026ae5efb542dd33f65b8a52ea9453d0ac Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 11:58:56 +0200 Subject: [PATCH 03/60] Add pre-execution confirmation to command dialogs and steppers --- .../CommandDialog/CommandDialog.stories.tsx | 34 ++++- Source/CommandDialog/CommandDialog.tsx | 92 +++++++----- Source/CommandDialog/CommandStepper.tsx | 66 ++++++--- Source/CommandDialog/StepperCommandDialog.tsx | 67 ++++++--- Source/CommandDialog/confirmBeforeExecute.ts | 18 +++ .../when_confirming_in_nested_dialog.tsx | 68 +++++++++ .../when_confirming_with_guard.tsx | 137 ++++++++++++++++++ .../when_confirming_before_execution.ts | 42 ++++++ .../when_confirming_before_execution.tsx | 68 +++++++++ Source/CommandDialog/useSubmissionFlight.ts | 30 ++++ 10 files changed, 542 insertions(+), 80 deletions(-) create mode 100644 Source/CommandDialog/confirmBeforeExecute.ts create mode 100644 Source/CommandDialog/for_CommandDialog/when_confirming_in_nested_dialog.tsx create mode 100644 Source/CommandDialog/for_CommandDialog/when_confirming_with_guard.tsx create mode 100644 Source/CommandDialog/for_CommandStepper/when_confirming_before_execution.ts create mode 100644 Source/CommandDialog/for_StepperCommandDialog/when_confirming_before_execution.tsx create mode 100644 Source/CommandDialog/useSubmissionFlight.ts diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx index a180a473..81cd6e24 100644 --- a/Source/CommandDialog/CommandDialog.stories.tsx +++ b/Source/CommandDialog/CommandDialog.stories.tsx @@ -7,7 +7,8 @@ import { CommandDialog } from './CommandDialog'; import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; import { PropertyDescriptor } from '@cratis/arc/reflection'; import { InputTextField, NumberField, TextAreaField } from '../CommandForm/fields'; -import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +import { DialogButtons, DialogComponents, DialogResult, useConfirmationDialog, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +import { ConfirmationDialog } from '../Dialogs/ConfirmationDialog'; import { DialogInitialFocus } from '../Dialogs/DialogInitialFocus'; import '@cratis/arc/validation'; import { expect, userEvent, within } from 'storybook/test'; @@ -867,6 +868,37 @@ export const WithResponseTypeAndCallbacks: Story = { * `initialFocus` moves the keyboard off it without giving up the footer, the * close (X), `Escape`, or the confirm wiring that runs the command. */ +const GuardedCommandDialog = () => { + const [showConfirmation] = useConfirmationDialog( + 'Save changes?', 'Run the command with these values?', DialogButtons.YesNo, + ); + return ( + + command={DemoSlowUpdateUserCommand} + title='Update sample user' + initialValues={{ name: 'Sample User', email: 'sample@example.invalid', age: 30 }} + confirmBeforeExecute={async () => (await showConfirmation()) === DialogResult.Yes} + > + command.name} title='Name' /> + + ); +}; + +export const WithPreExecutionConfirmation: Story = { + play: openCommandDialog, + render: () => { + const [GuardedDialog, showDialog] = useDialog(GuardedCommandDialog); + return ( + +
+ + +
+
+ ); + }, +}; + export const DestructiveCommandFocusesDismiss: Story = { play: openCommandDialog, render: () => { diff --git a/Source/CommandDialog/CommandDialog.tsx b/Source/CommandDialog/CommandDialog.tsx index 269bceb4..2422a08b 100644 --- a/Source/CommandDialog/CommandDialog.tsx +++ b/Source/CommandDialog/CommandDialog.tsx @@ -4,7 +4,7 @@ import type { ICommandResult } from '@cratis/arc/commands'; import { DialogButtons, DialogResult } from '@cratis/arc.react/dialogs'; import { Dialog, type DialogProps } from '../Dialogs/Dialog'; -import React, { useState } from 'react'; +import React from 'react'; import { CommandForm, CommandFormFieldWrapper, @@ -13,6 +13,8 @@ import { type CommandFormProps, } from '@cratis/arc.react/commands'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import { reportConfirmationError, type ConfirmBeforeExecute } from './confirmBeforeExecute'; +import { useSubmissionFlight } from './useSubmissionFlight'; import { isCommandFormField, markAsCommandFormColumn, @@ -47,6 +49,14 @@ export interface CommandDialogProps */ onBeforeExecute?: BeforeExecuteCallback; + /** + * Ask whether to run the command after validation and `onBeforeExecute`. + * Receives the transformed values. Return `false` to keep the dialog open + * without executing; may return a promise (for example from a confirmation dialog). + * Unlike `onConfirm`, this runs before execution, not after success. + */ + confirmBeforeExecute?: ConfirmBeforeExecute; + /** * Form fields and arbitrary content for the dialog body. Children that are * `CommandFormField` instances are automatically wrapped so they bind to @@ -69,6 +79,7 @@ const CommandDialogWrapper = ({ onException, onUnauthorized, onBeforeExecute, + confirmBeforeExecute, children, ...dialogProps }: Omit & { @@ -78,6 +89,7 @@ const CommandDialogWrapper = ({ onException?: CommandFormProps['onException']; onUnauthorized?: CommandFormProps['onUnauthorized']; onBeforeExecute?: BeforeExecuteCallback; + confirmBeforeExecute?: ConfirmBeforeExecute; }) => { const { setCommandValues, @@ -85,52 +97,64 @@ const CommandDialogWrapper = ({ isValid: isCommandFormValid, } = useCommandFormContext(); const commandInstance = useCommandInstance(); - const [isBusy, setIsBusy] = useState(false); + const submission = useSubmissionFlight(); const handleConfirm = async () => { - setIsBusy(true); - let result: ICommandResult; + if (!submission.begin()) return false; try { + let values = commandInstance; if (onBeforeExecute) { const applied = applyBeforeExecute(onBeforeExecute, commandInstance); - setCommandValues(applied instanceof Promise ? await applied : applied); + values = applied instanceof Promise ? await applied : applied; + if (!submission.isMounted()) return false; + setCommandValues(values); + } + if (confirmBeforeExecute) { + let approved: boolean; + try { + approved = await confirmBeforeExecute(values); + } catch (error) { + if (submission.isMounted()) await reportConfirmationError(error, onException); + return false; + } + if (!submission.isMounted() || !approved) return false; } + if (!submission.isMounted()) return false; // SAFETY: Arc command instances expose execute at runtime; the wrapper's public type omits it. - result = await ( + const result: ICommandResult = await ( commandInstance as unknown as { execute: () => Promise>; } ).execute(); - } finally { - setIsBusy(false); - } + if (!submission.isMounted()) return false; - if (!result.isSuccess) { - await onFailed?.(result); - if (result.hasExceptions) { - await onException?.(result.exceptionMessages, result.exceptionStackTrace); - } - if (!result.isAuthorized) await onUnauthorized?.(); - if (!result.isValid) { - await onValidationFailure?.(result.validationResults); + if (!result.isSuccess) { + await onFailed?.(result); + if (result.hasExceptions) { + await onException?.(result.exceptionMessages, result.exceptionStackTrace); + } + if (!result.isAuthorized) await onUnauthorized?.(); + if (!result.isValid) { + await onValidationFailure?.(result.validationResults); + } + setCommandResult(result); + return false; } - setCommandResult(result); - return false; - } - - await onSuccess?.(result.response as TResponse); - - if (onConfirm) { - const closeResult = await onConfirm(); - return closeResult === true; - } - if (onClose) { - const closeResult = await onClose(DialogResult.Ok); - return closeResult !== false; + await onSuccess?.(result.response as TResponse); + if (!submission.isMounted()) return false; + if (onConfirm) { + const closeResult = await onConfirm(); + return closeResult === true; + } + if (onClose) { + const closeResult = await onClose(DialogResult.Ok); + return closeResult !== false; + } + return true; + } finally { + submission.finish(); } - - return true; }; const processChildren = (nodes: React.ReactNode): React.ReactNode => { @@ -171,7 +195,7 @@ const CommandDialogWrapper = ({ onClose={onClose} onConfirm={handleConfirm} isValid={isDialogValid} - isBusy={isBusy} + isBusy={submission.isSubmitting} >
{processedChildren} @@ -322,6 +346,7 @@ const CommandDialogComponent = {children} diff --git a/Source/CommandDialog/CommandStepper.tsx b/Source/CommandDialog/CommandStepper.tsx index d0fac7ce..c0637384 100644 --- a/Source/CommandDialog/CommandStepper.tsx +++ b/Source/CommandDialog/CommandStepper.tsx @@ -10,6 +10,8 @@ import { type CommandFormProps, } from '@cratis/arc.react/commands'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import { reportConfirmationError, type ConfirmBeforeExecute } from './confirmBeforeExecute'; +import { useSubmissionFlight } from './useSubmissionFlight'; import { CommandStepperContent, type CommandStepperContentProps, @@ -122,6 +124,8 @@ export interface CommandStepperProps; + /** Ask before executing the transformed command values; return false to stay on the step. */ + confirmBeforeExecute?: ConfirmBeforeExecute; /** StepperPanel children defining each wizard step. */ children?: React.ReactNode; } @@ -144,6 +148,7 @@ type CommandStepperWrapperProps = O onException?: CommandFormProps['onException']; onUnauthorized?: CommandFormProps['onUnauthorized']; onBeforeExecute?: BeforeExecuteCallback; + confirmBeforeExecute?: ConfirmBeforeExecute; }; const CommandStepperWrapper = ({ @@ -170,6 +175,7 @@ const CommandStepperWrapper = ({ onException, onUnauthorized, onBeforeExecute, + confirmBeforeExecute, }: CommandStepperWrapperProps) => { const { getFieldError, @@ -180,42 +186,54 @@ const CommandStepperWrapper = ({ const commandInstance = useCommandInstance(); const [activeStep, setActiveStep] = useState(0); const [visitedSteps, setVisitedSteps] = useState>(new Set([0])); - const [isSubmitting, setIsSubmitting] = useState(false); + const submission = useSubmissionFlight(); const handleSubmit = async () => { - setIsSubmitting(true); - let result: ICommandResult; - + if (!submission.begin()) return; try { + let values = commandInstance; if (onBeforeExecute) { const applied = applyBeforeExecute(onBeforeExecute, commandInstance); - setCommandValues(applied instanceof Promise ? await applied : applied); + values = applied instanceof Promise ? await applied : applied; + if (!submission.isMounted()) return; + setCommandValues(values); } - + if (confirmBeforeExecute) { + let approved: boolean; + try { + approved = await confirmBeforeExecute(values); + } catch (error) { + if (submission.isMounted()) await reportConfirmationError(error, onException); + return; + } + if (!submission.isMounted() || !approved) return; + } + if (!submission.isMounted()) return; // SAFETY: Arc command instances expose execute at runtime; the wrapper's public type omits it. - result = await ( + const result: ICommandResult = await ( commandInstance as unknown as { execute: () => Promise>; } ).execute(); - } finally { - setIsSubmitting(false); - } + if (!submission.isMounted()) return; - if (!result.isSuccess) { - await onFailed?.(result); - if (result.hasExceptions) { - await onException?.(result.exceptionMessages, result.exceptionStackTrace); - } - if (!result.isAuthorized) await onUnauthorized?.(); - if (!result.isValid) { - await onValidationFailure?.(result.validationResults); + if (!result.isSuccess) { + await onFailed?.(result); + if (result.hasExceptions) { + await onException?.(result.exceptionMessages, result.exceptionStackTrace); + } + if (!result.isAuthorized) await onUnauthorized?.(); + if (!result.isValid) { + await onValidationFailure?.(result.validationResults); + } + setCommandResult(result); + return; } - setCommandResult(result); - return; - } - await onSuccess?.(result.response as TResponse); + await onSuccess?.(result.response as TResponse); + } finally { + submission.finish(); + } }; return ( @@ -232,7 +250,7 @@ const CommandStepperWrapper = ({ previousLabel={previousLabel} okLabel={okLabel} isBusy={isBusy} - isSubmitting={isSubmitting} + isSubmitting={submission.isSubmitting} isSubmitDisabled={!isCommandFormValid} onSubmit={handleSubmit} linear={linear} @@ -332,6 +350,7 @@ export const CommandStepper = {children} diff --git a/Source/CommandDialog/StepperCommandDialog.tsx b/Source/CommandDialog/StepperCommandDialog.tsx index 85b6126a..3a29f298 100644 --- a/Source/CommandDialog/StepperCommandDialog.tsx +++ b/Source/CommandDialog/StepperCommandDialog.tsx @@ -22,6 +22,8 @@ import { import type { StepperCustomizationProps } from './CommandStepper'; import { CommandStepperContent } from './CommandStepperContent'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import { reportConfirmationError, type ConfirmBeforeExecute } from './confirmBeforeExecute'; +import { useSubmissionFlight } from './useSubmissionFlight'; import { getStepPanels } from './stepChildren'; import { transitionStep } from './transitionStep'; @@ -54,6 +56,8 @@ export interface StepperCommandDialogProps; + /** Ask before executing the transformed command values; return false to stay open. */ + confirmBeforeExecute?: ConfirmBeforeExecute; /** Dialog title text. */ title: string; /** Controls dialog visibility. Defaults to `true`. */ @@ -129,6 +133,7 @@ type StepperCommandDialogWrapperProps['onException']; onUnauthorized?: CommandFormProps['onUnauthorized']; onBeforeExecute?: BeforeExecuteCallback; + confirmBeforeExecute?: ConfirmBeforeExecute; okLabel?: string; nextLabel?: string; previousLabel?: string; @@ -158,6 +163,7 @@ const StepperCommandDialogWrapper = (); const commandInstance = useCommandInstance(); - const [isBusy, setIsBusy] = useState(false); + const submission = useSubmissionFlight(); + const isBusy = submission.isSubmitting; const [activeStep, setActiveStep] = useState(0); const [visitedSteps, setVisitedSteps] = useState>(new Set([0])); const [stepErrors, setStepErrors] = useState([]); @@ -270,40 +277,52 @@ const StepperCommandDialogWrapper = { - setIsBusy(true); - let result: ICommandResult; - + if (!submission.begin()) return; try { + let values = commandInstance; if (onBeforeExecute) { const applied = applyBeforeExecute(onBeforeExecute, commandInstance); - setCommandValues(applied instanceof Promise ? await applied : applied); + values = applied instanceof Promise ? await applied : applied; + if (!submission.isMounted()) return; + setCommandValues(values); } - + if (confirmBeforeExecute) { + let approved: boolean; + try { + approved = await confirmBeforeExecute(values); + } catch (error) { + if (submission.isMounted()) await reportConfirmationError(error, onException); + return; + } + if (!submission.isMounted() || !approved) return; + } + if (!submission.isMounted()) return; // SAFETY: Arc command instances expose execute at runtime; the wrapper's public type omits it. - result = await ( + const result: ICommandResult = await ( commandInstance as unknown as { execute: () => Promise>; } ).execute(); - } finally { - setIsBusy(false); - } + if (!submission.isMounted()) return; - if (!result.isSuccess) { - await onFailed?.(result); - if (result.hasExceptions) { - await onException?.(result.exceptionMessages, result.exceptionStackTrace); - } - if (!result.isAuthorized) await onUnauthorized?.(); - if (!result.isValid) { - await onValidationFailure?.(result.validationResults); + if (!result.isSuccess) { + await onFailed?.(result); + if (result.hasExceptions) { + await onException?.(result.exceptionMessages, result.exceptionStackTrace); + } + if (!result.isAuthorized) await onUnauthorized?.(); + if (!result.isValid) { + await onValidationFailure?.(result.validationResults); + } + setCommandResult(result); + return; } - setCommandResult(result); - return; - } - await onSuccess?.(result.response as TResponse); - await handleClose(DialogResult.Ok); + await onSuccess?.(result.response as TResponse); + if (submission.isMounted()) await handleClose(DialogResult.Ok); + } finally { + submission.finish(); + } }; const footer = ( @@ -511,6 +530,7 @@ const StepperCommandDialogComponent = < onConfirm, onCancel, onBeforeExecute, + confirmBeforeExecute, okLabel, nextLabel, previousLabel, @@ -552,6 +572,7 @@ const StepperCommandDialogComponent = < onException={props.onException} onUnauthorized={props.onUnauthorized} onBeforeExecute={onBeforeExecute} + confirmBeforeExecute={confirmBeforeExecute} okLabel={okLabel} nextLabel={nextLabel} previousLabel={previousLabel} diff --git a/Source/CommandDialog/confirmBeforeExecute.ts b/Source/CommandDialog/confirmBeforeExecute.ts new file mode 100644 index 00000000..5a383f5e --- /dev/null +++ b/Source/CommandDialog/confirmBeforeExecute.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Return false to leave the command unexecuted; receives the values after onBeforeExecute. */ +export type ConfirmBeforeExecute = (values: TCommand) => boolean | Promise; + +/** Report a rejected guard through the same exception callback used for command failures. */ +export const reportConfirmationError = async ( + error: unknown, + onException?: (messages: string[], stackTrace: string) => void | Promise, +) => { + const exception = error instanceof Error ? error : new Error(String(error)); + if (onException) { + await onException([exception.message], exception.stack ?? ''); + } else { + console.error(exception); + } +}; diff --git a/Source/CommandDialog/for_CommandDialog/when_confirming_in_nested_dialog.tsx b/Source/CommandDialog/for_CommandDialog/when_confirming_in_nested_dialog.tsx new file mode 100644 index 00000000..f4a6029e --- /dev/null +++ b/Source/CommandDialog/for_CommandDialog/when_confirming_in_nested_dialog.tsx @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// @vitest-environment jsdom + +import React from 'react'; +import { DialogButtons, DialogComponents, DialogResult, useConfirmationDialog } from '@cratis/arc.react/dialogs'; +import { afterEach, beforeEach, describe, it, vi } from 'vitest'; +import { ConfirmationDialog } from '../../Dialogs/ConfirmationDialog'; +import { render, click, unmount, type DialogInTheDom } from '../../Dialogs/for_Dialog/given/a_dialog_in_the_dom'; +import { resolveZIndex } from '../../renderer/for_dialog_stack/resolveZIndex'; +import { CommandDialog } from '../CommandDialog'; + +const execution = vi.hoisted(() => ({ calls: 0 })); +vi.mock('@cratis/arc.react/commands', () => { + const command = { name: 'Example', execute: async () => { execution.calls++; return { isSuccess: true, response: {} }; } }; + const context = { isValid: true, setCommandValues: () => undefined, setCommandResult: () => undefined }; + return { + CommandForm: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children), + useCommandFormContext: () => context, + useCommandInstance: () => command, + CommandFormFieldWrapper: () => null, + }; +}); +class SampleCommand { name = 'Example'; } + +const CommandWithConfirmation = () => { + const [showConfirmation] = useConfirmationDialog('Run command?', 'Continue?', DialogButtons.YesNo); + return ( + command={SampleCommand} title='Example command' onConfirm={() => false} + confirmBeforeExecute={async () => (await showConfirmation()) === DialogResult.Yes} /> + ); +}; + +describe('when a confirmation dialog opens above a command dialog', () => { + let dialog: DialogInTheDom; + let firstZIndex: number; + let secondZIndex: number; + let callsAfterDecline: number; + let dialogsAfterDecline: number; + beforeEach(async () => { + execution.calls = 0; + document.documentElement.style.setProperty('--cratis-z-index-dialog', '1100'); + dialog = await render( + + + , + ); + await click('Ok'); + const backdrops = Array.from(document.querySelectorAll('.cratis-dialog__backdrop[data-cratis-part="backdrop"]')) as HTMLElement[]; + firstZIndex = resolveZIndex(backdrops[0]); + secondZIndex = resolveZIndex(backdrops[1]); + await click('No'); + callsAfterDecline = execution.calls; + dialogsAfterDecline = document.querySelectorAll('[role="dialog"]').length; + await click('Ok'); + await click('Yes'); + }); + afterEach(async () => { + document.documentElement.style.removeProperty('--cratis-z-index-dialog'); + await unmount(dialog); + }); + it('should stack confirmation above the command dialog', () => { (secondZIndex > firstZIndex).should.equal(true); }); + it('should leave the command dialog open without executing on No', () => { + callsAfterDecline.should.equal(0); + dialogsAfterDecline.should.equal(1); + }); + it('should execute only after Yes', () => { execution.calls.should.equal(1); }); +}); diff --git a/Source/CommandDialog/for_CommandDialog/when_confirming_with_guard.tsx b/Source/CommandDialog/for_CommandDialog/when_confirming_with_guard.tsx new file mode 100644 index 00000000..7acc48bd --- /dev/null +++ b/Source/CommandDialog/for_CommandDialog/when_confirming_with_guard.tsx @@ -0,0 +1,137 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, it, vi } from 'vitest'; +import { CommandDialog } from '../CommandDialog'; + +const state = vi.hoisted(() => ({ + confirm: undefined as (() => Promise) | undefined, + busy: false, + execute: vi.fn(async () => ({ isSuccess: true, response: {} })), + values: { name: 'Before' }, +})); + +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { onConfirm: () => Promise; isBusy: boolean }) => { + state.confirm = props.onConfirm; + state.busy = props.isBusy; + return React.createElement('div', { role: 'dialog' }); + }, +})); +vi.mock('@cratis/arc.react/dialogs', () => ({ + DialogButtons: { OkCancel: 2 }, DialogResult: { Ok: 3 }, +})); +vi.mock('@cratis/arc.react/commands', () => ({ + CommandForm: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children), + useCommandFormContext: () => ({ isValid: true, setCommandValues: (values: { name: string }) => { state.values = values; }, setCommandResult: () => undefined }), + useCommandInstance: () => ({ ...state.values, execute: state.execute }), + CommandFormFieldWrapper: () => null, +})); + +class SampleCommand { name = 'Before'; } + +const onSuccess = vi.fn(); +const onFailed = vi.fn(); +const onException = vi.fn(); +const onConfirm = vi.fn(); +let root: Root; +let container: HTMLDivElement; +const mount = async (guard: (values: SampleCommand) => boolean | Promise, transform?: (values: SampleCommand) => SampleCommand) => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root.render( + command={SampleCommand} title='Example' confirmBeforeExecute={guard} + onBeforeExecute={transform} onSuccess={onSuccess} onFailed={onFailed} + onException={onException} onConfirm={onConfirm} />, + )); +}; + +beforeEach(() => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + state.execute.mockClear(); + state.values = { name: 'Before' }; + onSuccess.mockClear(); onFailed.mockClear(); onException.mockClear(); onConfirm.mockClear(); +}); +afterEach(async () => { + if (root) await act(async () => root.unmount()); + container?.remove(); +}); + +describe('when confirming with a pre-execution guard that approves', () => { + let receivedValues: SampleCommand | undefined; + beforeEach(async () => { + await mount((values) => { receivedValues = values; return true; }, () => ({ name: 'After' })); + await act(async () => { await state.confirm?.(); }); + }); + it('should receive the transformed values', () => { receivedValues?.name.should.equal('After'); }); + it('should execute once', () => { state.execute.mock.calls.length.should.equal(1); }); +}); + +describe('when confirming with a pre-execution guard that declines', () => { + let close: boolean | undefined; + beforeEach(async () => { + await mount(() => false); + await act(async () => { close = await state.confirm?.(); }); + }); + it('should keep the dialog open', () => { close?.should.equal(false); }); + it('should not execute', () => { state.execute.mock.calls.length.should.equal(0); }); + it('should not call result or close callbacks', () => { + onSuccess.mock.calls.length.should.equal(0); + onFailed.mock.calls.length.should.equal(0); + onConfirm.mock.calls.length.should.equal(0); + }); + it('should release busy state', () => { state.busy.should.equal(false); }); +}); + +describe('when a guard is pending and another confirm is requested', () => { + let resolveGuard: (approved: boolean) => void; + let busyWhilePending: boolean; + let executeCalls: number; + beforeEach(async () => { + const pending = new Promise((resolve) => { resolveGuard = resolve; }); + await mount(() => pending); + await act(async () => { void state.confirm?.(); }); + busyWhilePending = state.busy; + await act(async () => { void state.confirm?.(); }); + await act(async () => resolveGuard(true)); + executeCalls = state.execute.mock.calls.length; + }); + it('should hold busy through confirmation', () => { busyWhilePending.should.equal(true); }); + it('should execute only once', () => { executeCalls.should.equal(1); }); +}); + +describe('when a pending guard rejects', () => { + let rejectGuard: (error: Error) => void; + let busyWhilePending: boolean; + beforeEach(async () => { + const pending = new Promise((_resolve, reject) => { rejectGuard = reject; }); + await mount(() => pending); + await act(async () => { void state.confirm?.(); }); + busyWhilePending = state.busy; + await act(async () => rejectGuard(new Error('Example failure'))); + }); + it('should be busy until rejection', () => { busyWhilePending.should.equal(true); }); + it('should release busy', () => { state.busy.should.equal(false); }); + it('should report through onException', () => { onException.mock.calls[0][0].should.deep.equal(['Example failure']); }); + it('should stay open and not execute', () => { + state.execute.mock.calls.length.should.equal(0); + onSuccess.mock.calls.length.should.equal(0); + onFailed.mock.calls.length.should.equal(0); + }); +}); + +describe('when unmounting while the guard is pending', () => { + let resolveGuard: (approved: boolean) => void; + beforeEach(async () => { + const pending = new Promise((resolve) => { resolveGuard = resolve; }); + await mount(() => pending); + await act(async () => { void state.confirm?.(); }); + await act(async () => root.unmount()); + await act(async () => resolveGuard(true)); + }); + it('should not execute after unmount', () => { state.execute.mock.calls.length.should.equal(0); }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_confirming_before_execution.ts b/Source/CommandDialog/for_CommandStepper/when_confirming_before_execution.ts new file mode 100644 index 00000000..76ce777d --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_confirming_before_execution.ts @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { execution, render, submit, submitButton, unmount, type InlineStepperInTheDom } from './given/an_inline_stepper_in_the_dom'; + +describe('when confirming an inline stepper submission', () => { + let stepper: InlineStepperInTheDom; + let approvedCalls: number; + let declinedCalls: number; + let submitEnabledAfterDecline: boolean; + let failedCallsAfterDecline: number; + let exceptionCallsAfterDecline: number; + const onSuccess = vi.fn(); + const onFailed = vi.fn(); + const onException = vi.fn(); + const guard = vi.fn(async () => false); + + beforeEach(async () => { + execution.calls = 0; + onSuccess.mockClear(); onFailed.mockClear(); onException.mockClear(); + guard.mockClear().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + stepper = await render({ confirmBeforeExecute: guard, onSuccess, onFailed, onException }); + await submit(stepper); + declinedCalls = execution.calls; + failedCallsAfterDecline = onFailed.mock.calls.length; + exceptionCallsAfterDecline = onException.mock.calls.length; + submitEnabledAfterDecline = !submitButton(stepper).disabled; + await submit(stepper); + approvedCalls = execution.calls; + }); + afterEach(async () => await unmount(stepper)); + + it('should not run or report a declined submission', () => { + declinedCalls.should.equal(0); + failedCallsAfterDecline.should.equal(0); + exceptionCallsAfterDecline.should.equal(0); + }); + it('should permit another submit after decline', () => { submitEnabledAfterDecline.should.equal(true); }); + it('should run an approved submission', () => { approvedCalls.should.equal(1); }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_confirming_before_execution.tsx b/Source/CommandDialog/for_StepperCommandDialog/when_confirming_before_execution.tsx new file mode 100644 index 00000000..63189004 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_confirming_before_execution.tsx @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// @vitest-environment jsdom + +import React from 'react'; +import { vi } from 'vitest'; +import { StepperPanel } from '../StepperPanel'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { click, disabledButtonLabels, render, unmount, type StepperDialogInTheDom } from './given/a_stepper_dialog_in_the_dom'; + +const execution = vi.hoisted(() => ({ calls: 0 })); +const closeDialog = vi.hoisted(() => vi.fn()); +vi.mock('../../Dialogs/Dialog', () => ({ + Dialog: (props: { buttons?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.buttons, props.children), +})); +vi.mock('../../Common/Button', () => ({ + Button: (props: { children?: React.ReactNode; disabled?: boolean; onClick?: () => void }) => + React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.children), +})); +vi.mock('@cratis/arc.react/dialogs', () => ({ + DialogResult: { Ok: 3, Cancelled: 4 }, useDialogContext: () => ({ closeDialog }), +})); +vi.mock('@cratis/arc.react/commands', () => { + const context = { isValid: true, setCommandValues: () => undefined, setCommandResult: () => undefined, getFieldError: () => undefined }; + const command = { name: 'Example', execute: async () => { execution.calls++; return { isSuccess: true, response: {} }; } }; + return { + CommandForm: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children), + useCommandFormContext: () => context, + useCommandInstance: () => command, + CommandFormFieldWrapper: () => null, + }; +}); +class SampleCommand { name = 'Example'; } + +const guard = vi.fn(async () => false); +const onSuccess = vi.fn(); +const onFailed = vi.fn(); +describe('when confirming a stepper dialog submission', () => { + let dialog: StepperDialogInTheDom; + let declinedCalls: number; + let approvedCalls: number; + let enabledAfterDecline: boolean; + beforeEach(async () => { + execution.calls = 0; + closeDialog.mockClear(); onSuccess.mockClear(); onFailed.mockClear(); + guard.mockClear().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + dialog = await render( + command={SampleCommand} title='Example' + confirmBeforeExecute={guard} onSuccess={onSuccess} onFailed={onFailed}> + Example content + , + ); + await click(dialog, 'Submit'); + declinedCalls = execution.calls; + enabledAfterDecline = !disabledButtonLabels(dialog).includes('Submit'); + await click(dialog, 'Submit'); + approvedCalls = execution.calls; + }); + afterEach(async () => await unmount(dialog)); + it('should not execute or close on decline', () => { + declinedCalls.should.equal(0); + closeDialog.mock.calls.length.should.equal(1); + onFailed.mock.calls.length.should.equal(0); + }); + it('should release busy state after decline', () => { enabledAfterDecline.should.equal(true); }); + it('should execute on approval', () => { approvedCalls.should.equal(1); }); +}); diff --git a/Source/CommandDialog/useSubmissionFlight.ts b/Source/CommandDialog/useSubmissionFlight.ts new file mode 100644 index 00000000..7f443f9d --- /dev/null +++ b/Source/CommandDialog/useSubmissionFlight.ts @@ -0,0 +1,30 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState } from 'react'; + +/** Tracks a submission synchronously so rapid clicks cannot start a second flight. */ +export const useSubmissionFlight = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const inFlight = useRef(false); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); + + const begin = () => { + if (!mounted.current || inFlight.current) return false; + inFlight.current = true; + setIsSubmitting(true); + return true; + }; + + const finish = () => { + inFlight.current = false; + if (mounted.current) setIsSubmitting(false); + }; + + return { isSubmitting, begin, finish, isMounted: () => mounted.current }; +}; From d16dc4de9c711da0800105ce25912e66a2a1c152 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 11:58:58 +0200 Subject: [PATCH 04/60] Show Chat query loading and failure states --- Source/Chat/ChatConversation.tsx | 26 ++++- Source/Chat/ChatSidebar.stories.tsx | 15 +++ Source/Chat/ChatSidebar.tsx | 13 ++- .../Chat/ChatSidebarForObservableQueries.tsx | 19 +++- Source/Chat/ChatStatus.ts | 14 +++ Source/Chat/ChatTopicList.tsx | 26 ++++- .../when_rendering_query_states.ts | 89 +++++++++++++++ .../when_rendering_query_states.tsx | 101 ++++++++++++++++++ .../when_rendering_query_states.ts | 84 +++++++++++++++ Source/Chat/index.ts | 1 + 10 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 Source/Chat/ChatStatus.ts create mode 100644 Source/Chat/for_ChatConversation/when_rendering_query_states.ts create mode 100644 Source/Chat/for_ChatSidebarForObservableQueries/when_rendering_query_states.tsx create mode 100644 Source/Chat/for_ChatTopicList/when_rendering_query_states.ts diff --git a/Source/Chat/ChatConversation.tsx b/Source/Chat/ChatConversation.tsx index b40f600c..927b0e55 100644 --- a/Source/Chat/ChatConversation.tsx +++ b/Source/Chat/ChatConversation.tsx @@ -16,6 +16,7 @@ import type { ChatAuthor } from './ChatAuthor'; import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier'; import type { ChatMention } from './ChatMention'; import type { ChatMessage } from './ChatMessage'; +import { ChatStatus } from './ChatStatus'; import type { ChatMessageAction } from './ChatMessageAction'; import { ChatMessageBody } from './ChatMessageBody'; import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp'; @@ -39,6 +40,15 @@ const ReplyIcon = () => ( * back to a literal English default — this library ships no i18n mechanism of its own, so a host * that localizes passes its own translated strings through here. */ export interface ChatConversationLabels { + /** Shown while loading without messages. Defaults to `'Loading messages…'`. */ + loading?: string; + + /** Shown when loading messages fails without existing messages. Defaults to `'Could not load messages.'`. */ + failed?: string; + + /** Shown when access is denied without existing messages. Defaults to `'You are not authorized to view these messages.'`. */ + unauthorized?: string; + /** Shown when there are no messages yet. Defaults to `'No messages yet. Say hello!'`. */ empty?: string; @@ -72,6 +82,9 @@ export interface ChatConversationProps({ messages, + status = ChatStatus.Ready, onSendMessage, authorOf, renderAvatar, @@ -255,10 +269,18 @@ export const ChatConversation = ({ return (
-
+
0 || undefined}> {messages.length === 0 && (

- {labels?.empty ?? 'No messages yet. Say hello!'} + {status === ChatStatus.Loading ? ( + {labels?.loading ?? 'Loading messages…'} + ) : status === ChatStatus.Failed ? ( + {labels?.failed ?? 'Could not load messages.'} + ) : status === ChatStatus.Unauthorized ? ( + {labels?.unauthorized ?? 'You are not authorized to view these messages.'} + ) : ( + labels?.empty ?? 'No messages yet. Say hello!' + )}

)} {messages.map((message, index) => { diff --git a/Source/Chat/ChatSidebar.stories.tsx b/Source/Chat/ChatSidebar.stories.tsx index 71b581b6..b62de131 100644 --- a/Source/Chat/ChatSidebar.stories.tsx +++ b/Source/Chat/ChatSidebar.stories.tsx @@ -11,6 +11,7 @@ import type { ChatIdentifier } from './ChatIdentifier'; import type { ChatMention } from './ChatMention'; import type { ChatMessage } from './ChatMessage'; import { ChatSidebar } from './ChatSidebar'; +import { ChatStatus } from './ChatStatus'; import type { ChatTopic } from './ChatTopic'; const authors: Record = { @@ -76,6 +77,20 @@ export const Playground: Story = { }, }; +/** Change topicsStatus in Controls to see the empty-list states; set selectedTopicId to topic-1 to see the failed conversation. */ +export const QueryStates: Story = { + args: { + open: true, + onClose: fn(), + topics: [], + messages: [], + selectedTopicId: null, + topicsStatus: ChatStatus.Loading, + messagesStatus: ChatStatus.Failed, + onSendMessage: fn(), + }, +}; + /** * The whole contract played by a simulated host: starting a topic creates one and opens it, the * first message in it triggers `onRequestTopicName` — the host "asks its LLM" (a 1.5s timer here) diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index 984c2d73..96b9727a 100644 --- a/Source/Chat/ChatSidebar.tsx +++ b/Source/Chat/ChatSidebar.tsx @@ -11,6 +11,7 @@ import { sameChatIdentifier, type ChatIdentifier } from './ChatIdentifier'; import type { ChatMention } from './ChatMention'; import type { ChatMessage } from './ChatMessage'; import type { ChatTopic } from './ChatTopic'; +import type { ChatStatus } from './ChatStatus'; import type { ChatTopicListLabels } from './ChatTopicList'; import { ChatTopicList } from './ChatTopicList'; import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed'; @@ -85,7 +86,7 @@ export interface ChatSidebarProps< TTopic extends ChatTopic = ChatTopic, > extends Omit< ChatConversationProps, - 'messages' | 'onSendMessage' | 'labels' | 'className' + 'messages' | 'onSendMessage' | 'labels' | 'className' | 'status' > { /** Whether the sidebar is open. */ open: boolean; @@ -106,6 +107,12 @@ export interface ChatSidebarProps< */ messages: TMessage[]; + /** Topic-list query display state. Defaults to ready when omitted. */ + topicsStatus?: ChatStatus; + + /** Conversation query display state. Defaults to ready when omitted. */ + messagesStatus?: ChatStatus; + /** * The open topic, for hosts that own the selection themselves: an identifier opens that * topic's conversation, `null` shows the topic list, and leaving the prop unset lets the @@ -218,6 +225,8 @@ export const ChatSidebar = < onClose, topics, messages, + topicsStatus, + messagesStatus, selectedTopicId, onTopicSelected, onStartTopic, @@ -390,6 +399,7 @@ export const ChatSidebar = < {openTopicId === undefined ? ( topics={topics} + status={topicsStatus} onOpen={(topic) => select(topic.id, topic)} onStart={ onStartTopic @@ -411,6 +421,7 @@ export const ChatSidebar = < {...conversation} messages={openMessages} + status={messagesStatus} onSendMessage={send} labels={labels?.conversation} /> diff --git a/Source/Chat/ChatSidebarForObservableQueries.tsx b/Source/Chat/ChatSidebarForObservableQueries.tsx index ca848c6a..75f36605 100644 --- a/Source/Chat/ChatSidebarForObservableQueries.tsx +++ b/Source/Chat/ChatSidebarForObservableQueries.tsx @@ -5,10 +5,23 @@ import React, { useState } from 'react'; import type { Constructor } from '@cratis/fundamentals'; import type { IObservableQueryFor } from '@cratis/arc/queries'; import { useObservableQuery } from '@cratis/arc.react/queries'; +import { DataTableStatus } from '../DataTables/DataTableStatus'; +import { resolveDataTableStatus } from '../DataTables/resolveDataTableStatus'; import { ChatSidebar, type ChatSidebarProps } from './ChatSidebar'; import type { ChatIdentifier } from './ChatIdentifier'; import type { ChatMessage } from './ChatMessage'; import type { ChatTopic } from './ChatTopic'; +import { ChatStatus } from './ChatStatus'; + +const chatStatusByTableStatus: Record = { + [DataTableStatus.Ready]: ChatStatus.Ready, + [DataTableStatus.Loading]: ChatStatus.Loading, + [DataTableStatus.Failed]: ChatStatus.Failed, + [DataTableStatus.Unauthorized]: ChatStatus.Unauthorized, +}; + +const resolveChatStatus = (result: Parameters[0]): ChatStatus => + chatStatusByTableStatus[resolveDataTableStatus(result)]; /** * Props for {@link ChatSidebarForObservableQueries}. @@ -28,7 +41,7 @@ export interface ChatSidebarForObservableQueriesProps< TMessagesArguments extends object = object, > extends Omit< ChatSidebarProps, - 'topics' | 'messages' | 'selectedTopicId' + 'topics' | 'messages' | 'selectedTopicId' | 'topicsStatus' | 'messagesStatus' > { /** The observable query delivering the topics. */ topicsQuery: Constructor; @@ -109,6 +122,10 @@ export const ChatSidebarForObservableQueries = < {...sidebar} topics={topics} messages={messages} + topicsStatus={resolveChatStatus(topicsResult)} + messagesStatus={selectedId !== undefined && messagesQueryArguments !== undefined + ? resolveChatStatus(messagesResult) + : ChatStatus.Ready} selectedTopicId={selectedId ?? null} onTopicSelected={(topicId, topic) => { setSelectedId(topicId); diff --git a/Source/Chat/ChatStatus.ts b/Source/Chat/ChatStatus.ts new file mode 100644 index 00000000..e58bdb93 --- /dev/null +++ b/Source/Chat/ChatStatus.ts @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Display state of a chat topic list or conversation. */ +export enum ChatStatus { + /** Shows topics or messages, or the empty state when nothing is pending. */ + Ready = 'ready', + /** Shows a loading message without data, or retains existing content during a refetch. */ + Loading = 'loading', + /** Shows a failure message without data, or retains existing content after a failure. */ + Failed = 'failed', + /** Shows an access-denied message without data, or retains existing content. */ + Unauthorized = 'unauthorized', +} diff --git a/Source/Chat/ChatTopicList.tsx b/Source/Chat/ChatTopicList.tsx index 97647fc1..93d72ae9 100644 --- a/Source/Chat/ChatTopicList.tsx +++ b/Source/Chat/ChatTopicList.tsx @@ -7,6 +7,7 @@ import { ChatAuthorKind } from './Kit/ChatAuthorKind'; import type { ChatAuthor } from './ChatAuthor'; import { chatIdentifierString, type ChatIdentifier } from './ChatIdentifier'; import type { ChatTopic } from './ChatTopic'; +import { ChatStatus } from './ChatStatus'; import { isTopicUnnamed as defaultIsTopicUnnamed } from './isTopicUnnamed'; import { relativeTimestamp, type RelativeTimestampLabels } from './relativeTimestamp'; import { topicsByActivity } from './topicsByActivity'; @@ -38,6 +39,15 @@ export interface ChatTopicListLabels { /** The started-by line under a topic's name. `{name}` is substituted. Defaults to `'Started by {name}'`. */ startedBy?: string; + /** Shown while loading without topics. Defaults to `'Loading topics…'`. */ + loading?: string; + + /** Shown when loading topics fails without existing topics. Defaults to `'Could not load topics.'`. */ + failed?: string; + + /** Shown when access is denied without existing topics. Defaults to `'You are not authorized to view these topics.'`. */ + unauthorized?: string; + /** Shown when there are no topics yet. Defaults to `'No topics yet. Start the first one!'`. */ empty?: string; @@ -56,6 +66,9 @@ export interface ChatTopicListProps { */ topics: TTopic[]; + /** Query display state. Defaults to {@link ChatStatus.Ready}; existing topics remain visible on loading or failure. */ + status?: ChatStatus; + /** * Invoked when a topic is picked from the list. * @param topic The topic that was picked. @@ -111,6 +124,7 @@ export interface ChatTopicListProps { */ export const ChatTopicList = ({ topics, + status = ChatStatus.Ready, onOpen, onStart, authorOf, @@ -129,7 +143,7 @@ export const ChatTopicList = ({ }; return ( -
+
0 || undefined}> {onStart && ( )} - {topics.length === 0 && ( + {(topics.length === 0 || status === ChatStatus.Unauthorized) && (

{status === ChatStatus.Loading ? ( {labels?.loading ?? 'Loading topics…'} @@ -168,7 +168,7 @@ export const ChatTopicList = ({

)}
    - {topicsByActivity(topics).map((topic) => { + {status !== ChatStatus.Unauthorized && topicsByActivity(topics).map((topic) => { const starter = topic.startedBy === undefined ? undefined diff --git a/Source/Chat/for_ChatConversation/when_rendering_query_states.ts b/Source/Chat/for_ChatConversation/when_rendering_query_states.ts index 38e25aca..afb45a8b 100644 --- a/Source/Chat/for_ChatConversation/when_rendering_query_states.ts +++ b/Source/Chat/for_ChatConversation/when_rendering_query_states.ts @@ -40,7 +40,7 @@ describe.each([ }); }); -describe.each([ChatStatus.Loading, ChatStatus.Failed, ChatStatus.Unauthorized])( +describe.each([ChatStatus.Loading, ChatStatus.Failed])( 'when the conversation is %s with messages', (status) => { beforeEach(async () => { await mount(status, [message]); @@ -67,6 +67,17 @@ describe.each([ }); }); +describe('when access to previously loaded messages is denied', () => { + beforeEach(async () => { + await mount(ChatStatus.Unauthorized, [message]); + }); + + it('should show the access-denied alert without exposing previous messages', () => { + conversation.container.querySelector('[role="alert"]')!.textContent!.should.equal('You are not authorized to view these messages.'); + (conversation.container.querySelector('.cratis-chat-message__body') === null).should.be.true; + }); +}); + describe('when messages are refetching', () => { beforeEach(async () => { await mount(ChatStatus.Loading, [message]); diff --git a/Source/Chat/for_ChatTopicList/when_rendering_query_states.ts b/Source/Chat/for_ChatTopicList/when_rendering_query_states.ts index eaefdbca..c62ff5da 100644 --- a/Source/Chat/for_ChatTopicList/when_rendering_query_states.ts +++ b/Source/Chat/for_ChatTopicList/when_rendering_query_states.ts @@ -35,7 +35,7 @@ describe.each([ }); }); -describe.each([ChatStatus.Loading, ChatStatus.Failed, ChatStatus.Unauthorized])( +describe.each([ChatStatus.Loading, ChatStatus.Failed])( 'when the topic list is %s with topics', (status) => { beforeEach(async () => { await mount(status, [topic]); @@ -62,6 +62,17 @@ describe.each([ }); }); +describe('when access to previously loaded topics is denied', () => { + beforeEach(async () => { + await mount(ChatStatus.Unauthorized, [topic]); + }); + + it('should show the access-denied alert without exposing previous topics', () => { + list.container.querySelector('[role="alert"]')!.textContent!.should.equal('You are not authorized to view these topics.'); + (list.container.querySelector('.cratis-chat-topics__topic') === null).should.be.true; + }); +}); + describe('when topics are refetching', () => { beforeEach(async () => { await mount(ChatStatus.Loading, [topic]); From 2d3cdc30f628b52b7fbe2e230e31b141b2497a58 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 12:02:20 +0200 Subject: [PATCH 08/60] Clarify Chat authorization state behavior --- Documentation/Chat/index.md | 2 +- Documentation/Chat/observable-queries.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/Chat/index.md b/Documentation/Chat/index.md index ecbb9987..53c56ad3 100644 --- a/Documentation/Chat/index.md +++ b/Documentation/Chat/index.md @@ -114,7 +114,7 @@ If your application owns the queries, import `ChatStatus` from `@cratis/componen | `ChatStatus.Ready` | Ordinary empty-state text | Existing content | | `ChatStatus.Loading` | Loading text with a status announcement | Existing content remains visible during a refetch | | `ChatStatus.Failed` | Failure text with an alert | Existing content remains visible | -| `ChatStatus.Unauthorized` | Access-denied text with an alert | Existing content remains visible | +| `ChatStatus.Unauthorized` | Access-denied text with an alert | Access-denied alert replaces the content | Override these messages with `labels.topicList.loading`, `.failed`, or `.unauthorized` for topics, and `labels.conversation.loading`, `.failed`, or `.unauthorized` for messages. Unset fields use English defaults; the existing `empty` labels still apply to successful empty results. Chat labels do not come from `CratisComponentsProvider.messages`. The [observable-query wrapper](./observable-queries.md#loading-and-failed-queries) resolves statuses for you. diff --git a/Documentation/Chat/observable-queries.md b/Documentation/Chat/observable-queries.md index 33855267..1b1869a4 100644 --- a/Documentation/Chat/observable-queries.md +++ b/Documentation/Chat/observable-queries.md @@ -47,7 +47,7 @@ export const LiveChat = () => { ## Loading and failed queries -The topics and messages queries resolve their statuses independently. Authorization denial takes precedence over failure or validation errors; failure takes precedence over loading. When a query is performing with no data, its list or conversation shows a loading message. When it completes successfully with an empty result, the ordinary empty-state text returns. Existing topics and messages stay visible during a refetch or after a failure, rather than disappearing behind a status message. +The topics and messages queries resolve their statuses independently. Authorization denial takes precedence over failure or validation errors; failure takes precedence over loading. When a query is performing with no data, its list or conversation shows a loading message. When it completes successfully with an empty result, the ordinary empty-state text returns. Existing topics and messages stay visible during a refetch or after a failure, rather than disappearing behind a status message. An unauthorized result hides previously loaded content. Set `labels.topicList.loading`, `labels.topicList.failed`, and `labels.topicList.unauthorized` for the topic list; set the corresponding `labels.conversation` keys for messages. The English defaults are “Loading topics…”, “Could not load topics.”, and “You are not authorized to view these topics.” for the list, and “Loading messages…”, “Could not load messages.”, and “You are not authorized to view these messages.” for the conversation. Chat uses `labels`, not the provider's `messages.dataTable` settings. From a72e5f50f348c6f9c4ceaba10443b1ebfc1f8023 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 12:09:48 +0200 Subject: [PATCH 09/60] Document DatePickerInput and turn Common and Dialogs landings into selection guides Adds a DatePickerInput reference page (props, Date | null contract, locale and messages.datePicker, React Aria keyboard and screen-reader behavior, validation states, stable parts), registers it in the Common toc, and links it from CalendarField. Rewrites the Common and Dialogs landing pages as 'which component do I use' guides. Docs only; no API changes. The Tooltip reference page is deferred. Prose was AI-drafted; review it as prose as well as for accuracy. Refs #260 --- Documentation/CommandForm/calendar-field.md | 2 +- Documentation/Common/date-picker-input.md | 226 ++++++++++++++++++++ Documentation/Common/index.md | 83 +++---- Documentation/Common/toc.yml | 2 + Documentation/Dialogs/index.md | 31 ++- 5 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 Documentation/Common/date-picker-input.md diff --git a/Documentation/CommandForm/calendar-field.md b/Documentation/CommandForm/calendar-field.md index 04ff6c12..d76a2f53 100644 --- a/Documentation/CommandForm/calendar-field.md +++ b/Documentation/CommandForm/calendar-field.md @@ -3,7 +3,7 @@ title: CalendarField description: Bind a Date property on an Arc command to the locale-aware Cratis date picker. --- -`CalendarField` wraps the internationalized Cratis `DatePickerInput` while preserving a `Date | null` command value. +`CalendarField` wraps the internationalized Cratis [`DatePickerInput`](../Common/date-picker-input.md) while preserving a `Date | null` command value. ## Usage diff --git a/Documentation/Common/date-picker-input.md b/Documentation/Common/date-picker-input.md new file mode 100644 index 00000000..5c7ddc30 --- /dev/null +++ b/Documentation/Common/date-picker-input.md @@ -0,0 +1,226 @@ +--- +title: DatePickerInput +description: Enter a controlled Date | null through locale-aware segments and a calendar popover, with bounds, Today and Clear actions, and stable parts. +--- + +`DatePickerInput` is the standalone date and date-time picker. The user types into locale-ordered segments (day, month, year and, optionally, hour and minute) or picks a day from a calendar popover. Your code only sees a plain JavaScript `Date | null`; the React Aria calendar values the control uses internally never cross its props. + +Use it for date entry in ordinary React state. To bind a `Date` property on an Arc command, use [`CalendarField`](../CommandForm/calendar-field.md), which wraps this control. + +```tsx +import { DatePickerInput } from '@cratis/components/Common'; +``` + +## Controlled usage + +```tsx +import { useState } from 'react'; +import { DatePickerInput } from '@cratis/components/Common'; + +export const SampleDeliveryDate = () => { + const [deliveryDate, setDeliveryDate] = useState(null); + const earliest = new Date(2030, 0, 1); + const latest = new Date(2030, 11, 31); + const outOfRange = + deliveryDate !== null && (deliveryDate < earliest || deliveryDate > latest); + + return ( +
    + +

    + {outOfRange ? 'Pick a date in 2030.' : 'Deliveries run throughout 2030.'} +

    +
    + ); +}; +``` + +Render it under [`CratisComponentsProvider`](cratis-components-provider.md) so the locale and labels come from your configuration. Without the provider, the labels use the English defaults and React Aria falls back to the browser's locale. `value` and `onChange` are both required: the control is controlled, so pass the new date back through `value`. + +`minDate`/`maxDate` disable out-of-range calendar cells and the Today action, but typing or stepping a segment can still produce an out-of-range date, and `onChange` receives it. The example therefore computes `invalid` from the value itself. See [Validation and states](#validation-and-states). + +## Value and change contract + +| Aspect | Behavior | +| --- | --- | +| Value type | `Date \| null`. `null` means no date. Conversion to and from `@internationalized/date` happens inside the component. | +| Time zone | Conversions use the browser's local time zone. | +| Date-only mode (default) | The time portion of an incoming `value` is ignored. Emitted dates are local midnight. | +| Date-time mode (`showTime`) | Hour and minute segments are added. `minDate`/`maxDate` are compared including their time. | +| `onChange` signature | `(value: Date \| null, meta?: ChangeMeta) => void`. A React state setter can be passed directly. | +| `meta.source` | `'user'` for every change the component emits. | +| `meta.nativeEvent` | Present for the Today and Clear actions; absent for segment and calendar changes. | +| Segment editing | `onChange` fires when the segments form a complete date. Clearing one segment leaves the last complete value in place without a callback; clearing every segment emits `null`. | +| Today action | Emits today's date (local midnight, also in date-time mode). Disabled and inert when today falls outside `minDate`/`maxDate`. | +| Clear action | Emits `null`. | +| Programmatic changes | Changing `value` from outside never calls `onChange`. | +| Disabled or read-only | Segments, trigger and calendar cannot change the value. | + +The Today and Clear buttons call `onChange` directly; they do not close the popover themselves. + +## Props + +| Prop | Type | Default | Behavior | +| --- | --- | --- | --- | +| `value` | `Date \| null` | Required | Controlled value. | +| `onChange` | `ChangeHandler` | Required | Receives the next date or `null` and optional change metadata. | +| `onBlur` | `FocusEventHandler` | — | Attached to the root wrapper. React focus events bubble, so it also fires when focus moves between segments or into the calendar, not only when focus leaves the picker. | +| `invalid` | `boolean` | `false` | Marks the picker invalid. See [Validation and states](#validation-and-states). | +| `disabled` | `boolean` | `false` | Disables the segments, trigger and calendar. | +| `readOnly` | `boolean` | `false` | Keeps segments focusable but prevents editing; the calendar trigger is disabled. | +| `id` | `string` | — | DOM id of the segmented-input group. | +| `placeholder` | `string` | — | Text shown while the value is `null` and the field is not focused. Also used as the accessible name when `aria-label` is absent. | +| `showIcon` | `boolean` | `true` | Renders the calendar trigger button. Without it, the calendar is still reachable with `Alt+ArrowDown`. | +| `showButtonBar` | `boolean` | `false` | Adds Today and Clear actions below the calendar. | +| `showTime` | `boolean` | `false` | Adds hour and minute segments. | +| `hourFormat` | `'12' \| '24'` | Locale | Hour cycle for the time segments. Omit it to use the locale's hour cycle. | +| `minDate` / `maxDate` | `Date` | Unbounded | Earliest and latest selectable date for the calendar and the Today action. | +| `todayLabel` / `clearLabel` | `string` | Provider message | Per-instance labels for the Today and Clear actions. | +| `aria-label` | `string` | See [Labels](#locale-and-labels) | Accessible name of the segmented-input group. | +| `aria-labelledby` | `string` | — | Id of an element that labels the picker. | +| `aria-describedby` | `string` | — | Id of an element that describes the picker. | +| `className` | `string` | — | Added to the root element, after `pt.root.className`. | +| `style` | `CSSProperties` | — | Inline style on the root element; merged over `pt.root.style`. | +| `pt` | `DatePickerInputPassThrough` | — | Per-part attributes. See [Stable parts](#stable-parts). | +| `dateFormat` | `string` | — | Accepted for source compatibility and ignored. The locale controls formatting. | +| `ptOptions` | `object` | — | Deprecated and ignored. Parts always merge. | +| `unstyled` | `boolean` | — | Deprecated and ignored. Style through `pt` and CSS. | + +`DatePickerInputProps` and `DatePickerInputPassThrough` are exported types. + +Top-level props take precedence over their `pt.input` equivalents. When a top-level prop is omitted, the component falls back to `pt.input.id`, `disabled`, `readOnly`, `placeholder`, `aria-invalid`, `aria-label`, `aria-labelledby` and `aria-describedby`; `pt.input.id` is applied to the group, like `id`. Other `pt.input` attributes, such as `data-*`, are forwarded to the segmented input. + +## Locale and labels + +The locale comes from the nearest `CratisComponentsProvider` (`value.locale`, default `en-US`; an invalid locale also falls back to `en-US`). It decides segment order, separators, the calendar system and the default hour cycle. There is no per-instance locale prop. + +Components-owned text comes from `messages.datePicker`: + +| Message key | Used for | Default | +| --- | --- | --- | +| `label` | Accessible name fallback for the segmented input | `Date` | +| `today` | Today action | `Today` | +| `clear` | Clear action | `Clear` | +| `openCalendar` | Calendar trigger name | `Open calendar` | +| `previousMonth` | Previous-month button name | `Previous month` | +| `nextMonth` | Next-month button name | `Next month` | + +```tsx +import { CratisComponentsProvider } from '@cratis/components'; + + + +; +``` + +Resolution order for each text: + +- **Accessible name:** `aria-label` → `pt.input['aria-label']` → `placeholder` → `messages.datePicker.label` → `Date`. +- **Today / Clear:** `todayLabel` / `clearLabel` → `messages.datePicker.today` / `.clear` → English default. +- **Trigger and month buttons:** `pt.trigger`, `pt.previous` or `pt.next` `aria-label` → provider message → English default. + +The previous- and next-month glyphs follow the provider's `icons.previous` and `icons.next`. See [Localize owned labels](cratis-components-provider.md#localize-owned-labels) and [Register an icon set](cratis-components-provider.md#register-an-icon-set). + +## Keyboard and screen readers + +The segmented input, calendar and popover are React Aria's `DatePicker` parts. The behavior below is what React Aria provides in the installed version; verify it with the assistive technologies your application supports. + +| Key | Where | Effect | +| --- | --- | --- | +| `Tab` / `Shift+Tab` | Segments, trigger | Each editable segment is its own tab stop, followed by the trigger. | +| `ArrowLeft` / `ArrowRight` | Segment | Move to the previous or next segment (visual order in right-to-left locales). | +| `ArrowUp` / `ArrowDown` | Segment | Increment or decrement the segment, wrapping at its limits. | +| `PageUp` / `PageDown` | Segment | Step by a larger amount (for example 7 days, 2 months, 5 years, 15 minutes). | +| `Home` / `End` | Segment | Set the segment to its minimum or maximum. | +| Digits | Segment | Type the segment value. | +| `Backspace` / `Delete` | Segment | Remove the last digit, then clear the segment. | +| `Alt+ArrowDown` / `Alt+ArrowUp` | Segmented input | Open the calendar popover. | + +Inside the popover, the calendar grid follows React Aria's calendar keyboard model; see the [React Aria DatePicker documentation](https://react-spectrum.adobe.com/react-aria/DatePicker.html). + +What assistive technology receives: + +- The segmented input is a `group` carrying the resolved accessible name, `aria-labelledby`, `aria-describedby` and, when `invalid` is set, `aria-invalid`. +- Each editable segment is a `spinbutton` named with its localized segment type followed by the group's name, for example "month, Delivery date". On iOS, where VoiceOver cannot focus spinbuttons, React Aria renders segments as `textbox` instead. +- Separator segments (such as `/` or `.`) are hidden from assistive technology. +- `aria-describedby` is applied to the first editable segment only, unless the field is invalid, so the description is not repeated on every segment. +- The visible placeholder is `aria-hidden`; its text reaches screen readers as the accessible name fallback instead. +- The trigger, previous-month and next-month buttons have the localized names listed above; their glyphs are `aria-hidden`. + +:::caution[Naming with aria-labelledby] +The group always carries a resolved `aria-label` (falling back to `Date`). React Aria then adds the group itself to `aria-labelledby`, so a picker labelled only by `aria-labelledby` is announced as "Date" followed by the referenced text. When that prefix is unwanted, pass `aria-label` with the visible label text instead of `aria-labelledby`. A `
@@ -114,20 +116,20 @@ export function Toolbar({ type="button" onClick={onZoomOut} disabled={zoomLevel <= ZOOM_MIN} - title="Zoom out" + title={labels?.zoomOut ?? 'Zoom out'} > − {isEditingZoom ? ( ({ autoFocus /> ) : ( - + {Math.round(zoomLevel * 100)}% )} @@ -172,18 +174,18 @@ export function Toolbar({ className={viewMode === 'collection' ? 'active' : ''} onClick={() => onViewModeChange('collection')} > - Collection + {labels?.collection ?? 'Collection'}