Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Source/Common/DatePickerInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
.cratis-date-picker__input {
display: flex;
flex: 1;
flex-wrap: wrap;
min-width: 0;
padding: 0.625rem 0.75rem;
}
Expand All @@ -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);
}
Expand Down
106 changes: 105 additions & 1 deletion Source/Common/DatePickerInput.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof DatePickerInput>;

Expand Down Expand Up @@ -52,6 +55,107 @@ export const StateMatrix: Story = {
),
};

export const SegmentHitTargets: Story = {
render: () => (
<div style={{ display: 'grid', gap: '1rem', maxWidth: '20rem' }}>
<ControlledPicker />
<ControlledPicker initialValue={new Date(2024, 5, 15)} />
</div>
),
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<Date | null>(new Date(2024, 5, 15, 9, 30));
return (
<DatePickerInput
aria-label='Delivery date and time'
value={value}
onChange={setValue}
placeholder='Choose a date and time'
showTime
hourFormat='12'
style={{ width }}
/>
);
};

// 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<HTMLElement>('.cratis-date-picker__input')).flatMap(
(input) => {
const inputRect = input.getBoundingClientRect();
return Array.from(
input.querySelectorAll<HTMLElement>(
"[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: () => (
<div style={{ display: 'grid', gap: '1rem', justifyItems: 'start' }}>
<ControlledTimePicker width='18rem' />
{/* A phone-sized content column: 375px viewport minus the usual 16px gutters. */}
<div style={{ width: '343px' }}>
<ControlledTimePicker width='100%' />
</div>
</div>
),
play: async ({ canvasElement }) => {
const atRest = segmentsOutsideTheirInput(canvasElement);
await expect(atRest, JSON.stringify(atRest)).toHaveLength(0);

const hour = canvasElement.querySelector<HTMLElement>("[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: () => <ControlledPicker initialValue={new Date(2024, 5, 15)} />,
play: async ({ canvasElement }) => {
Expand Down
4 changes: 4 additions & 0 deletions Source/Dropdown/Dropdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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;
}
Expand Down
74 changes: 74 additions & 0 deletions Source/Dropdown/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,77 @@ export const FilteredAndOpen: Story = {
await expect(await within(document.body).findByRole('listbox')).toBeTruthy();
},
};

export const SelectedOptionFocusTreatment: Story = {
render: () => <ControlledDropdown initialValue='developer' />,
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<string | null>('admin');
return (
<Dropdown<string | null>
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: () => <FilteredNoClearDropdown />,
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');
},
};
80 changes: 80 additions & 0 deletions Source/Dropdown/for_Dropdown/when_styling_option_focus.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion Storybook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 13 additions & 2 deletions Storybook/scripts/run-story-matrix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
// 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';

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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions Storybook/scripts/verify-storybook-indexes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading