From f43c4e04cc7e4c2c144a51e9d3aa07c782e1c0d0 Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Tue, 13 Aug 2024 13:51:59 -0600 Subject: [PATCH 01/22] wip --- .../button/stories/button/Button.stories.mdx | 8 +- modules/react/collection/lib/useListLoader.ts | 4 + .../combobox/lib/hooks/useComboboxLoader.ts | 1 + .../combobox/stories/Combobox.stories.mdx | 1 + .../react/combobox/stories/examples/App.tsx | 40 +++++++++ .../stories/examples/Autocomplete.tsx | 2 + .../examples/MultiSelect/MultiSelect.tsx | 24 +++++ .../examples/MultiSelect/MultiSelectInput.tsx | 90 +++++++++++++++++++ .../stories/examples/MultiSelect/index.ts | 3 + .../MultiSelect/useMultiSelectModel.ts | 32 +++++++ 10 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 modules/react/combobox/stories/examples/App.tsx create mode 100644 modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx create mode 100644 modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx create mode 100644 modules/react/combobox/stories/examples/MultiSelect/index.ts create mode 100644 modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts diff --git a/modules/react/button/stories/button/Button.stories.mdx b/modules/react/button/stories/button/Button.stories.mdx index ebca766c18..b4267e7070 100644 --- a/modules/react/button/stories/button/Button.stories.mdx +++ b/modules/react/button/stories/button/Button.stories.mdx @@ -1,3 +1,8 @@ +--- +title: Button +path: +--- + import {Specifications, SymbolDoc, SymbolDescription} from '@workday/canvas-kit-docs'; import {Primary} from './examples/Primary'; @@ -74,7 +79,8 @@ on a dark or colorful background such as `blueberry400`. ### Grow Prop -The example below shows the use of the `grow` prop on different variants of buttons. This will set the width of the button to the width of its container. +The example below shows the use of the `grow` prop on different variants of buttons. This will set +the width of the button to the width of its container. diff --git a/modules/react/collection/lib/useListLoader.ts b/modules/react/collection/lib/useListLoader.ts index ad4e4cf726..ce4f7f0b12 100644 --- a/modules/react/collection/lib/useListLoader.ts +++ b/modules/react/collection/lib/useListLoader.ts @@ -252,6 +252,7 @@ export function useListLoader< if (config.shouldLoad && !config.shouldLoad(params, prevState)) { return false; } + console.log('shouldLoadIndex'); load(params, loadRef.current, loadingRef.current) .then(updateItems) .then(() => { @@ -296,6 +297,7 @@ export function useListLoader< if (config.shouldLoad && !config.shouldLoad(params, state)) { return; } + console.log('updateFilter', params); load(params, loadRef.current, loadingRef.current).then(updateItems); }; @@ -311,6 +313,7 @@ export function useListLoader< if (config.shouldLoad && !config.shouldLoad(params, state)) { return; } + console.log('updateSorter', params); load(params, loadRef.current, loadingRef.current).then(updateItems); }; @@ -337,6 +340,7 @@ export function useListLoader< if (shouldLoad && !shouldLoad(params, stateRef.current)) { return; } + console.log('pagesToLoad'); load(params, loadRef.current, loadingRef.current).then(updateItems); }); diff --git a/modules/react/combobox/lib/hooks/useComboboxLoader.ts b/modules/react/combobox/lib/hooks/useComboboxLoader.ts index 5846832670..3e9910db1b 100644 --- a/modules/react/combobox/lib/hooks/useComboboxLoader.ts +++ b/modules/react/combobox/lib/hooks/useComboboxLoader.ts @@ -61,6 +61,7 @@ export const useComboboxLoader: typeof useListLoader = (config, modelHook) => { ...(modelHook.mergeConfig(config, { onChange(event) { const value = event.currentTarget.value; + console.log('onChange', value); debounce(() => list.loader.updateFilter(value), 150); }, } as typeof useComboboxModel.TConfig) as typeof config), diff --git a/modules/react/combobox/stories/Combobox.stories.mdx b/modules/react/combobox/stories/Combobox.stories.mdx index 810e7ce89e..a6146e6fd8 100644 --- a/modules/react/combobox/stories/Combobox.stories.mdx +++ b/modules/react/combobox/stories/Combobox.stories.mdx @@ -2,6 +2,7 @@ import {SymbolDoc, Specifications} from '@workday/canvas-kit-docs'; import {Combobox} from '@workday/canvas-kit-react/combobox'; import {Autocomplete} from './examples/Autocomplete'; +import {App} from './examples/App'; diff --git a/modules/react/combobox/stories/examples/App.tsx b/modules/react/combobox/stories/examples/App.tsx new file mode 100644 index 0000000000..5ba4097e0d --- /dev/null +++ b/modules/react/combobox/stories/examples/App.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import {CanvasProvider} from '@workday/canvas-kit-react/common'; +import {createStyles} from '@workday/canvas-kit-styling'; +import {FormField} from '@workday/canvas-kit-preview-react/form-field'; + +import {system} from '@workday/canvas-tokens-web'; + +import {MultiSelect} from './MultiSelect'; + +const mainContentStyles = createStyles({ + padding: system.space.x4, +}); + +const items = ['Cheese', 'Pepperoni', 'Olives']; + +export const App = () => { + return ( + + <> +
+

Welcome to Canvas Kit v11 Starter!!

