diff --git a/Source/Common/DatePickerInput.css b/Source/Common/DatePickerInput.css index 95e7176..594022e 100644 --- a/Source/Common/DatePickerInput.css +++ b/Source/Common/DatePickerInput.css @@ -34,6 +34,7 @@ .cratis-date-picker__input { display: flex; flex: 1; + flex-wrap: wrap; min-width: 0; padding: 0.625rem 0.75rem; } @@ -56,6 +57,13 @@ outline: none; font-variant-numeric: tabular-nums; } +.cratis-date-picker__segment:not([data-type='literal']) { + display: flex; + min-width: 24px; + min-height: 24px; + align-items: center; + justify-content: center; +} .cratis-date-picker__segment[data-placeholder] { color: var(--cratis-text-color-secondary); } diff --git a/Source/Common/DatePickerInput.stories.tsx b/Source/Common/DatePickerInput.stories.tsx index 64d28e4..93add3c 100644 --- a/Source/Common/DatePickerInput.stories.tsx +++ b/Source/Common/DatePickerInput.stories.tsx @@ -10,7 +10,10 @@ const meta = { title: 'Common/DatePickerInput', component: DatePickerInput, args: { value: null, onChange: fn() }, - parameters: { layout: 'padded' }, + parameters: { + layout: 'padded', + a11y: { config: { rules: [{ id: 'target-size', enabled: true }] } }, + }, tags: ['autodocs'], } satisfies Meta; @@ -52,6 +55,107 @@ export const StateMatrix: Story = { ), }; +export const SegmentHitTargets: Story = { + render: () => ( +
+ + +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const segments = canvas.getAllByRole('spinbutton'); + const undersized = segments + .map((segment) => segment.getBoundingClientRect()) + .filter((rect) => rect.width < 24 || rect.height < 24); + await expect( + undersized, + `undersized segments: ${JSON.stringify( + segments.map((segment) => { + const rect = segment.getBoundingClientRect(); + return { + type: segment.getAttribute('data-type'), + width: rect.width, + height: rect.height, + }; + }), + )}`, + ).toHaveLength(0); + }, +}; + +const ControlledTimePicker = ({ width }: { width: string }) => { + const [value, setValue] = useState(new Date(2024, 5, 15, 9, 30)); + return ( + + ); +}; + +// Every editable segment has to stay inside the input box it belongs to: overflowing it would +// either be clipped by the group or painted underneath the calendar trigger. +const segmentsOutsideTheirInput = (canvasElement: HTMLElement) => + Array.from(canvasElement.querySelectorAll('.cratis-date-picker__input')).flatMap( + (input) => { + const inputRect = input.getBoundingClientRect(); + return Array.from( + input.querySelectorAll( + "[data-cratis-part='segment']:not([data-type='literal'])", + ), + ) + .map((segment) => ({ segment, rect: segment.getBoundingClientRect() })) + .filter( + ({ rect }) => + rect.left < inputRect.left - 0.5 || + rect.right > inputRect.right + 0.5 || + rect.top < inputRect.top - 0.5 || + rect.bottom > inputRect.bottom + 0.5 || + rect.width < 24 || + rect.height < 24, + ) + .map(({ segment, rect }) => ({ + inputWidth: inputRect.width, + type: segment.getAttribute('data-type'), + left: rect.left, + right: rect.right, + width: rect.width, + height: rect.height, + })); + }, + ); + +export const NarrowShowTime12Hour: Story = { + render: () => ( +
+ + {/* A phone-sized content column: 375px viewport minus the usual 16px gutters. */} +
+ +
+
+ ), + play: async ({ canvasElement }) => { + const atRest = segmentsOutsideTheirInput(canvasElement); + await expect(atRest, JSON.stringify(atRest)).toHaveLength(0); + + const hour = canvasElement.querySelector("[data-type='hour']"); + if (!hour) throw new Error('Hour segment not found.'); + hour.focus(); + await userEvent.keyboard('{ArrowUp}'); + await expect(hour).toHaveTextContent('10'); + + const afterEditing = segmentsOutsideTheirInput(canvasElement); + await expect(afterEditing, JSON.stringify(afterEditing)).toHaveLength(0); + }, +}; + export const OpenCalendar: Story = { render: () => , play: async ({ canvasElement }) => { diff --git a/Source/Dropdown/Dropdown.css b/Source/Dropdown/Dropdown.css index 6b83578..8714afc 100644 --- a/Source/Dropdown/Dropdown.css +++ b/Source/Dropdown/Dropdown.css @@ -130,6 +130,7 @@ padding: 0.75rem 1.25rem; align-items: center; border-radius: calc(var(--cratis-border-radius) * 0.75); + outline: none; cursor: default; } .cratis-dropdown__option[data-focused] { @@ -139,6 +140,9 @@ background: var(--cratis-highlight-bg); color: var(--cratis-highlight-text-color); } +.cratis-dropdown__option[data-focus-visible] { + box-shadow: inset var(--cratis-focus-ring); +} .cratis-dropdown__option[data-disabled] { opacity: 0.5; } diff --git a/Source/Dropdown/Dropdown.stories.tsx b/Source/Dropdown/Dropdown.stories.tsx index ca7c122..74620f8 100644 --- a/Source/Dropdown/Dropdown.stories.tsx +++ b/Source/Dropdown/Dropdown.stories.tsx @@ -72,3 +72,77 @@ export const FilteredAndOpen: Story = { await expect(await within(document.body).findByRole('listbox')).toBeTruthy(); }, }; + +export const SelectedOptionFocusTreatment: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button', { name: /Role/ }); + trigger.focus(); + await userEvent.keyboard('{Enter}'); + const selectedOption = await within(document.body).findByRole('option', { + selected: true, + }); + await expect(document.activeElement).toBe(selectedOption); + const isFocusVisible = selectedOption.matches(':focus-visible'); + const computedStyle = getComputedStyle(selectedOption); + const diagnostics = JSON.stringify({ + isFocusVisible, + dataFocusVisible: selectedOption.getAttribute('data-focus-visible'), + outline: computedStyle.outline, + boxShadow: computedStyle.boxShadow, + }); + await expect(isFocusVisible, diagnostics).toBe(true); + // Either mechanism paints a real ring; this only fails if both are absent, so it + // stays sensitive to a regression without pinning outline vs. box-shadow forever. + const hasVisibleOutline = + computedStyle.outlineStyle !== 'none' && parseFloat(computedStyle.outlineWidth) > 0; + const hasVisibleBoxShadow = computedStyle.boxShadow !== 'none'; + await expect(hasVisibleOutline || hasVisibleBoxShadow, diagnostics).toBe(true); + }, +}; + +// Omits showClear on purpose: the clear button has a preexisting, unrelated aria-hidden-focus +// defect that axe flags independently of this fix, so this story isolates the filtered +// listbox's own arrow-key focus treatment rather than suppressing that separate violation. +const FilteredNoClearDropdown = () => { + const [value, setValue] = useState('admin'); + return ( + + aria-label='Role' + value={value} + options={roles} + optionLabel='label' + optionValue='value' + placeholder='Select a role' + filter + filterPlaceholder='Find a role' + onChange={setValue} + style={{ width: '18rem' }} + /> + ); +}; + +export const FilteredReopenedFocusTreatment: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /show options/i })); + const listbox = await within(document.body).findByRole('listbox'); + await userEvent.keyboard('{ArrowDown}{ArrowUp}'); + const focusedOption = within(listbox) + .getAllByRole('option') + .find((option) => option.getAttribute('data-focused') === 'true'); + if (!focusedOption) throw new Error('No option received keyboard focus after ArrowDown.'); + const computedStyle = getComputedStyle(focusedOption); + const diagnostics = JSON.stringify({ + focusedOptionText: focusedOption.textContent, + dataSelected: focusedOption.getAttribute('data-selected'), + dataFocused: focusedOption.getAttribute('data-focused'), + dataFocusVisible: focusedOption.getAttribute('data-focus-visible'), + boxShadow: computedStyle.boxShadow, + }); + await expect(focusedOption.getAttribute('data-selected'), diagnostics).toBe('true'); + await expect(computedStyle.boxShadow, diagnostics).not.toBe('none'); + }, +}; diff --git a/Source/Dropdown/for_Dropdown/when_styling_option_focus.ts b/Source/Dropdown/for_Dropdown/when_styling_option_focus.ts new file mode 100644 index 0000000..3e2171f --- /dev/null +++ b/Source/Dropdown/for_Dropdown/when_styling_option_focus.ts @@ -0,0 +1,80 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readFileSync } from 'node:fs'; +import { expect } from 'chai'; +import postcss, { type Rule } from 'postcss'; +import { describe, it } from 'vitest'; + +const source = readFileSync(new URL('../Dropdown.css', import.meta.url), 'utf8'); +const root = postcss.parse(source); + +const isWithinForcedColors = (rule: Rule) => + rule.parent?.type === 'atrule' && + 'name' in rule.parent && + rule.parent.name === 'media' && + 'params' in rule.parent && + typeof rule.parent.params === 'string' && + rule.parent.params.includes('forced-colors'); + +/** + * Finds every rule matching `selector` in the requested (forced-colors or normal-color) + * context, together with its position among rules in that same context. Returning every + * match - instead of silently keeping only the last one seen - lets callers assert there + * is exactly one, so an accidental duplicate selector fails loudly instead of the test + * quietly grading whichever rule happened to be declared last. + */ +const rulesMatching = (selector: string, insideForcedColors: boolean) => { + const matches: { rule: Rule; order: number }[] = []; + let order = 0; + root.walkRules((rule: Rule) => { + if (isWithinForcedColors(rule) === insideForcedColors && rule.selectors.includes(selector)) { + matches.push({ rule, order }); + } + order += 1; + }); + return matches; +}; + +const theOneRuleFor = (selector: string, insideForcedColors: boolean) => { + const matches = rulesMatching(selector, insideForcedColors); + expect( + matches, + `expected exactly one '${selector}' rule ${insideForcedColors ? 'inside' : 'outside'} @media (forced-colors: active)`, + ).to.have.lengthOf(1); + return matches[0]; +}; + +const declarationsFor = (selector: string, insideForcedColors: boolean) => + theOneRuleFor(selector, insideForcedColors) + .rule.nodes.filter((node) => node.type === 'decl') + .map((node) => `${node.prop}:${node.value}`); + +describe('when styling Dropdown option focus', () => { + it('should_order_the_focus_visible_rule_after_the_selected_rule_so_both_apply_together', () => { + // Cascade order, not exact declarations: [data-focus-visible] must lose no ground to + // [data-selected] on a focused-and-selected option, or the ring would be overridden. + const selected = theOneRuleFor('.cratis-dropdown__option[data-selected]', false); + const focusVisible = theOneRuleFor('.cratis-dropdown__option[data-focus-visible]', false); + expect(focusVisible.order).to.be.greaterThan(selected.order); + }); + + it('should_declare_a_focus_visible_box_shadow_consuming_the_shared_focus_ring_token', () => { + // Deliberately not asserting the exact declaration byte-for-byte (e.g. `inset` or + // spacing) - only that focus-visible paints via the public, themeable token rather + // than a hardcoded color, so the ring stays correct if the token's own value changes. + const declarations = declarationsFor('.cratis-dropdown__option[data-focus-visible]', false); + const boxShadow = declarations.find((declaration) => declaration.startsWith('box-shadow:')); + expect(boxShadow, 'expected a box-shadow declaration for the focus-visible state').to.exist; + expect(boxShadow).to.include('var(--cratis-focus-ring)'); + }); + + it('should_keep_the_forced_colors_highlight_outline_for_focused_and_selected_options', () => { + // Forced-colors system keywords are an exact OS contract, not a themeable token, so + // pinning the literal value here is intentional rather than brittle. + const focused = declarationsFor('.cratis-dropdown__option[data-focused]', true); + const selected = declarationsFor('.cratis-dropdown__option[data-selected]', true); + expect(focused).to.include('outline:2px solid Highlight'); + expect(selected).to.include('outline:2px solid Highlight'); + }); +}); diff --git a/Storybook/README.md b/Storybook/README.md index c1a14ae..f983d45 100644 --- a/Storybook/README.md +++ b/Storybook/README.md @@ -29,4 +29,4 @@ yarn workspace @cratis/components.storybook test-storybook yarn workspace @cratis/components.storybook dev ``` -`test-storybook` runs every discovered stable story in each isolated preview and both maintained appearance modes. The current V4 inventory is four previews × 277 stories × two appearances: **2,216 story/appearance/axe cases**. There is no story sampling or tag exclusion; generated preview indexes identify every executed story, while issue #217 tracks replacing release-snapshot count constants with generated reviewable inventories. The composed renderer control preserves a stable story id when available, but switching previews remounts the iframe and loses component-local state. +`test-storybook` runs every discovered stable story in each isolated preview and both maintained appearance modes. The current V4 inventory is four previews × 287 stories × two appearances: **2,296 story/appearance/axe cases**. There is no story sampling or tag exclusion; generated preview indexes identify every executed story, while issue #217 tracks replacing release-snapshot count constants with generated reviewable inventories. The composed renderer control preserves a stable story id when available, but switching previews remounts the iframe and loses component-local state. diff --git a/Storybook/scripts/run-story-matrix.mjs b/Storybook/scripts/run-story-matrix.mjs index 40df110..46eb229 100644 --- a/Storybook/scripts/run-story-matrix.mjs +++ b/Storybook/scripts/run-story-matrix.mjs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { discoverAdapterPackages } from './lib/adapter-inventory.mjs'; @@ -9,6 +10,7 @@ import { discoverAdapterPackages } from './lib/adapter-inventory.mjs'; const storybookRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const repositoryRoot = path.resolve(storybookRoot, '..'); const sourceRoot = path.join(repositoryRoot, 'Source'); +const outputRoot = path.join(sourceRoot, 'storybook-static/renderers'); const inventory = discoverAdapterPackages(repositoryRoot); const requestedAppearance = process.argv[2]; const appearances = requestedAppearance @@ -41,6 +43,15 @@ runNode( path.join(storybookRoot, 'scripts/verify-storybook-indexes.mjs'), ); +// Read the story count from the index the previous step just verified, rather than a +// hardcoded literal, so this log can never silently drift from the ratchet in +// verify-storybook-indexes.mjs again. +const builtInAdapter = inventory.adapters.find(adapter => adapter.builtIn) ?? inventory.adapters[0]; +const builtInIndex = JSON.parse( + readFileSync(path.join(outputRoot, builtInAdapter.metadata.id, 'index.json'), 'utf8'), +); +const storyCount = Object.values(builtInIndex.entries ?? {}).filter(entry => entry.type === 'story').length; + const vitest = path.join(repositoryRoot, 'node_modules/vitest/vitest.mjs'); for (const adapter of inventory.adapters) { for (const appearance of appearances) { @@ -73,9 +84,9 @@ for (const adapter of inventory.adapters) { } } -const matrixCount = inventory.adapters.length * 277 * appearances.length; +const matrixCount = inventory.adapters.length * storyCount * appearances.length; console.log( - `\nCompleted ${inventory.adapters.length} isolated previews × 277 stories × ${appearances.length} appearance mode(s) = ${matrixCount} story/appearance/axe cases.`, + `\nCompleted ${inventory.adapters.length} isolated previews × ${storyCount} stories × ${appearances.length} appearance mode(s) = ${matrixCount} story/appearance/axe cases.`, ); console.log('Story exclusions: none. No sampling or tag exclusion was applied.'); console.log( diff --git a/Storybook/scripts/verify-storybook-indexes.mjs b/Storybook/scripts/verify-storybook-indexes.mjs index a100c52..fd624bc 100644 --- a/Storybook/scripts/verify-storybook-indexes.mjs +++ b/Storybook/scripts/verify-storybook-indexes.mjs @@ -39,8 +39,8 @@ for (const adapter of inventory.adapters) { const entries = Object.values(index.entries ?? {}); const storyIds = entries.filter(entry => entry.type === 'story').map(entry => entry.id).sort(); const docsIds = entries.filter(entry => entry.type === 'docs').map(entry => entry.id).sort(); - if (storyIds.length !== 283 || docsIds.length !== 68) { - throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 283 and 68.`); + if (storyIds.length !== 287 || docsIds.length !== 68) { + throw new Error(`${adapter.metadata.id} indexed ${storyIds.length} stories and ${docsIds.length} autodocs pages; expected 287 and 68.`); } canonicalStoryIds ??= storyIds; canonicalDocsIds ??= docsIds;