|
| 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