+ + + Toppings + + + + + {item => {item}} + + + + + +
+ +
+ ); +}; diff --git a/modules/react/combobox/stories/examples/Autocomplete.tsx b/modules/react/combobox/stories/examples/Autocomplete.tsx index 165a9ef0b9..b79eadbbea 100644 --- a/modules/react/combobox/stories/examples/Autocomplete.tsx +++ b/modules/react/combobox/stories/examples/Autocomplete.tsx @@ -72,6 +72,8 @@ export const Autocomplete = () => { // A load function that will be called by the loader. You must return a promise that returns // an object like `{items: [], total: 0}`. The `items` will be merged into the loader's cache async load({pageNumber, pageSize, filter}) { + console.log(new Error().stack); + console.log(`load: pageNumber: ${pageNumber}, pageSize: ${pageSize}, filter: ${filter}`); return new Promise>(resolve => { // simulate a server response by resolving after a period of time setTimeout(() => { diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx new file mode 100644 index 0000000000..3de2673c72 --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import {createContainer} from '@workday/canvas-kit-react/common'; +import {handleCsProp} from '@workday/canvas-kit-styling'; +import {Combobox} from '@workday/canvas-kit-react/combobox'; + +import {useMultiSelectModel} from './useMultiSelectModel'; +import {MultiSelectInput} from './MultiSelectInput'; + +export interface MultiSelectProps {} + +export const MultiSelect = createContainer('div')({ + modelHook: useMultiSelectModel, + subComponents: { + Input: MultiSelectInput, + Popper: Combobox.Menu.Popper, + Card: Combobox.Menu.Card, + List: Combobox.Menu.List, + Item: Combobox.Menu.Item, + }, +})(({...elemProps}, Element, model) => { + console.log('model', model); + return ; +}); diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx new file mode 100644 index 0000000000..c197bfd949 --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import {caretDownSmallIcon} from '@workday/canvas-system-icons-web'; +import { + createElemPropsHook, + createSubcomponent, + errorRing, + useLocalRef, +} from '@workday/canvas-kit-react/common'; +import {createStencil, CSProps, handleCsProp} from '@workday/canvas-kit-styling'; +import {InputGroup} from '@workday/canvas-kit-react/text-input'; +import {SystemIcon} from '@workday/canvas-kit-react/icon'; +import {system} from '@workday/canvas-tokens-web'; + +import {useMultiSelectModel} from './useMultiSelectModel'; + +export const multiSelectStencil = createStencil({ + base: { + border: `1px solid ${system.color.border.input.default}`, + display: 'block', + backgroundColor: system.color.bg.default, + borderRadius: system.shape.x1, + boxSizing: 'border-box', + minHeight: 40, + transition: '0.2s box-shadow, 0.2s border-color', + padding: system.space.x2, // Compensate for border + margin: 0, // Fix Safari + // '&::placeholder': { + // color: system.color.fg.strong, + // }, + '&:hover, &.hover': { + borderColor: system.color.border.input.strong, + }, + // '&:focus-visible:not([disabled]), &.focus:not([disabled]), &:focus:not([disabled])': { + + // }, + // '&:disabled': { + // backgroundColor: inputColors.disabled.background, + // borderColor: inputColors.disabled.border, + // color: inputColors.disabled.text, + // '&::placeholder': { + // color: inputColors.disabled.text, + // }, + // }, + '&:focus-within': { + borderColor: system.color.border.primary.default, + boxShadow: `inset 0 0 0 1px ${system.color.border.primary.default}`, + }, + '& [data-slot="input"]': { + ...system.type.subtext.large, + border: 'none', + outlineWidth: '0px', + }, + }, +}); + +export const useMultiSelectInput = createElemPropsHook(useMultiSelectModel)((model, ref) => { + const {elementRef} = useLocalRef(ref as any); + + return { + role: 'combobox', + ref: elementRef, + 'aria-haspopup': 'menu', + onClick(e: React.MouseEvent) { + console.log('click', model.state.visibility); + if (model.state.visibility === 'hidden') { + model.events.show(e); + } else if (model.state.visibility === 'visible') { + model.events.hide(e); + } + }, + }; +}); + +export interface MultiSelectInputProps extends CSProps {} + +export const MultiSelectInput = createSubcomponent('input')({ + displayName: 'MultiSelect.Input', + modelHook: useMultiSelectModel, + elemPropsHook: useMultiSelectInput, +})(({className, cs, ...elemProps}, Element) => { + return ( + + + + + + + ); +}); diff --git a/modules/react/combobox/stories/examples/MultiSelect/index.ts b/modules/react/combobox/stories/examples/MultiSelect/index.ts new file mode 100644 index 0000000000..a245d2c6cb --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/index.ts @@ -0,0 +1,3 @@ +export {useMultiSelectInput} from './MultiSelectInput'; + +export {MultiSelect} from './MultiSelect'; diff --git a/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts b/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts new file mode 100644 index 0000000000..6778ec8f6a --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts @@ -0,0 +1,32 @@ +import React from 'react'; + +import {createModelHook} from '@workday/canvas-kit-react/common'; +import {useComboboxModel} from '@workday/canvas-kit-react/combobox'; + +/** + * `SelectModel` extends the {@link ComboboxModel}. Selecting items from + * the menu will dispatch an + * [input](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) event on the + * input which should work with form libraries, automation and autofill. + * + * ```tsx + * const model = useMultiSelectModel({items: ['Mobile', 'Phone', 'E-Mail']}) + * + * + * ``` + */ +export const useMultiSelectModel = createModelHook({ + defaultConfig: { + ...useComboboxModel.defaultConfig, + mode: 'multiple', + shouldVirtualize: false, + }, + requiredConfig: { + ...useComboboxModel.requiredConfig, + }, + contextOverride: useComboboxModel.Context, +})(config => { + return useComboboxModel(config); +}); From dd603a11c44d642b7407084ac7c11f89d7ea8943 Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Mon, 9 Sep 2024 16:34:00 -0600 Subject: [PATCH 02/22] wip --- .../react/combobox/lib/ComboboxMenuItem.tsx | 3 +- .../combobox/stories/Combobox.stories.mdx | 2 + .../react/combobox/stories/examples/App.tsx | 11 +- .../examples/MultiSelect/MultiSelect.tsx | 18 ++- .../examples/MultiSelect/MultiSelectInput.tsx | 50 +++++---- .../examples/MultiSelect/MultiSelectItem.tsx | 40 +++++++ .../visual-testing/stories_Checkbox.tsx | 39 ++++--- modules/react/menu/lib/MenuItem.tsx | 106 ++++++++++++++++-- modules/react/menu/stories/examples/Icons.tsx | 4 +- .../testing/lib/ComponentStatesTable.tsx | 4 + modules/styling/lib/cs.ts | 5 - 11 files changed, 227 insertions(+), 55 deletions(-) create mode 100644 modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx diff --git a/modules/react/combobox/lib/ComboboxMenuItem.tsx b/modules/react/combobox/lib/ComboboxMenuItem.tsx index 047ec8b7bb..884dae321d 100644 --- a/modules/react/combobox/lib/ComboboxMenuItem.tsx +++ b/modules/react/combobox/lib/ComboboxMenuItem.tsx @@ -11,6 +11,7 @@ import {SystemIcon} from '@workday/canvas-kit-react/icon'; import { useListItemAllowChildStrings, useListItemRegister, + isSelected, } from '@workday/canvas-kit-react/collection'; import {OverflowTooltip} from '@workday/canvas-kit-react/tooltip'; @@ -45,7 +46,7 @@ export const useComboboxMenuItem = composeHooks( event.preventDefault(); }; - const selected = model.state.cursorId === id; + const selected = isSelected(id, model.state); return { role: 'option', diff --git a/modules/react/combobox/stories/Combobox.stories.mdx b/modules/react/combobox/stories/Combobox.stories.mdx index a6146e6fd8..6702bda075 100644 --- a/modules/react/combobox/stories/Combobox.stories.mdx +++ b/modules/react/combobox/stories/Combobox.stories.mdx @@ -40,6 +40,8 @@ communicating with a server. + + ### Custom Styles Combobox and its subcomponents support custom styling via the `cs` prop. For more information, check diff --git a/modules/react/combobox/stories/examples/App.tsx b/modules/react/combobox/stories/examples/App.tsx index 5ba4097e0d..c62fe60289 100644 --- a/modules/react/combobox/stories/examples/App.tsx +++ b/modules/react/combobox/stories/examples/App.tsx @@ -4,9 +4,12 @@ import {CanvasProvider} from '@workday/canvas-kit-react/common'; import {createStyles} from '@workday/canvas-kit-styling'; import {FormField} from '@workday/canvas-kit-preview-react/form-field'; +import {accessibilityIcon, accountsIcon} from '@workday/canvas-system-icons-web'; + import {system} from '@workday/canvas-tokens-web'; import {MultiSelect} from './MultiSelect'; +import {Menu} from '../../../menu'; const mainContentStyles = createStyles({ padding: system.space.x4, @@ -27,7 +30,13 @@ export const App = () => { - {item => {item}} + {item => ( + + + {item} + + + )} diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx index 3de2673c72..b737f5a7f0 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx @@ -1,24 +1,30 @@ import React from 'react'; -import {createContainer} from '@workday/canvas-kit-react/common'; +import {createContainer, createSubcomponent} from '@workday/canvas-kit-react/common'; import {handleCsProp} from '@workday/canvas-kit-styling'; import {Combobox} from '@workday/canvas-kit-react/combobox'; +import {Menu} from '@workday/canvas-kit-react/menu'; import {useMultiSelectModel} from './useMultiSelectModel'; import {MultiSelectInput} from './MultiSelectInput'; +import {MultiSelectItem} from './MultiSelectItem'; export interface MultiSelectProps {} -export const MultiSelect = createContainer('div')({ +export const MultiSelect = createContainer()({ modelHook: useMultiSelectModel, subComponents: { Input: MultiSelectInput, Popper: Combobox.Menu.Popper, Card: Combobox.Menu.Card, List: Combobox.Menu.List, - Item: Combobox.Menu.Item, + Item: MultiSelectItem, }, -})(({...elemProps}, Element, model) => { - console.log('model', model); - return ; +})(({children, ...elemProps}, _, model) => { + console.log('model', model.state.selectedIds); + return ( + + {children} + + ); }); diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx index c197bfd949..aad84ad752 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx @@ -1,16 +1,20 @@ import React from 'react'; +import {system} from '@workday/canvas-tokens-web'; import {caretDownSmallIcon} from '@workday/canvas-system-icons-web'; + import { + composeHooks, createElemPropsHook, createSubcomponent, - errorRing, useLocalRef, } from '@workday/canvas-kit-react/common'; import {createStencil, CSProps, handleCsProp} from '@workday/canvas-kit-styling'; import {InputGroup} from '@workday/canvas-kit-react/text-input'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; -import {system} from '@workday/canvas-tokens-web'; +import {usePopupTarget} from '@workday/canvas-kit-react/popup'; +import {useListActiveDescendant} from '@workday/canvas-kit-react/collection'; +import {useComboboxListKeyboardHandler, useSetPopupWidth} from '@workday/canvas-kit-react/combobox'; import {useMultiSelectModel} from './useMultiSelectModel'; @@ -23,7 +27,6 @@ export const multiSelectStencil = createStencil({ boxSizing: 'border-box', minHeight: 40, transition: '0.2s box-shadow, 0.2s border-color', - padding: system.space.x2, // Compensate for border margin: 0, // Fix Safari // '&::placeholder': { // color: system.color.fg.strong, @@ -48,29 +51,38 @@ export const multiSelectStencil = createStencil({ }, '& [data-slot="input"]': { ...system.type.subtext.large, + backgroundColor: system.color.bg.transparent, border: 'none', outlineWidth: '0px', + padding: system.space.x2, // Compensate for border + borderRadius: system.shape.x1, }, }, }); -export const useMultiSelectInput = createElemPropsHook(useMultiSelectModel)((model, ref) => { - const {elementRef} = useLocalRef(ref as any); +export const useMultiSelectInput = composeHooks( + createElemPropsHook(useMultiSelectModel)((model, ref) => { + const {elementRef} = useLocalRef(ref as any); - return { - role: 'combobox', - ref: elementRef, - 'aria-haspopup': 'menu', - onClick(e: React.MouseEvent) { - console.log('click', model.state.visibility); - if (model.state.visibility === 'hidden') { - model.events.show(e); - } else if (model.state.visibility === 'visible') { - model.events.hide(e); - } - }, - }; -}); + return { + role: 'combobox', + ref: elementRef, + 'aria-haspopup': 'menu' as const, + // onClick(e: React.MouseEvent) { + // console.log('click', model.state.visibility); + // if (model.state.visibility === 'hidden') { + // model.events.show(e); + // } else if (model.state.visibility === 'visible') { + // model.events.hide(e); + // } + // }, + }; + }), + useSetPopupWidth, + useListActiveDescendant, + useComboboxListKeyboardHandler, + usePopupTarget +); export interface MultiSelectInputProps extends CSProps {} diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx new file mode 100644 index 0000000000..52c6fbac45 --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import {checkIcon} from '@workday/canvas-system-icons-web'; +import {handleCsProp} from '@workday/canvas-kit-styling'; + +import { + composeHooks, + createElemPropsHook, + createSubcomponent, + ExtractProps, +} from '@workday/canvas-kit-react/common'; +import {Combobox, useComboboxMenuItem} from '@workday/canvas-kit-react/combobox'; +import {SystemIcon} from '@workday/canvas-kit-react/icon'; +import {StyledMenuItem} from '@workday/canvas-kit-react/menu'; + +import {useMultiSelectModel} from './useMultiSelectModel'; + +export const useMultiSelectItem = composeHooks( + createElemPropsHook(useMultiSelectModel)(({state}) => { + return { + role: 'option', + }; + }), + useComboboxMenuItem +); + +export const MultiSelectItem = createSubcomponent('li')({ + modelHook: useMultiSelectModel, + elemPropsHook: useMultiSelectItem, + subComponents: { + Icon: Combobox.Menu.Item.Icon, + }, +})>(({children, ...elemProps}, Element, _model) => { + console.log('aria-selected', elemProps); + return ( + + {children} + + ); +}); diff --git a/modules/react/form-field/stories/visual-testing/stories_Checkbox.tsx b/modules/react/form-field/stories/visual-testing/stories_Checkbox.tsx index 569e147df3..283214ad5b 100644 --- a/modules/react/form-field/stories/visual-testing/stories_Checkbox.tsx +++ b/modules/react/form-field/stories/visual-testing/stories_Checkbox.tsx @@ -19,13 +19,18 @@ export const CheckboxStates = () => ( ( {...props} onChange={() => {}} // eslint-disable-line no-empty-function label="Checkbox" + as="span" /> )} @@ -79,13 +85,18 @@ export const InverseCheckboxStates = () => ( ( +export const menuItemStencil = createStencil({ + base: { + ...system.type.subtext.large, + display: 'flex', + alignItems: 'center', + width: '100%', + gap: space.s, + padding: `${space.xxs} ${space.s}`, + boxSizing: 'border-box', + cursor: 'pointer', + color: system.color.text.default, + borderWidth: 0, + textAlign: 'left', + transition: 'background-color 80ms, color 80ms', + backgroundColor: 'inherit', + minHeight: system.space.x10, + + // selected checkmark + '& [data-part="menu-item-selected"]': { + transition: 'opacity 80ms linear', + opacity: system.opacity.zero, + }, + '&[aria-selected=true] :where([data-part="menu-item-selected"])': { + opacity: system.opacity.full, + }, + + // if the menu item has children we need it to be displayed in flex + '&:has(span)': { + display: 'flex', + }, + + // Hover styles + '&:is(.hover, :hover, [aria-selected=true])': { + [systemIconStencil.vars.color]: system.color.icon.strong, + backgroundColor: brand.primary.lightest, + }, + + // Focus styles + '&:is(.focus, :focus)': { + [systemIconStencil.vars.color]: brand.primary.accent, + outline: 'none', + backgroundColor: brand.primary.base, + color: systemIconStencil.vars.color, + }, + + // Disabled styles + '&:is(:disabled, [aria-disabled=true])': { + [systemIconStencil.vars.color]: 'color', + color: system.color.text.disabled, + cursor: 'default', + + // Focus + Disabled + '&:where(.focus, :focus)': { + backgroundColor: brand.primary.light, + }, + }, + + '& :where([data-part="menu-item-text"])': { + flexGrow: 1, + alignSelf: 'center', + }, + }, +}); + +const MenuItemIcon = styled(SystemIcon)({alignSelf: 'start'}); + +const MenuItemText = ({children}: React.PropsWithChildren) => { + return ( + <> + {children} + + + ); +}; + +export const StyledMenuItem = createComponent('button')({ + displayName: 'MenuItem', + Component: ({children, ...elemProps}, ref, Element) => { + return ( + + {typeof children === 'string' ? {children} : children} + + ); + }, +}); + +export const StyledMenuItemOld = styled(Box.as('button'))( ({theme}) => { return { ...type.levels.subtext.large, @@ -181,15 +272,16 @@ export const MenuItem = createSubcomponent('button')({ modelHook: useMenuModel, elemPropsHook: useMenuItem, subComponents: { - Icon: styled(SystemIcon)({alignSelf: 'start'}), - Text: styled('span')({flexGrow: 1, alignSelf: 'center'}), + Icon: MenuItemIcon, + Text: MenuItemText, }, })(({children, ...elemProps}, Element) => { return ( - + {/* {children} - + */} + {children} ); }); diff --git a/modules/react/menu/stories/examples/Icons.tsx b/modules/react/menu/stories/examples/Icons.tsx index 54682464d5..ce00913573 100644 --- a/modules/react/menu/stories/examples/Icons.tsx +++ b/modules/react/menu/stories/examples/Icons.tsx @@ -16,7 +16,7 @@ export const Icons = () => { - + First Item @@ -24,7 +24,7 @@ export const Icons = () => { Second Item (with a really really really long label) - + Third Item diff --git a/modules/react/testing/lib/ComponentStatesTable.tsx b/modules/react/testing/lib/ComponentStatesTable.tsx index f269969554..5a8d1e8643 100644 --- a/modules/react/testing/lib/ComponentStatesTable.tsx +++ b/modules/react/testing/lib/ComponentStatesTable.tsx @@ -67,6 +67,10 @@ export const ComponentStatesTable = ({ {children({ ...row.props, ...col.props, + // join class names between rows and columns + className: [row.props.className, col.props.className] + .filter(c => c) + .join(' '), })} ); diff --git a/modules/styling/lib/cs.ts b/modules/styling/lib/cs.ts index 376deba31a..f722976fb4 100644 --- a/modules/styling/lib/cs.ts +++ b/modules/styling/lib/cs.ts @@ -653,11 +653,6 @@ export function createStyles( // https://codesandbox.io/s/stupefied-bartik-9c2jtd?file=/src/App.tsx const {styles} = serializeStyles([convertedStyles as CastStyleProps]); - // use `css.call()` instead of `css()` to trick Emotion's babel plugin to not rewrite our code - // to remove our generated Id for the name: - // https://github.com/emotion-js/emotion/blob/f3b268f7c52103979402da919c9c0dd3f9e0e189/packages/babel-plugin/src/utils/transform-expression-with-styles.js#L81-L82 - // Without this "fix", anyone using the Emotion babel plugin would get different results than - // intended when styles are merged. const name = generateUniqueId(); createStylesCache[`${instance.cache.key}-${name}`] = true; return instance.css({name, styles}); From f134171b82753a08bb15302e5b4401e661744cb2 Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Mon, 9 Sep 2024 17:00:52 -0600 Subject: [PATCH 03/22] Update stories for SB upgrade --- modules/react/combobox/stories/Combobox.stories.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/modules/react/combobox/stories/Combobox.stories.ts b/modules/react/combobox/stories/Combobox.stories.ts index 54f71cd978..7c12ebd8d4 100644 --- a/modules/react/combobox/stories/Combobox.stories.ts +++ b/modules/react/combobox/stories/Combobox.stories.ts @@ -4,6 +4,7 @@ import mdxDoc from './Combobox.mdx'; import {Combobox} from '@workday/canvas-kit-react/combobox'; import {Autocomplete as AutocompleteExample} from './examples/Autocomplete'; +import {App as AppExample} from './examples/App'; export default { title: 'Features/Combobox', @@ -21,3 +22,7 @@ type Story = StoryObj; export const Autocomplete: Story = { render: AutocompleteExample, }; + +export const App: Story = { + render: AppExample, +}; From b39916e445ed18312f5745d78769dd7758e1fbdf Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Wed, 25 Sep 2024 15:51:29 -0600 Subject: [PATCH 04/22] Add non-searchable multiselect --- modules/react/collection/lib/ListBox.tsx | 23 +- .../collection/lib/useListItemRemoveable.ts | 97 +++++++++ .../combobox/lib/hooks/useComboboxModel.tsx | 14 +- .../react/combobox/stories/examples/App.tsx | 74 +++++-- .../examples/MultiSelect/MultiSelect.tsx | 1 - .../examples/MultiSelect/MultiSelectInput.tsx | 206 ++++++++++++++++-- .../examples/MultiSelect/MultiSelectItem.tsx | 20 +- .../MultiSelect/useMultiSelectModel.ts | 44 +++- modules/react/icon/lib/SystemIcon.tsx | 10 +- .../react/layout/lib/utils/buildStyleFns.ts | 26 ++- modules/react/menu/lib/MenuItem.tsx | 74 +++++-- .../lib/hooks/useAssistiveHideSiblings.ts | 2 +- modules/react/text-input/lib/InputGroup.tsx | 7 +- modules/styling/lib/cs.ts | 12 +- 14 files changed, 517 insertions(+), 93 deletions(-) create mode 100644 modules/react/collection/lib/useListItemRemoveable.ts diff --git a/modules/react/collection/lib/ListBox.tsx b/modules/react/collection/lib/ListBox.tsx index 7c3cac06fd..70d1941ad7 100644 --- a/modules/react/collection/lib/ListBox.tsx +++ b/modules/react/collection/lib/ListBox.tsx @@ -52,7 +52,14 @@ export const useListBox = createElemPropsHook(useListModel)(model => { }); const listBoxContainerStencil = createStencil({ - base: {}, + base: { + '& :where([data-part="list"])': { + display: 'flex', + flexDirection: 'column', + marginTop: system.space.zero, + marginBottom: system.space.zero, + }, + }, modifiers: { orientation: { vertical: { @@ -60,20 +67,14 @@ const listBoxContainerStencil = createStencil({ }, horizontal: { overflowY: undefined, + '& :where([data-part="list"])': { + flexDirection: 'row', + }, }, }, }, }); -const listBoxStencil = createStencil({ - base: { - display: 'flex', - flexDirection: 'column', - marginTop: system.space.zero, - marginBottom: system.space.zero, - }, -}); - /** * The `ListBox` component that offers vertical rendering of a collection in the form of a * 2-dimension list. It supports virtualization, rendering only visible items in the DOM while also @@ -129,7 +130,7 @@ export const ListBox = createContainer('ul')({ listBoxContainerStencil({orientation: model.state.orientation}) )} > - + {useListRenderItems(model, elemProps.children)} diff --git a/modules/react/collection/lib/useListItemRemoveable.ts b/modules/react/collection/lib/useListItemRemoveable.ts new file mode 100644 index 0000000000..5947778c8a --- /dev/null +++ b/modules/react/collection/lib/useListItemRemoveable.ts @@ -0,0 +1,97 @@ +import React from 'react'; +import {useIsRTL, createElemPropsHook} from '@workday/canvas-kit-react/common'; + +import {useCursorListModel} from './useCursorListModel'; +import {keyboardEventToCursorEvents} from './keyUtils'; + +// retry a function each frame so we don't rely on the timing mechanism of React's render cycle. +const retryEachFrame = (cb: () => boolean, iterations: number) => { + if (cb() === false && iterations > 1) { + requestAnimationFrame(() => retryEachFrame(cb, iterations - 1)); + } +}; + +/** + * This elemProps hook is used when a menu item is expected to be removed. It will advance the cursor to + * another item. + * This elemProps hook is used for cursor navigation by using [Roving + * Tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex). Only a single item in the + * collection has a tab stop. Pressing an arrow key moves the tab stop to a different item in the + * corresponding direction. See the [Roving Tabindex](#roving-tabindex) example. This elemProps hook + * should be applied to an `*.Item` component. + * + * ```ts + * const useMyItem = composeHooks( + * useListItemRovingFocus, // adds the roving tabindex support + * useListItemRegister + * ); + * ``` + */ +export const useListItemRemove = createElemPropsHook(useCursorListModel)( + (model, _ref, elemProps: {'data-id'?: string} = {}) => { + // Create a ref out of state. We don't want to watch state on unmount, so we use a ref to get the + // current value at the time of unmounting. Otherwise, `state.items` would be a cached value of an + // empty array + const stateRef = React.useRef(model.state); + stateRef.current = model.state; + + const keyElementRef = React.useRef(null); + const isRTL = useIsRTL(); + + React.useEffect(() => { + if (keyElementRef.current) { + const item = model.navigation.getItem(model.state.cursorId, model); + if (model.state.isVirtualized) { + model.state.UNSTABLE_virtual.scrollToIndex(item.index); + } + + const selector = (id?: string) => { + return document.querySelector(`[data-focus-id="${`${id}-${item.id}`}"]`); + }; + + // In React concurrent mode, there could be several render attempts before the element we're + // looking for could be available in the DOM + retryEachFrame(() => { + // Attempt to extract the ID from the DOM element. This fixes issues where the server and client + // do not agree on a generated ID + const clientId = keyElementRef.current?.getAttribute('data-focus-id')?.split('-')[0]; + const element = selector(clientId) || selector(model.state.id); + + element?.focus(); + if (element) { + keyElementRef.current = null; + } + return !!element; + }, 5); // 5 should be enough, right?! + } + // we only want to run this effect if the cursor changes and not any other time + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model.state.cursorId]); + + // Roving focus must always have a focus stop to function correctly + React.useEffect(() => { + if (!model.state.cursorId && model.state.items.length) { + model.events.goTo({id: model.state.items[0].id}); + } + }, [model.state.cursorId, model.state.items, model.events]); + + return { + onKeyDown(event: React.KeyboardEvent) { + const handled = keyboardEventToCursorEvents(event, model, isRTL); + if (handled) { + event.preventDefault(); + keyElementRef.current = event.currentTarget; + } + }, + onClick() { + model.events.goTo({id: elemProps['data-id']!}); + }, + 'data-focus-id': `${model.state.id}-${elemProps['data-id']}`, + tabIndex: !model.state.cursorId + ? 0 // cursor isn't known yet, be safe and mark this as focusable + : !!elemProps['data-id'] && model.state.cursorId === elemProps['data-id'] + ? 0 // A name is known and cursor is here + : -1, // A name is known an cursor is somewhere else + }; + } +); diff --git a/modules/react/combobox/lib/hooks/useComboboxModel.tsx b/modules/react/combobox/lib/hooks/useComboboxModel.tsx index dbd4af90f4..fe9b720013 100644 --- a/modules/react/combobox/lib/hooks/useComboboxModel.tsx +++ b/modules/react/combobox/lib/hooks/useComboboxModel.tsx @@ -56,7 +56,19 @@ export const useComboboxModel = createModelHook({ const menu = useMenuModel( useMenuModel.mergeConfig(config, { onSelect({id}) { - dispatchInputEvent(menu.state.targetRef.current, id); + dispatchInputEvent( + menu.state.targetRef.current, + // The IIFE helps organize logic + (() => { + // Get the next selection state by calling the selection + const selected = menu.selection.select(id, menu.state).selectedIds; + if (selected === 'all') { + return selected; + } else { + return selected.join(', '); + } + })() + ); }, }) ); diff --git a/modules/react/combobox/stories/examples/App.tsx b/modules/react/combobox/stories/examples/App.tsx index c62fe60289..5d1ec60bec 100644 --- a/modules/react/combobox/stories/examples/App.tsx +++ b/modules/react/combobox/stories/examples/App.tsx @@ -9,40 +9,66 @@ import {accessibilityIcon, accountsIcon} from '@workday/canvas-system-icons-web' import {system} from '@workday/canvas-tokens-web'; import {MultiSelect} from './MultiSelect'; -import {Menu} from '../../../menu'; +import {Menu} from '@workday/canvas-kit-react/menu'; +import styled from '@emotion/styled'; +import {PrimaryButton} from '@workday/canvas-kit-react/button'; const mainContentStyles = createStyles({ padding: system.space.x4, }); -const items = ['Cheese', 'Pepperoni', 'Olives']; +const items = ['Cheese', 'Olives', 'Onions', 'Pepperoni', 'Peppers']; + +const Container = styled('div')({ + '& :where([data-part="test"])': { + backgroundColor: 'blue', + }, +}); + +const Test = styled('div')(({backgroundColor}) => ({ + backgroundColor, + width: 100, + height: 100, +})); export const App = () => { + const [color, setColor] = React.useState('red'); return ( <> -
-

Welcome to Canvas Kit v11 Starter!!

- - - Toppings - - - - - {item => ( - - - {item} - - - )} - - - - - -
+
{ + console.log('form submitted'); + e.preventDefault(); + }} + > +
+ + + + setColor(event.currentTarget.value)} /> + + + Toppings + + + + + {item => ( + + + {item} + + + )} + + + + + + Submit +
+
); diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx index b737f5a7f0..0653cbab9d 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx @@ -21,7 +21,6 @@ export const MultiSelect = createContainer()({ Item: MultiSelectItem, }, })(({children, ...elemProps}, _, model) => { - console.log('model', model.state.selectedIds); return ( {children} diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx index aad84ad752..7ed09700a0 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {system} from '@workday/canvas-tokens-web'; -import {caretDownSmallIcon} from '@workday/canvas-system-icons-web'; +import {caretDownSmallIcon, searchIcon} from '@workday/canvas-system-icons-web'; import { composeHooks, @@ -10,18 +10,28 @@ import { useLocalRef, } from '@workday/canvas-kit-react/common'; import {createStencil, CSProps, handleCsProp} from '@workday/canvas-kit-styling'; -import {InputGroup} from '@workday/canvas-kit-react/text-input'; +import {InputGroup, TextInput} from '@workday/canvas-kit-react/text-input'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; import {usePopupTarget} from '@workday/canvas-kit-react/popup'; -import {useListActiveDescendant} from '@workday/canvas-kit-react/collection'; +import { + ListBox, + useListActiveDescendant, + useListItemRegister, + useListItemRovingFocus, + useListItemSelect, + useListModel, +} from '@workday/canvas-kit-react/collection'; import {useComboboxListKeyboardHandler, useSetPopupWidth} from '@workday/canvas-kit-react/combobox'; +import {Pill} from '@workday/canvas-kit-preview-react/pill'; import {useMultiSelectModel} from './useMultiSelectModel'; +import {MultiSelectItem} from './MultiSelectItem'; export const multiSelectStencil = createStencil({ base: { border: `1px solid ${system.color.border.input.default}`, - display: 'block', + display: 'flex', + flexDirection: 'column', backgroundColor: system.color.bg.default, borderRadius: system.shape.x1, boxSizing: 'border-box', @@ -49,13 +59,47 @@ export const multiSelectStencil = createStencil({ borderColor: system.color.border.primary.default, boxShadow: `inset 0 0 0 1px ${system.color.border.primary.default}`, }, - '& [data-slot="input"]': { + + // @ts-ignore + '& :where([data-part="user-input"])': { ...system.type.subtext.large, backgroundColor: system.color.bg.transparent, border: 'none', outlineWidth: '0px', padding: system.space.x2, // Compensate for border borderRadius: system.shape.x1, + + '&:not([aria-autocomplete])': { + caretColor: 'transparent', + cursor: 'default', + '&::selection': { + backgroundColor: 'transparent', + }, + }, + }, + + '& :where([data-part="form-input"])': { + position: 'absolute', + top: system.space.zero, + bottom: system.space.zero, + left: system.space.zero, + right: system.space.zero, + opacity: system.opacity.zero, + cursor: 'default', + pointerEvents: 'none', + }, + + '& :where([data-part="separator"])': { + backgroundColor: system.color.border.divider, + height: 1, + margin: `${system.space.zero} ${system.space.x2}`, + }, + + '& :where([data-part="list"])': { + display: 'flex', + gap: system.space.x2, + padding: system.space.x2, + flexWrap: 'wrap', }, }, }); @@ -64,10 +108,39 @@ export const useMultiSelectInput = composeHooks( createElemPropsHook(useMultiSelectModel)((model, ref) => { const {elementRef} = useLocalRef(ref as any); + const {elementRef: userElementRef, localRef: userLocalRef} = useLocalRef(model.state.targetRef); + const {elementRef: formElementRef, localRef: formLocalRef} = useLocalRef( + ref as React.Ref + ); + return { + onKeyDown(event: React.KeyboardEvent) { + if ( + (event.key === 'Enter' || event.key === 'Space') && + model.state.visibility === 'visible' + ) { + const id = model.state.cursorId; + if (id) { + model.events.select({id}); + // If enter key, prevents the form from being submitted while the select is open. If + // space key, prevents a space from being entered in the search input + // event.preventDefault(); + } + } + + console.log('key', event.key); + if ( + (event.key === 'ArrowDown' || event.key === 'ArrowUp') && + model.state.visibility === 'hidden' + ) { + // prevent navigating vertically on the input + model.events.show(event); + event.preventDefault(); + } + }, role: 'combobox', ref: elementRef, - 'aria-haspopup': 'menu' as const, + 'aria-haspopup': 'listbox' as const, // onClick(e: React.MouseEvent) { // console.log('click', model.state.visibility); // if (model.state.visibility === 'hidden') { @@ -84,19 +157,122 @@ export const useMultiSelectInput = composeHooks( usePopupTarget ); -export interface MultiSelectInputProps extends CSProps {} +const removeItem = (id: string, model: ReturnType) => { + const index = model.state.items.findIndex(item => item.id === model.state.cursorId); + const nextIndex = index === model.state.items.length - 1 ? index - 1 : index + 1; + const nextId = model.state.items[nextIndex].id; + console.log('nextId', id, nextId); + if (model.state.cursorId === id) { + // We're removing the currently focused item. Focus next item + model.events.goTo({id: nextId}); + + // // wait for stabilization of state + // requestAnimationFrame(() => { + // document.querySelector(`#${model.state.id}-${nextId}`)?.focus(); + // }); + } +}; + +const useMultiSelectedItem = composeHooks( + createElemPropsHook(useListModel)((model, ref, elemProps) => { + return { + onKeyDown(event: React.KeyboardEvent) { + const id = event.currentTarget.dataset.id || ''; + if (event.key === 'Backspace' || event.key === 'Delete') { + model.events.select({id}); + removeItem(id, model); + } + }, + onClick(event: React.MouseEvent) { + const id = event.currentTarget.dataset.id || ''; + model.events.select({id}); + }, + }; + }), + useListItemRovingFocus, + useListItemRegister +); + +const MultiSelectedItem = createSubcomponent('span')({ + modelHook: useListModel, + elemPropsHook: useMultiSelectedItem, +})(({children, ref, ...elemProps}, Element, model) => { + return ( + + {children} + + + ); +}); + +export interface MultiSelectInputProps + extends CSProps, + Pick, 'disabled' | 'className'> {} + +export const MultiSelectInput = createSubcomponent(TextInput)({ + displayName: 'MultiSelect.Input', + modelHook: useMultiSelectModel, + elemPropsHook: useMultiSelectInput, +})(({className, cs, disabled, ...elemProps}, Element, model) => { + return ( +
+ + + + + + + + {model.selected.state.items.length ? ( + <> +
+ + {item => {item.textValue}} + + + ) : null} +
+ ); +}); -export const MultiSelectInput = createSubcomponent('input')({ +export const MultiSelectSearchInput = createSubcomponent(TextInput)({ displayName: 'MultiSelect.Input', modelHook: useMultiSelectModel, elemPropsHook: useMultiSelectInput, -})(({className, cs, ...elemProps}, Element) => { +})(({className, cs, disabled, ...elemProps}, Element, model) => { return ( - - - - - - +
+ + + + + + + + + + + + + + {model.selected.state.items.length ? ( + <> +
+ + {item => {item.textValue}} + + + ) : null} +
); }); diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx index 52c6fbac45..8b1a7cfcc1 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectItem.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import {checkIcon} from '@workday/canvas-system-icons-web'; import {handleCsProp} from '@workday/canvas-kit-styling'; import { @@ -10,7 +9,6 @@ import { ExtractProps, } from '@workday/canvas-kit-react/common'; import {Combobox, useComboboxMenuItem} from '@workday/canvas-kit-react/combobox'; -import {SystemIcon} from '@workday/canvas-kit-react/icon'; import {StyledMenuItem} from '@workday/canvas-kit-react/menu'; import {useMultiSelectModel} from './useMultiSelectModel'; @@ -30,11 +28,13 @@ export const MultiSelectItem = createSubcomponent('li')({ subComponents: { Icon: Combobox.Menu.Item.Icon, }, -})>(({children, ...elemProps}, Element, _model) => { - console.log('aria-selected', elemProps); - return ( - - {children} - - ); -}); +})>( + ({children, type = 'selectable', ...elemProps}, Element, _model) => { + console.log('aria-selected', elemProps); + return ( + + {children} + + ); + } +); diff --git a/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts b/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts index 6778ec8f6a..6c81a31b95 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts +++ b/modules/react/combobox/stories/examples/MultiSelect/useMultiSelectModel.ts @@ -2,6 +2,7 @@ import React from 'react'; import {createModelHook} from '@workday/canvas-kit-react/common'; import {useComboboxModel} from '@workday/canvas-kit-react/combobox'; +import {useListModel, Item} from '@workday/canvas-kit-react/collection'; /** * `SelectModel` extends the {@link ComboboxModel}. Selecting items from @@ -20,7 +21,7 @@ import {useComboboxModel} from '@workday/canvas-kit-react/combobox'; export const useMultiSelectModel = createModelHook({ defaultConfig: { ...useComboboxModel.defaultConfig, - mode: 'multiple', + mode: 'multiple' as const, shouldVirtualize: false, }, requiredConfig: { @@ -28,5 +29,44 @@ export const useMultiSelectModel = createModelHook({ }, contextOverride: useComboboxModel.Context, })(config => { - return useComboboxModel(config); + const model = useComboboxModel( + useComboboxModel.mergeConfig(config, { + onHide() { + setSelectedItems(cachedSelected); + }, + }) + ); + const [selectedItems, setSelectedItems] = React.useState[]>(() => { + return (config.initialSelectedIds === 'all' ? [] : config.initialSelectedIds).map(id => + model.navigation.getItem(id, model) + ); + }); + + const cachedSelected = React.useMemo( + () => + (model.state.selectedIds === 'all' ? [] : model.state.selectedIds).map(id => + model.navigation.getItem(id, model) + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [model.state.selectedIds] + ); + + const selected = useListModel({ + orientation: 'horizontal', + onSelect({id}) { + model.events.select({id}); + }, + shouldVirtualize: false, + items: model.state.visibility === 'hidden' ? cachedSelected : selectedItems, + }); + + const state = { + ...model.state, + }; + + const events = { + ...model.events, + }; + + return {selected, ...model, state, events}; }); diff --git a/modules/react/icon/lib/SystemIcon.tsx b/modules/react/icon/lib/SystemIcon.tsx index 8d7f879d0b..6798da7e44 100644 --- a/modules/react/icon/lib/SystemIcon.tsx +++ b/modules/react/icon/lib/SystemIcon.tsx @@ -152,10 +152,9 @@ export const systemIconStencil = createStencil({ */ color: '', accentColor: '', - backgroundColor: 'transparent', + backgroundColor: '', }, base: ({accentColor, backgroundColor, color}) => ({ - [backgroundColor]: 'transparent', '& .wd-icon-fill': { fill: cssVar(color, base.licorice200), }, @@ -163,7 +162,7 @@ export const systemIconStencil = createStencil({ fill: cssVar(accentColor, cssVar(color, base.licorice200)), }, '& .wd-icon-background': { - fill: backgroundColor, + fill: cssVar(backgroundColor, 'transparent'), }, // will be removed eventually '&:where(:hover, .hover) .wd-icon-fill': { @@ -182,7 +181,10 @@ export const systemIconStencil = createStencil({ ), }, '&:where(:hover, .hover) .wd-icon-background': { - fill: cssVar(deprecatedSystemIconVars.backgroundHover, backgroundColor), + fill: cssVar( + deprecatedSystemIconVars.backgroundHover, + cssVar(backgroundColor, 'transparent') + ), }, }), }); diff --git a/modules/react/layout/lib/utils/buildStyleFns.ts b/modules/react/layout/lib/utils/buildStyleFns.ts index 08e2af85a5..4300716be1 100644 --- a/modules/react/layout/lib/utils/buildStyleFns.ts +++ b/modules/react/layout/lib/utils/buildStyleFns.ts @@ -5,6 +5,7 @@ import { space as spaceTokens, type as typeTokens, } from '@workday/canvas-kit-react/tokens'; +import {maybeWrapCSSVariables} from '@workday/canvas-kit-styling'; import {CanvasSystemPropValues, SystemPropNames, SystemPropValues} from './systemProps'; @@ -18,8 +19,12 @@ export type StyleFns = { [key: string]: (value: unknown) => {}; }; +function maybeWrapValue(input: string | number): string | number { + return typeof input === 'string' ? maybeWrapCSSVariables(input) : input; +} + const getColor = (value: SystemPropValues['color']) => { - return colorTokens[value] || value; + return colorTokens[value] || maybeWrapValue(value); }; const getDepth = (value: SystemPropValues['depth']) => { @@ -27,23 +32,32 @@ const getDepth = (value: SystemPropValues['depth']) => { }; const getShape = (value: SystemPropValues['shape']) => { - return borderRadiusTokens[value as CanvasSystemPropValues['shape']] || value; + return borderRadiusTokens[value as CanvasSystemPropValues['shape']] || maybeWrapValue(value); }; const getSpace = (value: SystemPropValues['space']) => { - return spaceTokens[value as CanvasSystemPropValues['space']] || value; + return spaceTokens[value as CanvasSystemPropValues['space']] || maybeWrapValue(value); }; const getFont = (value: SystemPropValues['font']) => { - return typeTokens.properties.fontFamilies[value as CanvasSystemPropValues['font']] || value; + return ( + typeTokens.properties.fontFamilies[value as CanvasSystemPropValues['font']] || + maybeWrapValue(value) + ); }; const getFontSize = (value: SystemPropValues['fontSize'] | string) => { - return typeTokens.properties.fontSizes[value as CanvasSystemPropValues['fontSize']] || value; + return ( + typeTokens.properties.fontSizes[value as CanvasSystemPropValues['fontSize']] || + maybeWrapValue(value) + ); }; const getFontWeight = (value: SystemPropValues['fontWeight'] | string) => { - return typeTokens.properties.fontWeights[value as CanvasSystemPropValues['fontWeight']] || value; + return ( + typeTokens.properties.fontWeights[value as CanvasSystemPropValues['fontWeight']] || + maybeWrapValue(value) + ); }; export function buildStyleFns(styleFnConfigs: StyleFnConfig[]): StyleFns { diff --git a/modules/react/menu/lib/MenuItem.tsx b/modules/react/menu/lib/MenuItem.tsx index d862398eb2..9d58f1abf6 100644 --- a/modules/react/menu/lib/MenuItem.tsx +++ b/modules/react/menu/lib/MenuItem.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import {colors, iconColors, typeColors, space, type} from '@workday/canvas-kit-react/tokens'; import {createStencil} from '@workday/canvas-kit-styling'; import {brand, system} from '@workday/canvas-tokens-web'; -import {checkIcon} from '@workday/canvas-system-icons-web'; +import {checkSmallIcon} from '@workday/canvas-system-icons-web'; import { createSubcomponent, @@ -36,6 +36,18 @@ export interface MenuItemProps { * The label text of the MenuItem. */ children: React.ReactNode; + /** + * The semantic side effect of selecting a menu item. What is the intent when a user activates this + * menu item? + * - `selectable`: The menu item is intended to be an option that can be selected and retain a + * selected state. It will be shown using selected styling when the menu is open. This should + * be used for components like `Select` or `MultiSelect` or toolbars that have a few valid + * options + * - `actionable`: The menu item is intended to perform an action rather than selecting something. + * This could be a navigation dropdown menu or creating a new user. There is no selected state + * that is portrayed to the user because selection is not the intent of the item. + */ + type?: 'selectable' | 'actionable'; /** * The name of the menu item. This name will be used in the `onSelect` callback in the model. If * this property is not provided, it will default to a string representation of the the zero-based @@ -71,23 +83,20 @@ export const menuItemStencil = createStencil({ minHeight: system.space.x10, // selected checkmark - '& [data-part="menu-item-selected"]': { + '& :where([data-part="menu-item-selected"])': { transition: 'opacity 80ms linear', opacity: system.opacity.zero, }, - '&[aria-selected=true] :where([data-part="menu-item-selected"])': { - opacity: system.opacity.full, - }, // if the menu item has children we need it to be displayed in flex - '&:has(span)': { + '&:where(:has(span))': { display: 'flex', }, // Hover styles - '&:is(.hover, :hover, [aria-selected=true])': { + '&:is(.hover, :hover)': { [systemIconStencil.vars.color]: system.color.icon.strong, - backgroundColor: brand.primary.lightest, + backgroundColor: brand.neutral.lightest, }, // Focus styles @@ -115,6 +124,42 @@ export const menuItemStencil = createStencil({ alignSelf: 'center', }, }, + modifiers: { + /** + * The semantic side effect of selecting a menu item. What is the intent when a user activates this + * menu item? + * - `selectable`: The menu item is intended to be an option that can be selected and retain a + * selected state. It will be shown using selected styling when the menu is open. This should + * be used for components like `Select` or `MultiSelect` or toolbars that have a few valid + * options + * - `actionable`: The menu item is intended to perform an action rather than selecting something. + * This could be a navigation dropdown menu or creating a new user. There is no selected state + * that is portrayed to the user because selection is not the intent of the item. + */ + type: { + selectable: { + '&[aria-selected=true]': { + [systemIconStencil.vars.color]: brand.primary.dark, + color: systemIconStencil.vars.color, + backgroundColor: brand.primary.lightest, + '& :where([data-part="menu-item-selected"])': { + opacity: system.opacity.full, + }, + '&:where(.focus, :focus)': { + [systemIconStencil.vars.color]: brand.primary.accent, + outline: 'none', + backgroundColor: brand.primary.base, + color: systemIconStencil.vars.color, + }, + }, + }, + actionable: { + '& [data-part="menu-item-selected"]': { + display: 'none', + }, + }, + }, + }, }); const MenuItemIcon = styled(SystemIcon)({alignSelf: 'start'}); @@ -123,16 +168,17 @@ const MenuItemText = ({children}: React.PropsWithChildren) => { return ( <> {children} - + ); }; export const StyledMenuItem = createComponent('button')({ displayName: 'MenuItem', - Component: ({children, ...elemProps}, ref, Element) => { + Component: ({children, type = 'actionable', ...elemProps}: MenuItemProps, ref, Element) => { + console.log('type', type); return ( - + {typeof children === 'string' ? {children} : children} ); @@ -275,13 +321,15 @@ export const MenuItem = createSubcomponent('button')({ Icon: MenuItemIcon, Text: MenuItemText, }, -})(({children, ...elemProps}, Element) => { +})(({children, type = 'actionable', ...elemProps}, Element) => { return ( {/* {children} */} - {children} + + {children} + ); }); diff --git a/modules/react/popup/lib/hooks/useAssistiveHideSiblings.ts b/modules/react/popup/lib/hooks/useAssistiveHideSiblings.ts index f940e41ad3..7aaab19b6b 100644 --- a/modules/react/popup/lib/hooks/useAssistiveHideSiblings.ts +++ b/modules/react/popup/lib/hooks/useAssistiveHideSiblings.ts @@ -23,7 +23,7 @@ export const useAssistiveHideSiblings = createElemPropsHook(usePopupModel)(model } const siblings = [ - ...((model.state.stackRef.current?.parentElement?.children as any) as HTMLElement[]), + ...((model.state.stackRef.current?.parentElement?.children || []) as HTMLElement[]), ].filter(el => el !== model.state.stackRef.current); const prevAriaHidden = siblings.map(el => el.getAttribute('aria-hidden')); siblings.forEach(el => { diff --git a/modules/react/text-input/lib/InputGroup.tsx b/modules/react/text-input/lib/InputGroup.tsx index c16531ebcf..6cdb349ba1 100644 --- a/modules/react/text-input/lib/InputGroup.tsx +++ b/modules/react/text-input/lib/InputGroup.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import {space} from '@workday/canvas-kit-react/tokens'; +import {maybeWrapCSSVariables} from '@workday/canvas-kit-styling'; +import {system} from '@workday/canvas-tokens-web'; import { createContainer, createElemPropsHook, @@ -198,11 +199,11 @@ export const InputGroup = createContainer('div')({ // `offsetEnd` arrays React.Children.forEach(children, child => { if (React.isValidElement(child) && child.type === InputGroupInnerStart) { - const width = child.props.width || space.xl; + const width = maybeWrapCSSVariables(child.props.width || system.space.x10); offsetsStart.push(width); } if (React.isValidElement(child) && child.type === InputGroupInnerEnd) { - const width = child.props.width || space.xl; + const width = maybeWrapCSSVariables(child.props.width || system.space.x10); offsetsEnd.push(width); } }); diff --git a/modules/styling/lib/cs.ts b/modules/styling/lib/cs.ts index 0492d041e2..7c5d22f821 100644 --- a/modules/styling/lib/cs.ts +++ b/modules/styling/lib/cs.ts @@ -36,9 +36,17 @@ type DefaultedVarsShape = Record | Record Date: Wed, 9 Oct 2024 17:19:06 -0600 Subject: [PATCH 05/22] wip --- .../examples/MultiSelect/MultiSelectCard.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 modules/react/combobox/stories/examples/MultiSelect/MultiSelectCard.tsx diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectCard.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectCard.tsx new file mode 100644 index 0000000000..fda42d334a --- /dev/null +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectCard.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { + createElemPropsHook, + createSubcomponent, + ExtractProps, +} from '@workday/canvas-kit-react/common'; +import {Menu} from '@workday/canvas-kit-react/menu'; + +import {useMultiSelectModel} from './useMultiSelectModel'; +import {calc, px2rem} from '@workday/canvas-kit-styling'; + +export interface MultiSelectCardProps extends ExtractProps {} + +/** + * This hook sets the `minWidth` style attribute to match the width of the + * {@link MultiSelectInput MultiSelect.Input} component. + */ +export const useMultiSelectCard = createElemPropsHook(useMultiSelectModel)(model => { + return { + minWidth: calc.add(px2rem(model.state.width), px2rem(2)), + } as const; +}); + +export const MultiSelectCard = createSubcomponent('div')({ + modelHook: useMultiSelectModel, + elemPropsHook: useMultiSelectCard, +})(({children, ...elemProps}, Element) => { + return ( + + {children} + + ); +}); From 71bb7931a65477e69170c403e49f6580e7836947 Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Wed, 9 Oct 2024 17:19:26 -0600 Subject: [PATCH 06/22] wip --- cypress/component/FormField.spec.tsx | 72 +++-- cypress/component/SelectPreview.spec.tsx | 246 +++++++++++------- cypress/support/commands.ts | 33 ++- modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx | 15 ++ .../combobox/spec/Combobox.spec.tsx | 2 +- .../stories/examples/DisabledItem.tsx | 2 +- .../stories/examples/OverflowActionBar.tsx | 1 + .../react/button/stories/button/Button.mdx | 2 +- .../react/combobox/stories/examples/App.tsx | 17 +- .../examples/MultiSelect/MultiSelect.tsx | 6 +- .../examples/MultiSelect/MultiSelectInput.tsx | 106 +++++--- .../lib/hooks/useFormFieldInput.tsx | 7 + .../lib/hooks/useFormFieldLabel.tsx | 8 + .../react/form-field/stories/FormField.mdx | 43 ++- modules/react/menu/lib/MenuItem.tsx | 138 ++-------- modules/react/text-input/lib/TextInput.tsx | 3 +- modules/styling/lib/cs.ts | 4 +- modules/styling/spec/cs.spec.tsx | 8 +- 18 files changed, 410 insertions(+), 303 deletions(-) diff --git a/cypress/component/FormField.spec.tsx b/cypress/component/FormField.spec.tsx index 99af6246d7..e5f059419e 100644 --- a/cypress/component/FormField.spec.tsx +++ b/cypress/component/FormField.spec.tsx @@ -4,10 +4,6 @@ import {Alert} from '../../modules/react/form-field/stories/examples/Alert'; import {Error} from '../../modules/react/form-field/stories/examples/Error'; import {Required} from '../../modules/react/form-field/stories/examples/Required'; -const getInput = () => { - return cy.get(`input`); -}; - describe('Form Field', () => { context(`given the Basic example is rendered`, () => { beforeEach(() => { @@ -18,30 +14,72 @@ describe('Form Field', () => { cy.checkA11y(); }); + it('should add an "id" to the input and point it to the "for" attribute of the label', () => { + cy.get('label').should('have.attr', 'for'); + cy.get('input').should('have.attr', 'id'); + + cy.get('input').should($input => { + // use jQuery to grab the for attribute of the label element + const labelId = cy.$$('label').attr('for'); + + expect($input).attr('id').to.equal(labelId); + }); + }); + + it('should add an "aria-labelledby" to the input and point it to the "id" attribute of the label', () => { + cy.get('label').should('have.attr', 'id'); + cy.get('input').should('have.attr', 'aria-labelledby'); + + cy.get('input').should($input => { + // use jQuery to grab the for attribute of the label element + const labelId = cy.$$('label').attr('id'); + + expect($input).attr('aria-labelledby').to.equal(labelId); + }); + }); + + it('should link the input to the label name', () => { + cy.get('input').should('have.ariaLabel', 'First Name'); + }); + context('when clicking on the label', () => { beforeEach(() => { - getInput().click(); + cy.get('label').click(); }); it('should focus the input', () => { - getInput().should('be.focused'); + cy.get('input').should('be.focused'); }); }); }); - [Alert, Error].forEach(Example => { - context(`given the '${Example.name}' story is rendered`, () => { - beforeEach(() => { - cy.mount(); - }); + context(`given the 'Alert' story is rendered`, () => { + beforeEach(() => { + cy.mount(); + }); - it('should not have any axe errors', () => { - cy.checkA11y(); - }); + it('should not have any axe errors', () => { + cy.checkA11y(); + }); - it('should connect the input with the hint text', () => { - getInput().should('have.attr', 'aria-describedby'); - }); + it('should connect the input with the hint text', () => { + cy.get('input').should('have.attr', 'aria-describedby'); + cy.get('input').should('have.ariaDescription', 'Cannot contain numbers'); + }); + }); + + context(`given the 'Error' story is rendered`, () => { + beforeEach(() => { + cy.mount(); + }); + + it('should not have any axe errors', () => { + cy.checkA11y(); + }); + + it('should connect the input with the hint text', () => { + cy.get('input').should('have.attr', 'aria-describedby'); + cy.get('input').should('have.ariaDescription', 'Must Contain a number and a capital letter'); }); }); diff --git a/cypress/component/SelectPreview.spec.tsx b/cypress/component/SelectPreview.spec.tsx index bb452267bb..84e4b5fa22 100644 --- a/cypress/component/SelectPreview.spec.tsx +++ b/cypress/component/SelectPreview.spec.tsx @@ -71,7 +71,7 @@ describe('Select', () => { context('when the select button is clicked', () => { beforeEach(() => { - cy.findByLabelText('Label').click(); + cy.findByRole('button', {name: 'Label'}).click(); }); it('should not have any axe errors', () => { @@ -85,21 +85,25 @@ describe('Select', () => { context('the select button', () => { it('should have an aria-expanded attribute set to "true"', () => { - cy.findByLabelText('Label').should('have.attr', 'aria-expanded', 'true'); + cy.findByRole('button', {name: 'Label'}).should('have.attr', 'aria-expanded', 'true'); }); }); context('the menu', () => { it('should be visible', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('be.visible'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('be.visible'); }); it('should have focus', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('be.focused'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('be.focused'); }); it('should have an aria-activedescendant attribute with the same value as the id of the first option ("E-mail")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .should($menu => { const menuAD = $menu.attr('aria-activedescendant'); @@ -110,7 +114,7 @@ describe('Select', () => { }); it('should set assistive focus to the first option ("E-mail")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -119,7 +123,7 @@ describe('Select', () => { context('the first option ("Mail")', () => { it('should have an aria-selected attribute set to "true"', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getOption(0)) .should('have.attr', 'aria-selected', 'true'); }); @@ -127,37 +131,41 @@ describe('Select', () => { context(`when the "Phone" option (with the value "phone") is clicked`, () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getOption('Phone')).click(); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getOption('Phone')) + .click(); }); context('the select button', () => { it(`should read "Phone"`, () => { - cy.findByLabelText('Label').should('have.text', 'Phone'); + cy.findByRole('button', {name: 'Label'}).should('have.text', 'Phone'); }); it(`should have a value of "phone"`, () => { - cy.findByLabelText('Label').should('have.value', 'phone'); + cy.findByRole('button', {name: 'Label'}).should('have.value', 'phone'); }); it(`should re-acquire focus`, () => { - cy.findByLabelText('Label').should('be.focused'); + cy.findByRole('button', {name: 'Label'}).should('be.focused'); }); }); context('the menu', () => { it('should not be visible', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('not.exist'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('not.exist'); }); }); context('when the menu is opened again', () => { beforeEach(() => { - cy.findByLabelText('Label').click(); + cy.findByRole('button', {name: 'Label'}).click(); }); context('the menu', () => { it('should set assistive focus to the "Phone" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Phone'); @@ -166,7 +174,7 @@ describe('Select', () => { context('the "Phone" option', () => { it('should have an aria-selected attribute set to "true"', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getOption('Phone')) .should('have.attr', 'aria-selected', 'true'); }); @@ -177,7 +185,7 @@ describe('Select', () => { context('when the select button is focused', () => { beforeEach(() => { - cy.findByLabelText('Label').focus(); + cy.findByRole('button', {name: 'Label'}).focus(); }); context('when the down arrow key is pressed', () => { @@ -187,17 +195,21 @@ describe('Select', () => { context('the select button', () => { it('should have an aria-expanded attribute set to "true"', () => { - cy.findByLabelText('Label').should('have.attr', 'aria-expanded', 'true'); + cy.findByRole('button', {name: 'Label'}).should('have.attr', 'aria-expanded', 'true'); }); }); context('the menu', () => { it('should be visible', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('be.visible'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('be.visible'); }); it('should have focus', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('be.focused'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('be.focused'); }); }); @@ -208,7 +220,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the "Phone" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Phone'); @@ -222,7 +234,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the "Mail" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Mail'); @@ -231,26 +243,30 @@ describe('Select', () => { context('when the enter key is pressed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('{enter}'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('{enter}'); }); context('the select button', () => { it(`should read "Mail"`, () => { - cy.findByLabelText('Label').should('have.text', 'Mail'); + cy.findByRole('button', {name: 'Label'}).should('have.text', 'Mail'); }); it(`should have a value of "mail"`, () => { - cy.findByLabelText('Label').should('have.value', 'mail'); + cy.findByRole('button', {name: 'Label'}).should('have.value', 'mail'); }); it(`should re-acquire focus`, () => { - cy.findByLabelText('Label').should('be.focused'); + cy.findByRole('button', {name: 'Label'}).should('be.focused'); }); }); context('the menu', () => { it('should not be visible', () => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('not.exist'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('not.exist'); }); }); @@ -261,7 +277,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the "Mail" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Mail'); @@ -270,7 +286,7 @@ describe('Select', () => { context('the "Mail" option', () => { it('should have an aria-selected attribute set to "true"', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getOption('Mail')) .should('have.attr', 'aria-selected', 'true'); }); @@ -281,12 +297,14 @@ describe('Select', () => { context('when the up arrow key is pressed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('{uparrow}'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('{uparrow}'); }); context('the menu', () => { it('should set assistive focus to the "E-mail" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -298,24 +316,24 @@ describe('Select', () => { context('when the enter key is pressed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('{enter}'); + cy.findByRole('button', {name: 'Label'}).realType('{enter}'); }); context('the select button', () => { it('should have an aria-expanded attribute set to "true"', () => { - cy.findByLabelText('Label').should('have.attr', 'aria-expanded', 'true'); + cy.findByRole('button', {name: 'Label'}).should('have.attr', 'aria-expanded', 'true'); }); }); }); context('when the space key is pressed', () => { beforeEach(() => { - cy.findByLabelText('Label').type(' ', {force: true}); // disable event.preventDefault checks + cy.findByRole('button', {name: 'Label'}).type(' ', {force: true}); // disable event.preventDefault checks }); context('the select button', () => { it('should have an aria-expanded attribute set to "true"', () => { - cy.findByLabelText('Label').should('have.attr', 'aria-expanded', 'true'); + cy.findByRole('button', {name: 'Label'}).should('have.attr', 'aria-expanded', 'true'); }); }); }); @@ -323,21 +341,21 @@ describe('Select', () => { context('when the "select" helper is used to select "Mail"', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.select('Mail')); + cy.findByRole('button', {name: 'Label'}).pipe(h.selectPreview.select('Mail')); }); it('should have a value of "mail"', () => { - cy.findByLabelText('Label').should('have.value', 'mail'); + cy.findByRole('button', {name: 'Label'}).should('have.value', 'mail'); }); }); context('when the "select" helper is used to select /^Mail$/', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.select(/^Mail$/)); + cy.findByRole('button', {name: 'Label'}).pipe(h.selectPreview.select(/^Mail$/)); }); it('should have a value of "mail"', () => { - cy.findByLabelText('Label').should('have.value', 'mail'); + cy.findByRole('button', {name: 'Label'}).should('have.value', 'mail'); }); }); }); @@ -350,12 +368,12 @@ describe('Select', () => { context('when the menu is opened', () => { beforeEach(() => { - cy.findByLabelText('Label').focus().realType('{downarrow}'); + cy.findByRole('button', {name: 'Label'}).focus().realType('{downarrow}'); }); context('the menu', () => { it('should set assistive focus to the first option ("E-mail")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -369,7 +387,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the second option ("Phone")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Phone'); @@ -388,13 +406,15 @@ describe('Select', () => { // Wait for menu to fully close before we open it again (so we // don't interrupt the menu's closing animation and cause it to // re-open while it's in the middle of closing) - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).should('not.exist'); - cy.findByLabelText('Label').focus().realType('{downarrow}'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .should('not.exist'); + cy.findByRole('button', {name: 'Label'}).focus().realType('{downarrow}'); }); context('the menu', () => { it('should have reset assistive focus to the first option ("E-mail")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -412,7 +432,7 @@ describe('Select', () => { // Focus is shifting between the button and menu as we close // and open the menu. It's important that we use getMenu rather // than cy.focused() to ensure we obtain a reference to the menu. - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Phone'); @@ -437,7 +457,7 @@ describe('Select', () => { context('the select button', () => { it('should be disabled', () => { - cy.findByLabelText('Label').should('be.disabled'); + cy.findByRole('button', {name: 'Label'}).should('be.disabled'); }); }); }); @@ -450,12 +470,12 @@ describe('Select', () => { context('when the menu is opened', () => { beforeEach(() => { - cy.findByLabelText('Label (Disabled Options)').focus().realType('{downarrow}'); + cy.findByRole('button', {name: 'Label (Disabled Options)'}).focus().realType('{downarrow}'); }); context('the "Carrier Pigeon" option', () => { it('should have an aria-disabled attribute set to "true"', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getOption('Carrier Pigeon')) .should('have.attr', 'aria-disabled', 'true'); }); @@ -468,7 +488,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to first enabled option ("E-mail")', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -482,7 +502,7 @@ describe('Select', () => { context('the menu', () => { it('should retain assistive focus on the "E-mail" option since the previous option ("Carrier Pigeon", which also happens to be the first option) is disabled', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -497,7 +517,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the third option down ("Mail") since focus will have skipped one disabled option ("Fax")', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Mail'); @@ -511,7 +531,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the first option down ("Mobile Phone") since the second option down ("Telegram", which also happens to be the last option) is disabled', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Mobile Phone'); @@ -528,7 +548,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the first enabled option ("E-mail")', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'E-mail'); @@ -543,7 +563,7 @@ describe('Select', () => { context('the menu', () => { it('should set assistive focus to the last enabled option ("Mobile Phone")', () => { - cy.findByLabelText('Label (Disabled Options)') + cy.findByRole('button', {name: 'Label (Disabled Options)'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Mobile Phone'); @@ -561,7 +581,7 @@ describe('Select', () => { context('when the select button is focused', () => { beforeEach(() => { - cy.findByLabelText('Label').focus(); + cy.findByRole('button', {name: 'Label'}).focus(); }); context( @@ -569,40 +589,49 @@ describe('Select', () => { () => { context('when "s" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('s'); + cy.findByRole('button', {name: 'Label'}).realType('s'); }); context('the select button', () => { it('should read the first option beginning with "s" ("San Francisco (United States)")', () => { - cy.findByLabelText('Label').should('have.text', 'San Francisco (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'San Francisco (United States)' + ); }); it(`should have a value of "san-francisco"`, () => { - cy.findByLabelText('Label').should('have.value', 'san-francisco'); + cy.findByRole('button', {name: 'Label'}).should('have.value', 'san-francisco'); }); }); }); context('when "s{500ms delay}s" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('ss', {delay: 500}); + cy.findByRole('button', {name: 'Label'}).realType('ss', {delay: 500}); }); context('the select button', () => { it('should read the second option beginning with "s" ("San Mateo (United States)")', () => { - cy.findByLabelText('Label').should('have.text', 'San Mateo (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'San Mateo (United States)' + ); }); }); }); context('when "s{500ms delay}d" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('sd', {delay: 500}); + cy.findByRole('button', {name: 'Label'}).realType('sd', {delay: 500}); }); context('the select button', () => { it('should read the first option beginning with "d" ("Dallas (United States)")', () => { - cy.findByLabelText('Label').should('have.text', 'Dallas (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'Dallas (United States)' + ); }); }); }); @@ -614,36 +643,45 @@ describe('Select', () => { () => { context('when "sa" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('sa'); + cy.findByRole('button', {name: 'Label'}).realType('sa'); }); context('the select button', () => { it('should read "San Francisco (United States)"', () => { - cy.findByLabelText('Label').should('have.text', 'San Francisco (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'San Francisco (United States)' + ); }); }); }); context('when "san " is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('san '); + cy.findByRole('button', {name: 'Label'}).realType('san '); }); context('the select button', () => { it('should read "San Francisco (United States)"', () => { - cy.findByLabelText('Label').should('have.text', 'San Francisco (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'San Francisco (United States)' + ); }); }); }); context('when "san m" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').realType('san m'); + cy.findByRole('button', {name: 'Label'}).realType('san m'); }); context('the select button', () => { it('should read "San Mateo (United States)"', () => { - cy.findByLabelText('Label').should('have.text', 'San Mateo (United States)'); + cy.findByRole('button', {name: 'Label'}).should( + 'have.text', + 'San Mateo (United States)' + ); }); }); }); @@ -658,12 +696,12 @@ describe('Select', () => { // typeahead string // context('when "san " is typed', () => { // beforeEach(() => { - // cy.findByLabelText('Label').realType('san '); + // cy.findByRole('button', {name: 'Label'}).realType('san '); // }); // context('the menu', () => { // it('should not be visible', () => { - // cy.findByLabelText('Label') + // cy.findByRole('button', {name: 'Label'}) // .pipe(h.selectPreview.getMenu) // .should('not.exist'); // }); @@ -673,7 +711,7 @@ describe('Select', () => { context('when the menu is opened', () => { beforeEach(() => { - cy.findByLabelText('Label').click(); + cy.findByRole('button', {name: 'Label'}).click(); }); context( @@ -681,19 +719,21 @@ describe('Select', () => { () => { context('when "s" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('s'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('s'); }); context('the menu', () => { it('should set assistive focus to the first option beginning with "s" ("San Francisco (United States)")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'San Francisco (United States)'); }); it('should scroll so that the "San Francisco (United States)" option is fully visible', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionInView); @@ -703,21 +743,21 @@ describe('Select', () => { context('when "s{500ms delay}s" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .realType('ss', {delay: 500}); }); context('the menu', () => { it('should set assistive focus to the second option beginning with "s" ("San Mateo (United States)")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'San Mateo (United States)'); }); it('should scroll so that the "San Mateo (United States)" option is fully visible', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionInView); @@ -727,21 +767,21 @@ describe('Select', () => { context('when "s{500ms delay}d" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .realType('sd', {delay: 500}); }); context('the menu', () => { it('should set assistive focus to the first option beginning with "d" ("Dallas (United States)")', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'Dallas (United States)'); }); it('should scroll so that the "Dallas (United States)" option is fully visible', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionInView); @@ -751,12 +791,14 @@ describe('Select', () => { context('when "the onto" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('the onto'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('the onto'); }); context('the menu', () => { it('should set assistive focus to "The Ontologically..."', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should( @@ -766,7 +808,7 @@ describe('Select', () => { }); it('should scroll so that the "The Ontologically..." (text wrapped) option is fully visible', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionInView); @@ -781,12 +823,14 @@ describe('Select', () => { () => { context('when "sa" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('sa'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('sa'); }); context('the menu', () => { it('should set assistive focus to the "San Francisco (United States)" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'San Francisco (United States)'); @@ -796,12 +840,14 @@ describe('Select', () => { context('when "san " is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('san '); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('san '); }); context('the menu', () => { it('should set assistive focus to the "San Francisco (United States)" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'San Francisco (United States)'); @@ -811,12 +857,14 @@ describe('Select', () => { context('when "san m" is typed', () => { beforeEach(() => { - cy.findByLabelText('Label').pipe(h.selectPreview.getMenu).realType('san m'); + cy.findByRole('button', {name: 'Label'}) + .pipe(h.selectPreview.getMenu) + .realType('san m'); }); context('the menu', () => { it('should set assistive focus to the "San Mateo (United States)" option', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should('have.text', 'San Mateo (United States)'); @@ -832,13 +880,13 @@ describe('Select', () => { () => { context('when "Dallas (United States)" is selected and the menu is opened', () => { beforeEach(() => { - cy.findByLabelText('Label').focus().type('d').click(); + cy.findByRole('button', {name: 'Label'}).focus().type('d').click(); }); context('the menu', () => { // Asserting specific pixels is incredibly hard it.skip('should scroll so that the "Dallas (United States)" option is centered in view', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionCenteredInView); @@ -850,13 +898,13 @@ describe('Select', () => { 'when "The Ontologically..." (text wrapped) is selected and the menu is opened', () => { beforeEach(() => { - cy.findByLabelText('Label').focus().type('the onto').click(); + cy.findByRole('button', {name: 'Label'}).focus().type('the onto').click(); }); context('the menu', () => { // Skipping this, trying to assert specific pixel values and it's always off it.skip('should scroll so that the "The Ontologically..." option is centered in view', () => { - cy.findByLabelText('Label') + cy.findByRole('button', {name: 'Label'}) .pipe(h.selectPreview.getMenu) .pipe(getAssistiveFocus) .should(assertOptionCenteredInView); @@ -881,7 +929,7 @@ describe('Select', () => { context('when the bottommost select button is clicked', () => { beforeEach(() => { - cy.findByLabelText('Label (Bottom)').click(); + cy.findByRole('button', {name: 'Label (Bottom)'}).click(); }); context('the page', () => { @@ -898,7 +946,7 @@ describe('Select', () => { () => { beforeEach(() => { cy.findByTestId('blur-test-button').click(); - cy.findByLabelText('Label (Bottom)').click(); + cy.findByRole('button', {name: 'Label (Bottom)'}).click(); }); context('the page', () => { @@ -920,12 +968,12 @@ describe('Select', () => { context('when the select button with aria-required set to true is clicked', () => { beforeEach(() => { - cy.findByLabelText(/Label \(aria-required\)/).click(); + cy.findByRole('button', {name: /Label \(aria-required\)/}).click(); }); context('the menu', () => { it('should have an aria-required attribute set to "true"', () => { - cy.findByLabelText(/Label \(aria-required\)/) + cy.findByRole('button', {name: /Label \(aria-required\)/}) .pipe(h.selectPreview.getMenu) .should('have.attr', 'aria-required', 'true'); }); @@ -934,12 +982,12 @@ describe('Select', () => { context('when the select button with required set to true is clicked', () => { beforeEach(() => { - cy.findByLabelText(/Label \(required\)/).click(); + cy.findByRole('button', {name: /Label \(required\)/}).click(); }); context('the menu', () => { it('should have an aria-required attribute set to "true"', () => { - cy.findByLabelText(/Label \(required\)/) + cy.findByRole('button', {name: /Label \(required\)/}) .pipe(h.selectPreview.getMenu) .should('have.attr', 'aria-required', 'true'); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 573c466cf5..5f9d3490e7 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -101,17 +101,25 @@ declare global { } export const haveAriaDescription = (text: string) => ($target: JQuery) => { - expect($target).to.have.attr('aria-describedby'); + if ($target.attr('aria-describe')) { + expect($target).to.have.attr('aria-describe', text); + } else if ($target.attr('aria-describedby')) { + expect($target).to.have.attr('aria-describedby'); + + const id = $target.attr('aria-describedby'); + const $descriptionEl = Cypress.$(`[id="${id}"]`); + if (!$descriptionEl.length) { + throw Error( + `Could not find an element with an id matching the aria-describedby: ${$target[0].outerHTML}` + ); + } - const id = $target.attr('aria-describedby'); - const $descriptionEl = Cypress.$(`[id="${id}"]`); - if (!$descriptionEl.length) { + expect($descriptionEl).to.have.text(text); + } else { throw Error( - `Could not find an element with an id matching the aria-describedby: ${$target[0].outerHTML}` + `Expected element to have an aria-describe or aria-describedby, but did not find one.` ); } - - expect($descriptionEl).to.have.text(text); }; export const haveAriaLabel = (text: string) => ($target: JQuery) => { @@ -126,6 +134,17 @@ export const haveAriaLabel = (text: string) => ($target: JQuery) => { ); } + expect($labelledEl).to.have.text(text); + } else if ($target.attr('id')) { + const id = $target.attr('id'); + console.log('id', id); + const $labelledEl = Cypress.$(`label[for="${id}"]`); + if (!$labelledEl.length) { + throw Error( + `Could not found an element with an id matching the for: ${$target[0].outerHTML}` + ); + } + expect($labelledEl).to.have.text(text); } else { throw Error(`Expected element to have an aria-label or aria-labelledby, but did not find one.`); diff --git a/modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx b/modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx index bc5ade301c..10adabce9f 100644 --- a/modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx +++ b/modules/docs/mdx/12.0-UPGRADE-GUIDE.mdx @@ -49,6 +49,7 @@ A note to the reader: - [Search Form](#search-form) - [Select](#select) - [Text Area](#text-area) + - [Menu Item](#menu-item) - [Troubleshooting](#troubleshooting) - [Glossary](#glossary) - [Main](#main) @@ -453,6 +454,20 @@ interface MyProps { } ``` +### Menu Item + +**PR: ** [2969](https://github.com/Workday/canvas-kit/pull/2969) + +`Menu.Item` was converted to use Stencils for styling and uses SystemIcon stencil variables to +change icon color instead of deeply nested selectors. We also added a `type` prop that can be either +`selectable` or `actionable`. The default for `Menu.Item` is `actionable`, while components with the +primary function of selection (like `Select`) default to `selectable`. + +- `actionable` - used for menu items where the user performs an action rather than selecting. A + selected checkmark does not show for `actionable` menu items. +- `selectable` - used for menu items where the user selects an option. A selected checkmark will + show. Note this may cause visual regression tests to trigger, but this is intended. + ## Troubleshooting ### My Styles Seem Broken? diff --git a/modules/labs-react/combobox/spec/Combobox.spec.tsx b/modules/labs-react/combobox/spec/Combobox.spec.tsx index ead4153b88..835077d6de 100644 --- a/modules/labs-react/combobox/spec/Combobox.spec.tsx +++ b/modules/labs-react/combobox/spec/Combobox.spec.tsx @@ -152,7 +152,7 @@ describe('Combobox', () => { const menuText = 'menuText'; const id = 'my-id'; const autocompleteItems = [ - + {menuText} , ]; diff --git a/modules/labs-react/combobox/stories/examples/DisabledItem.tsx b/modules/labs-react/combobox/stories/examples/DisabledItem.tsx index d1d5272e93..052a158fac 100644 --- a/modules/labs-react/combobox/stories/examples/DisabledItem.tsx +++ b/modules/labs-react/combobox/stories/examples/DisabledItem.tsx @@ -12,7 +12,7 @@ const autocompleteResult = ( textModifier: number, disabled: boolean ): ReactElement => ( - + Result number diff --git a/modules/react/action-bar/stories/examples/OverflowActionBar.tsx b/modules/react/action-bar/stories/examples/OverflowActionBar.tsx index 36aef0146f..42c31a728a 100644 --- a/modules/react/action-bar/stories/examples/OverflowActionBar.tsx +++ b/modules/react/action-bar/stories/examples/OverflowActionBar.tsx @@ -66,6 +66,7 @@ export const OverflowActionBar = () => { +

Selected: {containerWidth}

); diff --git a/modules/react/button/stories/button/Button.mdx b/modules/react/button/stories/button/Button.mdx index 1360b87c1f..78e6cfff5b 100644 --- a/modules/react/button/stories/button/Button.mdx +++ b/modules/react/button/stories/button/Button.mdx @@ -110,4 +110,4 @@ should be used for navigation. ## Specifications - + diff --git a/modules/react/combobox/stories/examples/App.tsx b/modules/react/combobox/stories/examples/App.tsx index 5d1ec60bec..9e41e23425 100644 --- a/modules/react/combobox/stories/examples/App.tsx +++ b/modules/react/combobox/stories/examples/App.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {CanvasProvider} from '@workday/canvas-kit-react/common'; import {createStyles} from '@workday/canvas-kit-styling'; -import {FormField} from '@workday/canvas-kit-preview-react/form-field'; +import {FormField} from '@workday/canvas-kit-react/form-field'; import {accessibilityIcon, accountsIcon} from '@workday/canvas-system-icons-web'; @@ -19,18 +19,6 @@ const mainContentStyles = createStyles({ const items = ['Cheese', 'Olives', 'Onions', 'Pepperoni', 'Peppers']; -const Container = styled('div')({ - '& :where([data-part="test"])': { - backgroundColor: 'blue', - }, -}); - -const Test = styled('div')(({backgroundColor}) => ({ - backgroundColor, - width: 100, - height: 100, -})); - export const App = () => { const [color, setColor] = React.useState('red'); return ( @@ -43,9 +31,6 @@ export const App = () => { }} >
- - - setColor(event.currentTarget.value)} /> diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx index 0653cbab9d..aaaf43d5ba 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelect.tsx @@ -6,8 +6,9 @@ import {Combobox} from '@workday/canvas-kit-react/combobox'; import {Menu} from '@workday/canvas-kit-react/menu'; import {useMultiSelectModel} from './useMultiSelectModel'; -import {MultiSelectInput} from './MultiSelectInput'; +import {MultiSelectInput, MultiSelectSearchInput} from './MultiSelectInput'; import {MultiSelectItem} from './MultiSelectItem'; +import {MultiSelectCard} from './MultiSelectCard'; export interface MultiSelectProps {} @@ -15,8 +16,9 @@ export const MultiSelect = createContainer()({ modelHook: useMultiSelectModel, subComponents: { Input: MultiSelectInput, + SearchInput: MultiSelectSearchInput, Popper: Combobox.Menu.Popper, - Card: Combobox.Menu.Card, + Card: MultiSelectCard, List: Combobox.Menu.List, Item: MultiSelectItem, }, diff --git a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx index 7ed09700a0..f4eb5cffaf 100644 --- a/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx +++ b/modules/react/combobox/stories/examples/MultiSelect/MultiSelectInput.tsx @@ -61,15 +61,18 @@ export const multiSelectStencil = createStencil({ }, // @ts-ignore - '& :where([data-part="user-input"])': { + '& [data-part="user-input"]': { ...system.type.subtext.large, backgroundColor: system.color.bg.transparent, + // padding: system.space.x2, // Compensate for border + borderRadius: system.shape.x1, + + // Remove the focus ring - it is handled at the container level border: 'none', + boxShadow: 'none', outlineWidth: '0px', - padding: system.space.x2, // Compensate for border - borderRadius: system.shape.x1, - '&:not([aria-autocomplete])': { + '&:where(:not([aria-autocomplete]))': { caretColor: 'transparent', cursor: 'default', '&::selection': { @@ -102,6 +105,15 @@ export const multiSelectStencil = createStencil({ flexWrap: 'wrap', }, }, + parts: { + 'user-input': { + // styles + // hover container + '&:hover': {}, + }, + // hover data-part + '& #:hover': {}, + }, }); export const useMultiSelectInput = composeHooks( @@ -128,7 +140,7 @@ export const useMultiSelectInput = composeHooks( } } - console.log('key', event.key); + // console.log('key', event.key); if ( (event.key === 'ArrowDown' || event.key === 'ArrowUp') && model.state.visibility === 'hidden' @@ -207,15 +219,18 @@ const MultiSelectedItem = createSubcomponent('span')({ export interface MultiSelectInputProps extends CSProps, - Pick, 'disabled' | 'className'> {} + Pick< + React.InputHTMLAttributes, + 'disabled' | 'className' | 'style' | 'aria-labelledby' + > {} export const MultiSelectInput = createSubcomponent(TextInput)({ displayName: 'MultiSelect.Input', modelHook: useMultiSelectModel, elemPropsHook: useMultiSelectInput, -})(({className, cs, disabled, ...elemProps}, Element, model) => { +})(({className, cs, style, disabled, ...elemProps}, Element, model) => { return ( -
+
- + @@ -244,35 +259,44 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({ displayName: 'MultiSelect.Input', modelHook: useMultiSelectModel, elemPropsHook: useMultiSelectInput, -})(({className, cs, disabled, ...elemProps}, Element, model) => { - return ( -
- - - - - - - - - - - - - - {model.selected.state.items.length ? ( - <> -
- - {item => {item.textValue}} - - - ) : null} -
- ); -}); +})( + ({className, cs, disabled, 'aria-labelledby': ariaLabelledBy, ...elemProps}, Element, model) => { + return ( +
+ + + + + + + + + + + + + + {model.selected.state.items.length ? ( + <> +
+ + {item => {item.textValue}} + + + ) : null} +
+ ); + } +); diff --git a/modules/react/form-field/lib/hooks/useFormFieldInput.tsx b/modules/react/form-field/lib/hooks/useFormFieldInput.tsx index e1e021b73d..535d846f5f 100644 --- a/modules/react/form-field/lib/hooks/useFormFieldInput.tsx +++ b/modules/react/form-field/lib/hooks/useFormFieldInput.tsx @@ -10,6 +10,13 @@ export const useFormFieldInput = createElemPropsHook(useFormFieldModel)(({state} required: state.isRequired ? true : undefined, 'aria-invalid': state.error === 'error' ? true : undefined, 'aria-describedby': state.id ? `hint-${state.id}` : undefined, + /** + * Provide an `aria-labelledby` for fields that need access to `label` directly + */ + 'aria-labelledby': state.id ? `label-${state.id}` : undefined, + /** + * Provide an `id` to link the input via the `for` attribute on a `label` + */ id: state.id ? `input-${state.id}` : undefined, error: state.error, }; diff --git a/modules/react/form-field/lib/hooks/useFormFieldLabel.tsx b/modules/react/form-field/lib/hooks/useFormFieldLabel.tsx index 64b5930304..24eeadde3a 100644 --- a/modules/react/form-field/lib/hooks/useFormFieldLabel.tsx +++ b/modules/react/form-field/lib/hooks/useFormFieldLabel.tsx @@ -7,6 +7,14 @@ import {useFormFieldModel} from './useFormFieldModel'; */ export const useFormFieldLabel = createElemPropsHook(useFormFieldModel)(({state}) => { return { + /** + * Provide an `for` attribute for `for/id` links between a `label/input` respectively + */ htmlFor: `input-${state.id}`, + /** + * Provide an `id` attribute for `id/aria-labelledby` links between the label and any other + * element + */ + id: `label-${state.id}`, }; }); diff --git a/modules/react/form-field/stories/FormField.mdx b/modules/react/form-field/stories/FormField.mdx index 9215955e74..fffbf0af2b 100644 --- a/modules/react/form-field/stories/FormField.mdx +++ b/modules/react/form-field/stories/FormField.mdx @@ -1,4 +1,4 @@ -import {ExampleCodeBlock, SymbolDoc} from '@workday/canvas-kit-docs'; +import {ExampleCodeBlock, Specifications, SymbolDoc} from '@workday/canvas-kit-docs'; import * as FormFieldStories from './FormField.stories'; import {Basic} from './examples/Basic'; @@ -30,6 +30,43 @@ by passing in `TextInput`, `Select`, `RadioGroup` and other form elements to `Fo yarn add @workday/canvas-kit-react ``` +## Accessibility + +The `FormField` adds a `for` attribute to the `FormField.Label` (`