Skip to content

Commit 4ab0f18

Browse files
committed
Storybook: Compare composite focus indicators
1 parent e1af55e commit 4ab0f18

3 files changed

Lines changed: 396 additions & 1 deletion

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Temporary, story-scoped overrides for https://github.com/WordPress/gutenberg/issues/80405.
2+
// Keep the popup class on the popup itself because it renders in a portal.
3+
.comparison {
4+
max-inline-size: 760px;
5+
color: var(--wpds-color-foreground-content-neutral);
6+
}
7+
8+
.columns {
9+
display: grid;
10+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
11+
gap: var(--wpds-dimension-gap-xl);
12+
min-block-size: 400px;
13+
}
14+
15+
.search {
16+
padding: var(--wpds-dimension-padding-sm);
17+
}
18+
19+
.neutral {
20+
--_comparison-background: var(--wpds-color-background-interactive-neutral-weak-active);
21+
--_comparison-foreground: var(--wpds-color-foreground-interactive-neutral-active);
22+
}
23+
24+
.brand {
25+
--_comparison-background: var(--wpds-color-background-interactive-brand-strong);
26+
--_comparison-foreground: var(--wpds-color-foreground-interactive-brand-strong);
27+
}
28+
29+
.popup {
30+
min-inline-size: 260px;
31+
32+
:is([role="option"], [role^="menuitem"]) {
33+
position: relative;
34+
}
35+
36+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not(
37+
[aria-disabled="true"]
38+
) {
39+
background-color: var(--_comparison-background);
40+
color: var(--_comparison-foreground);
41+
outline: none;
42+
box-shadow: none;
43+
}
44+
45+
// Descriptions, shortcuts, icons, and checks use the same paired foreground.
46+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not([aria-disabled="true"]) :is(span, svg) {
47+
color: inherit;
48+
}
49+
50+
&[data-keyboard="true"][data-indicator="ring"]
51+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not([aria-disabled="true"]) {
52+
background-color: transparent;
53+
color: var(--wpds-color-foreground-interactive-neutral);
54+
outline:
55+
var(--wpds-border-width-focus) solid
56+
var(--wpds-color-stroke-focus);
57+
outline-offset: var(--wpds-border-width-focus);
58+
z-index: 1;
59+
60+
.description {
61+
color: var(--wpds-color-foreground-content-neutral-weak);
62+
}
63+
}
64+
65+
&[data-keyboard="true"][data-indicator="marker"]
66+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not([aria-disabled="true"])::before {
67+
content: "";
68+
position: absolute;
69+
inset-inline-start: 0;
70+
inset-block: var(--wpds-dimension-padding-xs);
71+
inline-size: var(--wpds-border-width-focus);
72+
border-radius: var(--wpds-border-radius-sm);
73+
background-color: var(--_comparison-foreground);
74+
}
75+
76+
@media (forced-colors: active) {
77+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not([aria-disabled="true"]) {
78+
forced-color-adjust: none;
79+
background-color: Highlight;
80+
color: HighlightText;
81+
}
82+
83+
&[data-keyboard="true"][data-indicator="ring"]
84+
:is([role="option"], [role^="menuitem"])[data-highlighted]:not([aria-disabled="true"]) {
85+
background-color: Canvas;
86+
color: CanvasText;
87+
outline-color: Highlight;
88+
89+
.description {
90+
color: CanvasText;
91+
}
92+
}
93+
94+
&[data-keyboard="true"][data-indicator="marker"]
95+
:is([role="option"], [role^="menuitem"])[data-highlighted]::before {
96+
background-color: HighlightText;
97+
}
98+
}
99+
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useEffect, useState } from '@wordpress/element';
3+
import { Autocomplete, Button, Combobox, Menu, Select } from '@wordpress/ui';
4+
import styles from './composite-focus.module.scss';
5+
6+
type ComparisonArgs = {
7+
component: 'menu' | 'select' | 'combobox' | 'autocomplete';
8+
virtualIndicator: 'ring' | 'marker';
9+
};
10+
11+
const ITEMS = [
12+
{
13+
value: 'editor',
14+
label: 'Editor tasks',
15+
description: 'Plan the next release.',
16+
},
17+
{ value: 'polish', label: 'Polish', description: 'Refine the details.' },
18+
{
19+
value: 'roadmap',
20+
label: 'Roadmap',
21+
description: 'Review upcoming work.',
22+
},
23+
];
24+
25+
function Comparison( { component, virtualIndicator }: ComparisonArgs ) {
26+
const [ keyboard, setKeyboard ] = useState( false );
27+
28+
// Story-only modality tracking includes portaled popups. Virtual focus
29+
// cannot use :focus-visible because DOM focus stays on the input.
30+
useEffect( () => {
31+
const onKeyDown = ( event: KeyboardEvent ) => {
32+
if (
33+
! [ 'Shift', 'Control', 'Alt', 'Meta' ].includes( event.key )
34+
) {
35+
setKeyboard( true );
36+
}
37+
};
38+
const onPointer = () => setKeyboard( false );
39+
document.addEventListener( 'keydown', onKeyDown, true );
40+
document.addEventListener( 'pointerdown', onPointer, true );
41+
document.addEventListener( 'pointermove', onPointer, true );
42+
return () => {
43+
document.removeEventListener( 'keydown', onKeyDown, true );
44+
document.removeEventListener( 'pointerdown', onPointer, true );
45+
document.removeEventListener( 'pointermove', onPointer, true );
46+
};
47+
}, [] );
48+
49+
return (
50+
<div className={ styles.comparison }>
51+
<p>
52+
Design exploration for #80405. Hover to compare backgrounds. Use
53+
Tab and arrow keys to compare keyboard indicators. Rings use no
54+
background highlight. Change the Storybook theme to check both
55+
treatments with other colors.
56+
</p>
57+
{ ( component === 'combobox' || component === 'autocomplete' ) && (
58+
<p>
59+
The input keeps its focus ring. Use the virtualIndicator
60+
control to compare a second ring with a side marker on the
61+
active option. The marker keeps the background highlight.
62+
Checkmarks indicate selected values.
63+
</p>
64+
) }
65+
<div className={ styles.columns }>
66+
{ ( [ 'neutral', 'brand' ] as const ).map( ( tone ) => {
67+
const popupProps = {
68+
className: `${ styles.popup } ${ styles[ tone ] }`,
69+
'data-keyboard': keyboard,
70+
'data-indicator':
71+
component === 'menu' || component === 'select'
72+
? 'ring'
73+
: virtualIndicator,
74+
};
75+
return (
76+
<section key={ tone }>
77+
<h2>
78+
{ tone === 'neutral' ? 'Neutral' : 'Brand' }
79+
</h2>
80+
{ component === 'menu' && (
81+
<MenuExample
82+
tone={ tone }
83+
popupProps={ popupProps }
84+
/>
85+
) }
86+
{ component === 'select' && (
87+
<Select.Root
88+
items={ ITEMS }
89+
defaultValue={ ITEMS[ 0 ] }
90+
>
91+
<Select.Trigger
92+
aria-label={ `${ tone } project` }
93+
/>
94+
<Select.Popup { ...popupProps }>
95+
{ ITEMS.map( ( item ) => (
96+
<Select.Item
97+
key={ item.value }
98+
value={ item }
99+
>
100+
<Select.ItemLabel>
101+
{ item.label }
102+
</Select.ItemLabel>
103+
<Select.ItemDescription
104+
className={
105+
styles.description
106+
}
107+
>
108+
{ item.description }
109+
</Select.ItemDescription>
110+
</Select.Item>
111+
) ) }
112+
</Select.Popup>
113+
</Select.Root>
114+
) }
115+
{ component === 'combobox' && (
116+
<Combobox.Root
117+
items={ ITEMS }
118+
multiple
119+
defaultValue={ [ ITEMS[ 0 ] ] }
120+
>
121+
<Combobox.Trigger
122+
aria-label={ `${ tone } projects` }
123+
/>
124+
<Combobox.Popup
125+
{ ...popupProps }
126+
aria-label={ `${ tone } projects` }
127+
>
128+
<div className={ styles.search }>
129+
<Combobox.Input
130+
aria-label={ `${ tone } filter projects` }
131+
placeholder="Filter projects"
132+
/>
133+
</div>
134+
<Combobox.Empty>
135+
No matching projects.
136+
</Combobox.Empty>
137+
<Combobox.List>
138+
<Combobox.ListBody>
139+
<Combobox.Collection>
140+
{ (
141+
item: ( typeof ITEMS )[ number ]
142+
) => (
143+
<Combobox.Item
144+
key={ item.value }
145+
value={ item }
146+
>
147+
<Combobox.ItemLabel>
148+
{ item.label }
149+
</Combobox.ItemLabel>
150+
<Combobox.ItemDescription
151+
className={
152+
styles.description
153+
}
154+
>
155+
{
156+
item.description
157+
}
158+
</Combobox.ItemDescription>
159+
</Combobox.Item>
160+
) }
161+
</Combobox.Collection>
162+
</Combobox.ListBody>
163+
</Combobox.List>
164+
</Combobox.Popup>
165+
</Combobox.Root>
166+
) }
167+
{ component === 'autocomplete' && (
168+
<Autocomplete.Root
169+
items={ ITEMS.map( ( item ) => ( {
170+
...item,
171+
value: item.label,
172+
} ) ) }
173+
>
174+
<Autocomplete.Input
175+
aria-label={ `${ tone } search projects` }
176+
placeholder="Search projects"
177+
/>
178+
<Autocomplete.Popup { ...popupProps }>
179+
<Autocomplete.Empty>
180+
No matching projects.
181+
</Autocomplete.Empty>
182+
<Autocomplete.List>
183+
<Autocomplete.ListBody>
184+
<Autocomplete.Collection>
185+
{ (
186+
item: ( typeof ITEMS )[ number ]
187+
) => (
188+
<Autocomplete.Item
189+
key={ item.value }
190+
value={ item }
191+
>
192+
<Autocomplete.ItemLabel>
193+
{ item.label }
194+
</Autocomplete.ItemLabel>
195+
<Autocomplete.ItemDescription
196+
className={
197+
styles.description
198+
}
199+
>
200+
{
201+
item.description
202+
}
203+
</Autocomplete.ItemDescription>
204+
</Autocomplete.Item>
205+
) }
206+
</Autocomplete.Collection>
207+
</Autocomplete.ListBody>
208+
</Autocomplete.List>
209+
</Autocomplete.Popup>
210+
</Autocomplete.Root>
211+
) }
212+
</section>
213+
);
214+
} ) }
215+
</div>
216+
</div>
217+
);
218+
}
219+
220+
function MenuExample( {
221+
tone,
222+
popupProps,
223+
}: {
224+
tone: string;
225+
popupProps: {
226+
className: string;
227+
'data-keyboard': boolean;
228+
'data-indicator': string;
229+
};
230+
} ) {
231+
const [ checked, setChecked ] = useState( true );
232+
return (
233+
<Menu.Root>
234+
<Menu.Trigger render={ <Button variant="outline" /> }>
235+
{ tone } menu
236+
</Menu.Trigger>
237+
<Menu.Popup { ...popupProps }>
238+
<Menu.CheckboxItem
239+
checked={ checked }
240+
onCheckedChange={ setChecked }
241+
closeOnClick={ false }
242+
>
243+
<Menu.ItemLabel>Show completed tasks</Menu.ItemLabel>
244+
<Menu.ItemDescription className={ styles.description }>
245+
Keep finished work in the list.
246+
</Menu.ItemDescription>
247+
</Menu.CheckboxItem>
248+
{ ITEMS.map( ( item ) => (
249+
<Menu.Item key={ item.value }>
250+
<Menu.ItemLabel>{ item.label }</Menu.ItemLabel>
251+
<Menu.ItemDescription className={ styles.description }>
252+
{ item.description }
253+
</Menu.ItemDescription>
254+
</Menu.Item>
255+
) ) }
256+
<Menu.Item disabled>
257+
<Menu.ItemLabel>Unavailable project</Menu.ItemLabel>
258+
</Menu.Item>
259+
</Menu.Popup>
260+
</Menu.Root>
261+
);
262+
}
263+
264+
const meta = {
265+
title: 'Design System/Experiments/Composite focus',
266+
component: Comparison,
267+
args: { component: 'menu', virtualIndicator: 'marker' },
268+
argTypes: {
269+
component: { control: false, table: { disable: true } },
270+
virtualIndicator: { control: 'radio', options: [ 'marker', 'ring' ] },
271+
},
272+
parameters: {
273+
layout: 'padded',
274+
docs: {
275+
description: {
276+
component:
277+
'Temporary focus-style comparison for #80405. These overrides are scoped to the examples and do not change component defaults.',
278+
},
279+
},
280+
},
281+
} satisfies Meta< typeof Comparison >;
282+
283+
export default meta;
284+
type Story = StoryObj< typeof meta >;
285+
286+
export const MenuComparison: Story = {
287+
argTypes: { virtualIndicator: { table: { disable: true } } },
288+
};
289+
export const SelectComparison: Story = {
290+
args: { component: 'select' },
291+
argTypes: { virtualIndicator: { table: { disable: true } } },
292+
};
293+
export const ComboboxComparison: Story = { args: { component: 'combobox' } };
294+
export const AutocompleteComparison: Story = {
295+
args: { component: 'autocomplete' },
296+
};

0 commit comments

Comments
 (0)