From 2ad2b087dc7ee7b1c0a737ebcee42b927dca7806 Mon Sep 17 00:00:00 2001 From: Aislinn Hayes Date: Fri, 1 Nov 2024 14:31:38 +0000 Subject: [PATCH 1/6] feat: Add backgroundColor var on LoadingDots (#2991) Fixes: #2990 https://canvas.workdaydesign.com/components/indicators/loading-dots/ shows a circle variant for use on gray/dark/image-based backgrounds. Our LoadingDots component styling needed some TLC to allow consumers to set the background color to match this pattern, and our Storybook entries can be updated to show how to achieve this variant. I also noticed some links to docs were broken in the codebase so I updated that too. [category:Components Documentation] Release Note: Adds capability to LoadingDots to accept a `loadingDotColor` prop to change loading dots color, with the capability to override that var with a stencil - intended for use with the circle variant for dark backgrounds/images, which is now shown in Storybook with Custom Shape story. Also adds a `animationDurationMs` prop in the same pattern, with a Custom Color and Animation story in storybook demonstrating the use of both these new props. Co-authored-by: @mannycarrera4 Co-authored-by: manuel.carrera Co-authored-by: @NicholasBoll Co-authored-by: @alanbsmith Co-authored-by: @alanbsmith Co-authored-by: @mannycarrera4 --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 2 +- .../react/loading-dots/lib/LoadingDots.tsx | 94 ++++++++++--------- .../loading-dots/stories/LoadingDots.mdx | 23 +++-- .../stories/LoadingDots.stories.ts | 8 ++ .../stories/examples/Accessible.tsx | 2 +- .../examples/CustomColorAndAnimation.tsx | 19 ++++ .../stories/examples/CustomShape.tsx | 32 +++++++ 8 files changed, 131 insertions(+), 51 deletions(-) create mode 100644 modules/react/loading-dots/stories/examples/CustomColorAndAnimation.tsx create mode 100644 modules/react/loading-dots/stories/examples/CustomShape.tsx diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3c55940e45..466089b906 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,7 +20,7 @@ Optional breaking changes message. If your PR includes breaking changes. It is e ## Checklist -- [ ] MDX documentation adheres to Canvas Kit's [Documentation Guidelines](https://workday.github.io/canvas-kit/?path=/docs/guides-documentation-guidelines--page) +- [ ] MDX documentation adheres to Canvas Kit's [Documentation Guidelines](https://workday.github.io/canvas-kit/?path=/docs/guides-documentation-guidelines--docs) - [ ] Label `ready for review` has been added to PR ## For the Reviewer diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1723e112b..1a8ca50b85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ # Contributing to Canvas You may read our contribution guidelines -[here](https://workday.github.io/canvas-kit/?path=/docs/guides-contributing--page). +[here](https://workday.github.io/canvas-kit/?path=/docs/guides-contributing--docs). diff --git a/modules/react/loading-dots/lib/LoadingDots.tsx b/modules/react/loading-dots/lib/LoadingDots.tsx index 03953df3e8..d23963dac5 100644 --- a/modules/react/loading-dots/lib/LoadingDots.tsx +++ b/modules/react/loading-dots/lib/LoadingDots.tsx @@ -15,48 +15,51 @@ const keyframesLoading = keyframes({ }, }); -const singleLoadingDotStencil = createStencil({ +export interface LoadingDotsProps extends CSProps { + /** + * Applies backgroundColor to loading dots, intended for use with the circle variant design on grey/dark/image-based backgrounds. + * @default `system.color.bg.alt.strong` + */ + loadingDotColor?: string; + /** + * Duration of the loading animation in milliseconds. + * @default `40ms` + */ + animationDurationMs?: string; +} + +export const loadingDotsStencil = createStencil({ vars: { animationDurationMs: '40ms', + loadingDotColor: system.color.bg.alt.strong, }, - base: ({animationDurationMs}) => ({ - backgroundColor: system.color.bg.alt.strong, - width: system.space.x4, - height: system.space.x4, - fontSize: system.space.zero, - borderRadius: system.shape.round, - transform: 'scale(0)', - display: 'inline-block', - animationName: keyframesLoading, - animationDuration: calc.multiply(animationDurationMs, 35), - animationIterationCount: 'infinite', - animationTimingFunction: 'ease-in-out', - animationFillMode: 'both', - '&:nth-child(1)': { - animationDelay: '0ms', - }, - '&:nth-child(2)': { - animationDelay: calc.multiply(animationDurationMs, 4), - }, - '&:nth-child(3)': { - animationDelay: calc.multiply(animationDurationMs, 8), - }, - }), -}); - -/** - * The actual loading dot div. - */ -const LoadingAnimationDot = () =>
; - -/** - * A simple container for the loading dots. - */ -const loadingDotsStencil = createStencil({ - base: { + base: ({loadingDotColor, animationDurationMs}) => ({ display: 'inline-flex', gap: system.space.x2, - }, + '& [data-part="loading-animation-dot"]': { + backgroundColor: loadingDotColor, + width: system.space.x4, + height: system.space.x4, + fontSize: system.space.zero, + borderRadius: system.shape.round, + transform: 'scale(0)', + display: 'inline-block', + animationName: keyframesLoading, + animationDuration: calc.multiply(animationDurationMs, 35), + animationIterationCount: 'infinite', + animationTimingFunction: 'ease-in-out', + animationFillMode: 'both', + '&:nth-child(1)': { + animationDelay: '0ms', + }, + '&:nth-child(2)': { + animationDelay: calc.multiply(animationDurationMs, 4), + }, + '&:nth-child(3)': { + animationDelay: calc.multiply(animationDurationMs, 8), + }, + }, + }), }); /** @@ -64,12 +67,19 @@ const loadingDotsStencil = createStencil({ */ export const LoadingDots = createComponent('div')({ displayName: 'LoadingDots', - Component: (elemProps: CSProps, ref, Element) => { + Component: ( + {loadingDotColor, animationDurationMs, ...elemProps}: LoadingDotsProps, + ref, + Element + ) => { return ( - - - - + +
+
+
); }, diff --git a/modules/react/loading-dots/stories/LoadingDots.mdx b/modules/react/loading-dots/stories/LoadingDots.mdx index afd4eb3ffe..1bd8b228b4 100644 --- a/modules/react/loading-dots/stories/LoadingDots.mdx +++ b/modules/react/loading-dots/stories/LoadingDots.mdx @@ -1,7 +1,9 @@ -import {ExampleCodeBlock} from '@workday/canvas-kit-docs'; +import {ExampleCodeBlock, SymbolDoc} from '@workday/canvas-kit-docs'; import {Basic} from './examples/Basic'; import {RTL} from './examples/RTL'; import {Accessible} from './examples/Accessible'; +import {CustomShape} from './examples/CustomShape'; +import {CustomColorAndAnimation} from './examples/CustomColorAndAnimation'; import * as LoadingDotsStories from './LoadingDots.stories'; @@ -11,7 +13,7 @@ import * as LoadingDotsStories from './LoadingDots.stories'; Loading Dots make users aware that content is currently being loaded, processing, or that change will occur on the page. -[> Workday Design Reference](https://design.workday.com/components/indicators/loading-dots) +[> Workday Design Reference](https://canvas.workdaydesign.com/components/indicators/loading-dots/) ## Installation @@ -29,10 +31,20 @@ yarn add @workday/canvas-kit-react +### Custom Shape + + + +### Custom Color and Animation + + + ### Custom Styles Loading Dots supports custom styling via the `cs` prop. For more information, check our -["How To Customize Styles"](https://workday.github.io/canvas-kit/?path=/docs/styling-how-to-customize-styles--page). +["How To Customize Styles"](https://workday.github.io/canvas-kit/?path=/docs/styling-how-to-customize-styles--docs). + +Custom styling is also supported through the [Loading Dots documented props below](#props). ### Adding screen reader support to loading animations @@ -50,7 +62,6 @@ components included in Canvas to describe both the appearance and disappearance -## Props +## Component API -Loading Dots does not have any documented props. Undocumented props are spread to its outermost -element. + diff --git a/modules/react/loading-dots/stories/LoadingDots.stories.ts b/modules/react/loading-dots/stories/LoadingDots.stories.ts index 45f736ef5f..fc41a8a021 100644 --- a/modules/react/loading-dots/stories/LoadingDots.stories.ts +++ b/modules/react/loading-dots/stories/LoadingDots.stories.ts @@ -7,6 +7,8 @@ import {LoadingDots} from '@workday/canvas-kit-react/loading-dots'; import {Basic as BasicExample} from './examples/Basic'; import {RTL as RTLExample} from './examples/RTL'; import {Accessible as AccessibleExample} from './examples/Accessible'; +import {CustomShape as CustomShapeExample} from './examples/CustomShape'; +import {CustomColorAndAnimation as CustomColorAndAnimationExample} from './examples/CustomColorAndAnimation'; export default { title: 'Components/Indicators/Loading Dots', @@ -30,3 +32,9 @@ export const RTL: Story = { export const Accessible: Story = { render: AccessibleExample, }; +export const CustomShape: Story = { + render: CustomShapeExample, +}; +export const CustomColorAndAnimation: Story = { + render: CustomColorAndAnimationExample, +}; diff --git a/modules/react/loading-dots/stories/examples/Accessible.tsx b/modules/react/loading-dots/stories/examples/Accessible.tsx index 375138aa21..796324193f 100644 --- a/modules/react/loading-dots/stories/examples/Accessible.tsx +++ b/modules/react/loading-dots/stories/examples/Accessible.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {LoadingDots} from '@workday/canvas-kit-react/loading-dots'; -import {base, system} from '@workday/canvas-tokens-web'; +import {system} from '@workday/canvas-tokens-web'; import {Flex} from '@workday/canvas-kit-react/layout'; import {SecondaryButton} from '@workday/canvas-kit-react/button'; import {createStyles} from '@workday/canvas-kit-styling'; diff --git a/modules/react/loading-dots/stories/examples/CustomColorAndAnimation.tsx b/modules/react/loading-dots/stories/examples/CustomColorAndAnimation.tsx new file mode 100644 index 0000000000..488c36c9b1 --- /dev/null +++ b/modules/react/loading-dots/stories/examples/CustomColorAndAnimation.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import {LoadingDots} from '@workday/canvas-kit-react/loading-dots'; +import {system} from '@workday/canvas-tokens-web'; +import {createStyles} from '@workday/canvas-kit-styling'; + +const styleOverrides = { + parentContainer: createStyles({ + display: 'flex', + gap: system.space.x4, + }), +}; + +export const CustomColorAndAnimation = () => { + return ( +
+ +
+ ); +}; diff --git a/modules/react/loading-dots/stories/examples/CustomShape.tsx b/modules/react/loading-dots/stories/examples/CustomShape.tsx new file mode 100644 index 0000000000..cb40ea62a8 --- /dev/null +++ b/modules/react/loading-dots/stories/examples/CustomShape.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {LoadingDots, loadingDotsStencil} from '@workday/canvas-kit-react/loading-dots'; +import {system} from '@workday/canvas-tokens-web'; +import {createStyles, createStencil} from '@workday/canvas-kit-styling'; + +const styleOverrides = { + parentContainer: createStyles({ + display: 'flex', + gap: system.space.x4, + }), +}; + +const loadingStencil = createStencil({ + base: { + borderRadius: system.shape.round, + backgroundColor: system.color.bg.overlay, + height: 80, + width: 80, + alignItems: 'center', + justifyContent: 'center', + display: 'flex', + [loadingDotsStencil.vars.loadingDotColor]: system.color.icon.inverse, + }, +}); + +export const CustomShape = () => { + return ( +
+ +
+ ); +}; From b0122ce4912dbd26971c9fdeaa71ef307bed60c3 Mon Sep 17 00:00:00 2001 From: Manuel Carrera Date: Wed, 6 Nov 2024 11:04:04 -0700 Subject: [PATCH 2/6] feat(collection): Add vertical overflow support (#3035) Fixes: https://github.com/Workday/canvas-kit/issues/3024 [category:Components] Release Note: - Add vertical overflow support to `useOverflowListModel`. - We've deprecated `addItemWidth`, use `additemSize` instead. Add either the height or the width based on the orientation. - We've deprecated `setContainerWidth`, use `setContainerSize` to either set the height or the width of the element. - We've deprecated `setOverflowTargetWidth`, use `setOverflowTargetSize` instead. - We've deprecated `removeItemWidth`, use `removeItemSize` instead. Co-authored-by: manuel.carrera Co-authored-by: @NicholasBoll --- .../lib/useOverflowListItemMeasure.tsx | 8 +- .../collection/lib/useOverflowListMeasure.ts | 2 +- .../collection/lib/useOverflowListModel.tsx | 141 +++++++++++------- .../collection/lib/useOverflowListTarget.tsx | 6 +- .../collection/spec/useOverflowModel.spec.tsx | 48 +++--- .../collection/stories/mdx/Collection.mdx | 11 ++ .../mdx/examples/OverflowVerticalList.tsx | 73 +++++++++ 7 files changed, 210 insertions(+), 79 deletions(-) create mode 100644 modules/react/collection/stories/mdx/examples/OverflowVerticalList.tsx diff --git a/modules/react/collection/lib/useOverflowListItemMeasure.tsx b/modules/react/collection/lib/useOverflowListItemMeasure.tsx index 7da8fb8258..a1772ad17c 100644 --- a/modules/react/collection/lib/useOverflowListItemMeasure.tsx +++ b/modules/react/collection/lib/useOverflowListItemMeasure.tsx @@ -26,17 +26,21 @@ export const useOverflowListItemMeasure = createElemPropsHook(useOverflowListMod useMountLayout(() => { if (localRef.current) { const styles = getComputedStyle(localRef.current); - model.events.addItemWidth({ + model.events.addItemSize({ id: name, width: localRef.current.offsetWidth + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight), + height: + localRef.current.offsetHeight + + parseFloat(styles.marginTop) + + parseFloat(styles.marginBottom), }); } return () => { - model.events.removeItemWidth({id: name}); + model.events.removeItemSize({id: name}); }; }); diff --git a/modules/react/collection/lib/useOverflowListMeasure.ts b/modules/react/collection/lib/useOverflowListMeasure.ts index 4dbf953efc..f72948ab50 100644 --- a/modules/react/collection/lib/useOverflowListMeasure.ts +++ b/modules/react/collection/lib/useOverflowListMeasure.ts @@ -19,7 +19,7 @@ export const useOverflowListMeasure = createElemPropsHook(useOverflowListModel)( useResizeObserver({ ref: localRef, - onResize: model.events.setContainerWidth, + onResize: model.events.setContainerSize, }); useMountLayout(() => { if (localRef.current) { diff --git a/modules/react/collection/lib/useOverflowListModel.tsx b/modules/react/collection/lib/useOverflowListModel.tsx index 97e09cc304..b78637ecf0 100644 --- a/modules/react/collection/lib/useOverflowListModel.tsx +++ b/modules/react/collection/lib/useOverflowListModel.tsx @@ -5,17 +5,17 @@ import {useSelectionListModel} from './useSelectionListModel'; import {Item} from './useBaseListModel'; export function getHiddenIds( - containerWidth: number, + containerSize: number, containerGap: number, - overflowTargetWidth: number, - itemWidthCache: Record, + overflowTargetSize: number, + itemSizeCache: Record, selectedIds: string[] | 'all', items: Item[] ): string[] { /** Allows us to prioritize showing the selected item */ let selectedKey: undefined | string; /** Tally of combined item widths. We'll add items that fit until the container is full */ - let itemWidth = 0; + let itemSize = 0; /** Tally ids that won't fit inside the container. These will be used by components to hide * elements that won't fit in the container */ const hiddenIds: string[] = []; @@ -31,31 +31,31 @@ export function getHiddenIds( } if ( - Object.keys(itemWidthCache).reduce( - (sum, key, index) => sum + itemWidthCache[key] + (index > 0 ? containerGap : 0), + Object.keys(itemSizeCache).reduce( + (sum, key, index) => sum + itemSizeCache[key] + (index > 0 ? containerGap : 0), 0 - ) <= containerWidth + ) <= containerSize ) { // All items fit, return empty array return []; } else if (selectedKey) { - if (itemWidthCache[selectedKey] + overflowTargetWidth > containerWidth) { + if (itemSizeCache[selectedKey] + overflowTargetSize > containerSize) { // If the selected item doesn't fit, only show overflow (all items hidden) - return Object.keys(itemWidthCache); + return Object.keys(itemSizeCache); } else { // at least the selected item and overflow target fit. Update our itemWidth with the sum - itemWidth += itemWidthCache[selectedKey] + overflowTargetWidth; + itemSize += itemSizeCache[selectedKey] + overflowTargetSize; shouldAddGap = true; } } else { - itemWidth += overflowTargetWidth; + itemSize += overflowTargetSize; } - for (const key in itemWidthCache) { + for (const key in itemSizeCache) { if (key !== selectedKey) { - itemWidth += itemWidthCache[key] + (shouldAddGap ? containerGap : 0); + itemSize += itemSizeCache[key] + (shouldAddGap ? containerGap : 0); shouldAddGap = true; - if (itemWidth > containerWidth) { + if (itemSize > containerSize) { hiddenIds.push(key); } } @@ -81,13 +81,13 @@ export const useOverflowListModel = createModelHook({ const shouldCalculateOverflow = config.shouldCalculateOverflow === undefined ? true : config.shouldCalculateOverflow; const [hiddenIds, setHiddenIds] = React.useState(config.initialHiddenIds); - const [itemWidthCache, setItemWidthCache] = React.useState>({}); - const [containerWidth, setContainerWidth] = React.useState(0); + const [itemSizeCache, setItemSizeCache] = React.useState>({}); + const [containerSize, setContainerSize] = React.useState(0); const [containerGap, setContainerGap] = React.useState(0); - const containerWidthRef = React.useRef(0); - const itemWidthCacheRef = React.useRef(itemWidthCache); + const containerSizeRef = React.useRef(0); + const itemSizeCacheRef = React.useRef(itemSizeCache); const [overflowTargetWidth, setOverflowTargetWidth] = React.useState(0); - const overflowTargetWidthRef = React.useRef(0); + const overflowTargetSizeRef = React.useRef(0); const internalHiddenIds = shouldCalculateOverflow ? hiddenIds : []; @@ -103,8 +103,16 @@ export const useOverflowListModel = createModelHook({ const state = { ...model.state, hiddenIds: internalHiddenIds, - itemWidthCache, - containerWidth, + itemSizeCache, + /** + * @deprecated Use `itemSizeCache` instead + */ + itemWidthCache: itemSizeCache, + containerSize, + /** + * @deprecated Use `containerSize` instead + */ + containerWidth: containerSize, containerGap, overflowTargetWidth, }; @@ -114,10 +122,10 @@ export const useOverflowListModel = createModelHook({ select(data: Parameters[0]) { const {selectedIds} = model.selection.select(data.id, state); const ids = getHiddenIds( - containerWidthRef.current, + containerSizeRef.current, containerGap, - overflowTargetWidthRef.current, - itemWidthCacheRef.current, + overflowTargetSizeRef.current, + itemSizeCacheRef.current, selectedIds, config.items ); @@ -125,69 +133,93 @@ export const useOverflowListModel = createModelHook({ setHiddenIds(ids); }, - setContainerWidth(data: {width?: number}) { - containerWidthRef.current = data.width || 0; - setContainerWidth(data.width || 0); - + setContainerSize(data: {width?: number; height?: number}) { + containerSizeRef.current = + model.state.orientation === 'horizontal' ? data.width || 0 : data.height || 0; + setContainerSize(containerSizeRef.current); const ids = getHiddenIds( - containerWidthRef.current, + containerSizeRef.current, containerGap, - overflowTargetWidthRef.current, - itemWidthCacheRef.current, + overflowTargetSizeRef.current, + itemSizeCacheRef.current, state.selectedIds, config.items ); - setHiddenIds(ids); }, + /** + * @deprecated Use `setContainerSize` instead and pass both `width` and `height` + */ + setContainerWidth(data: {width?: number}) { + events.setContainerSize({width: data.width, height: 0}); + }, setContainerGap(data: {size: number}) { setContainerGap(data.size); const ids = getHiddenIds( - containerWidthRef.current, + containerSizeRef.current, data.size, - overflowTargetWidthRef.current, - itemWidthCacheRef.current, + overflowTargetSizeRef.current, + itemSizeCacheRef.current, state.selectedIds, config.items ); setHiddenIds(ids); }, + setOverflowTargetSize(data: {width: number; height: number}) { + overflowTargetSizeRef.current = + model.state.orientation === 'horizontal' ? data.width || 0 : data.height || 0; + setOverflowTargetWidth(overflowTargetSizeRef.current); + }, + + /** + * + * @deprecated `setOverflowTargetWidth` is deprecated. Please use `setOverflowTargetSize` and pass in the `width` and set `height` to `0`. + */ setOverflowTargetWidth(data: {width: number}) { - overflowTargetWidthRef.current = data.width; - setOverflowTargetWidth(data.width); + overflowTargetSizeRef.current = data.width; + events.setOverflowTargetSize({width: overflowTargetSizeRef.current, height: 0}); }, + + /** + * + * @deprecated `addItemWidth` is deprecated. Please use `addItemSize` and set the `width` + */ addItemWidth(data: {id: string; width: number}) { - itemWidthCacheRef.current = { - ...itemWidthCacheRef.current, - [data.id]: data.width, + events.addItemSize({id: data.id, width: data.width, height: 0}); + }, + addItemSize(data: {id: string; width: number; height: number}) { + itemSizeCacheRef.current = { + ...itemSizeCacheRef.current, + [data.id]: model.state.orientation === 'horizontal' ? data.width : data.height, }; - setItemWidthCache(itemWidthCacheRef.current); + + setItemSizeCache(itemSizeCacheRef.current); const ids = getHiddenIds( - containerWidthRef.current, + containerSizeRef.current, containerGap, - overflowTargetWidthRef.current, - itemWidthCacheRef.current, + overflowTargetSizeRef.current, + itemSizeCacheRef.current, state.selectedIds, config.items ); setHiddenIds(ids); }, - removeItemWidth(data: {id: string}) { - const newCache = {...itemWidthCacheRef.current}; + removeItemSize(data: {id: string}) { + const newCache = {...itemSizeCacheRef.current}; delete newCache[data.id]; - itemWidthCacheRef.current = newCache; - setItemWidthCache(itemWidthCacheRef.current); + itemSizeCacheRef.current = newCache; + setItemSizeCache(itemSizeCacheRef.current); const ids = getHiddenIds( - containerWidthRef.current, + containerSizeRef.current, containerGap, - overflowTargetWidthRef.current, - itemWidthCacheRef.current, + overflowTargetSizeRef.current, + itemSizeCacheRef.current, state.selectedIds !== 'all' ? state.selectedIds.filter(sId => data.id !== sId) : state.selectedIds, @@ -196,6 +228,13 @@ export const useOverflowListModel = createModelHook({ setHiddenIds(ids); }, + /** + * + * @deprecated `removeItemWidth` is deprecated. Please use `removeItemSize`. + */ + removeItemWidth(data: {id: string}) { + events.removeItemSize({id: data.id}); + }, addHiddenKey(data: {id: string}) { setHiddenIds(ids => ids.concat(data.id)); }, diff --git a/modules/react/collection/lib/useOverflowListTarget.tsx b/modules/react/collection/lib/useOverflowListTarget.tsx index 86dfacb81a..c3ccf969eb 100644 --- a/modules/react/collection/lib/useOverflowListTarget.tsx +++ b/modules/react/collection/lib/useOverflowListTarget.tsx @@ -24,11 +24,15 @@ export const useOverflowListTarget = createElemPropsHook(useOverflowListModel)(( if (localRef.current) { const styles = getComputedStyle(localRef.current); - model.events.setOverflowTargetWidth({ + model.events.setOverflowTargetSize({ width: localRef.current.offsetWidth + parseFloat(styles.marginLeft) + parseFloat(styles.marginRight), + height: + localRef.current.offsetWidth + + parseFloat(styles.marginTop) + + parseFloat(styles.marginBottom), }); } }); diff --git a/modules/react/collection/spec/useOverflowModel.spec.tsx b/modules/react/collection/spec/useOverflowModel.spec.tsx index 0bc2e41b0d..e90071b250 100644 --- a/modules/react/collection/spec/useOverflowModel.spec.tsx +++ b/modules/react/collection/spec/useOverflowModel.spec.tsx @@ -2,83 +2,83 @@ import {getHiddenIds} from '../lib/useOverflowListModel'; describe('useOverflowModel', () => { describe('getHiddenIds', () => { - const itemWidthCache = { + const itemSizeCache = { first: 100, second: 150, third: 200, fourth: 250, }; - const overflowTargetWidth = 100; + const overflowTargeSize = 100; [ { - containerWidth: 100, + containerSize: 100, gap: 0, selected: 'first', hiddenIds: ['first', 'second', 'third', 'fourth'], }, { - containerWidth: 199, + containerSize: 199, gap: 0, selected: 'first', hiddenIds: ['first', 'second', 'third', 'fourth'], }, { - containerWidth: 200, + containerSize: 200, gap: 0, selected: 'first', hiddenIds: ['second', 'third', 'fourth'], }, - {containerWidth: 700, gap: 0, selected: 'first', hiddenIds: []}, - {containerWidth: 350, gap: 0, selected: 'first', hiddenIds: ['third', 'fourth']}, - {containerWidth: 549, gap: 0, selected: 'first', hiddenIds: ['third', 'fourth']}, - {containerWidth: 550, gap: 0, selected: 'first', hiddenIds: ['fourth']}, + {containerSize: 700, gap: 0, selected: 'first', hiddenIds: []}, + {containerSize: 350, gap: 0, selected: 'first', hiddenIds: ['third', 'fourth']}, + {containerSize: 549, gap: 0, selected: 'first', hiddenIds: ['third', 'fourth']}, + {containerSize: 550, gap: 0, selected: 'first', hiddenIds: ['fourth']}, { - containerWidth: 250, + containerSize: 250, gap: 0, selected: 'second', hiddenIds: ['first', 'third', 'fourth'], }, // gap { - containerWidth: 100, + containerSize: 100, gap: 10, selected: 'first', hiddenIds: ['first', 'second', 'third', 'fourth'], }, { - containerWidth: 199, + containerSize: 199, gap: 10, selected: 'first', hiddenIds: ['first', 'second', 'third', 'fourth'], }, { - containerWidth: 200, + containerSize: 200, gap: 10, selected: 'first', hiddenIds: ['second', 'third', 'fourth'], }, - {containerWidth: 729, gap: 10, selected: 'first', hiddenIds: ['fourth']}, - {containerWidth: 730, gap: 10, selected: 'first', hiddenIds: []}, - {containerWidth: 360, gap: 10, selected: 'first', hiddenIds: ['third', 'fourth']}, - {containerWidth: 559, gap: 10, selected: 'first', hiddenIds: ['third', 'fourth']}, - {containerWidth: 570, gap: 10, selected: 'first', hiddenIds: ['fourth']}, + {containerSize: 729, gap: 10, selected: 'first', hiddenIds: ['fourth']}, + {containerSize: 730, gap: 10, selected: 'first', hiddenIds: []}, + {containerSize: 360, gap: 10, selected: 'first', hiddenIds: ['third', 'fourth']}, + {containerSize: 559, gap: 10, selected: 'first', hiddenIds: ['third', 'fourth']}, + {containerSize: 570, gap: 10, selected: 'first', hiddenIds: ['fourth']}, { - containerWidth: 250, + containerSize: 250, gap: 10, selected: 'second', hiddenIds: ['first', 'third', 'fourth'], }, - ].forEach(({containerWidth, hiddenIds, gap, selected}) => { - it(`when containerWidth is ${containerWidth} and selected is '${selected}' should contain hiddenIds [${hiddenIds.join( + ].forEach(({containerSize, hiddenIds, gap, selected}) => { + it(`when containerSize is ${containerSize} and selected is '${selected}' should contain hiddenIds [${hiddenIds.join( ', ' )}] `, () => { expect( getHiddenIds( - containerWidth, + containerSize, gap, - overflowTargetWidth, - itemWidthCache, + overflowTargeSize, + itemSizeCache, [selected], [ {id: 'first', value: 'first', index: 0, textValue: 'first'}, diff --git a/modules/react/collection/stories/mdx/Collection.mdx b/modules/react/collection/stories/mdx/Collection.mdx index 03fa4d83c9..5d5bee0e93 100644 --- a/modules/react/collection/stories/mdx/Collection.mdx +++ b/modules/react/collection/stories/mdx/Collection.mdx @@ -13,6 +13,7 @@ import {Selection} from './examples/Selection'; import {MultiSelection} from './examples/MultiSelection'; import {BasicGrid} from './examples/BasicGrid'; import {WrappingGrid} from './examples/WrappingGrid'; +import {OverflowVerticalList} from './examples/OverflowVerticalList'; @@ -168,6 +169,16 @@ cursor wraps around columns and rows when an edge of a column or row is encounte +### Overflow Vertical List + +A List can overflow vertically or horizontally to account for responsive resizing or an overflow of +items. Using multiple hooks from the Collection system like `useOverflowListModel` and ensuring that +`orientation`is set to`vertical`, you can achieve vertical overflow lists. In the example below, +when the window is resized vertically, items in the Sidebar will overflow into the "More Actions" +button. + + + ## Component API ### ListBox diff --git a/modules/react/collection/stories/mdx/examples/OverflowVerticalList.tsx b/modules/react/collection/stories/mdx/examples/OverflowVerticalList.tsx new file mode 100644 index 0000000000..f95fa08a7a --- /dev/null +++ b/modules/react/collection/stories/mdx/examples/OverflowVerticalList.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import {ActionBar, useActionBarModel} from '@workday/canvas-kit-react/action-bar'; +import {PrimaryButton} from '@workday/canvas-kit-react/button'; +import {Box} from '@workday/canvas-kit-react/layout'; +import styled from '@emotion/styled'; +import {StyledType} from '@workday/canvas-kit-react/common'; + +type MyActionItem = { + id: string; + text: React.ReactNode; +}; + +const StyledActionbarList = styled(ActionBar.List)({ + '> *': { + flex: '0 0 auto', + }, +}); + +export const OverflowVerticalList = () => { + const [items] = React.useState([ + {id: 'first', text: 'First Action'}, + {id: 'second', text: 'Second Action'}, + {id: 'third', text: 'Third Action'}, + {id: 'fourth', text: 'Fourth Action'}, + {id: 'fifth', text: 'Fifth Action'}, + {id: 'sixth', text: 'Sixth Action'}, + {id: 'seventh', text: 'Seventh Action'}, + ]); + + const model = useActionBarModel({items, orientation: 'vertical', maximumVisible: 4}); + + return ( + <> + + + + } + > + {(item: MyActionItem, index) => ( + console.log(item.id)} + > + {item.text} + + )} + + + + + {(item: MyActionItem) => ( + console.log(item.id)}> + {item.text} + + )} + + + + + + + ); +}; From cbfbb06dd0be8cd5feca6ee2114cd2b039da66db Mon Sep 17 00:00:00 2001 From: Nicholas Boll Date: Wed, 6 Nov 2024 12:00:02 -0700 Subject: [PATCH 3/6] feat(collection): Add removable support (#3036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #3025 Adds `remove` event to the `ListModel`. An `onRemove` config should be added to dynamic lists to remove the item from the collection. The `MultiSelect.Input` uses this new remove event to handle removing items from the Selected pill list when the user uses the “Delete” key. Focus is managed by the collection system when an item is removed. [category:Components] --- cypress/component/Tabs.spec.tsx | 19 +-- .../multi-select/lib/MultiSelectInput.tsx | 113 +++++------------- .../multi-select/lib/MultiSelectedItem.tsx | 46 +++++++ .../multi-select/lib/MultiSelectedList.tsx | 30 +++++ .../lib/useMultiSelectItemRemove.ts | 48 ++++++++ .../multi-select/lib/useMultiSelectModel.ts | 25 +++- .../multi-select/stories/examples/Basic.tsx | 8 +- .../multi-select/stories/examples/Complex.tsx | 1 + .../stories/examples/Controlled.tsx | 1 + .../multi-select/stories/examples/Icons.tsx | 6 +- .../stories/examples/Searching.tsx | 1 + modules/react/collection/index.ts | 3 + .../collection/lib/focusOnCurrentCursor.ts | 55 +++++++++ .../react/collection/lib/listItemRemove.ts | 21 ++++ .../lib/useListItemRemoveOnDeleteKey.tsx | 40 +++++++ .../collection/lib/useListItemRovingFocus.tsx | 42 ++----- modules/react/collection/lib/useListLoader.ts | 2 + .../lib/useListResetCursorOnBlur.tsx | 18 ++- .../collection/lib/useSelectionListModel.tsx | 10 ++ .../combobox/lib/hooks/useComboboxInput.ts | 17 ++- .../select/stories/examples/Controlled.tsx | 7 +- 21 files changed, 372 insertions(+), 141 deletions(-) create mode 100644 modules/preview-react/multi-select/lib/MultiSelectedItem.tsx create mode 100644 modules/preview-react/multi-select/lib/MultiSelectedList.tsx create mode 100644 modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts create mode 100644 modules/react/collection/lib/focusOnCurrentCursor.ts create mode 100644 modules/react/collection/lib/listItemRemove.ts create mode 100644 modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx diff --git a/cypress/component/Tabs.spec.tsx b/cypress/component/Tabs.spec.tsx index 027a23761d..1a83048366 100644 --- a/cypress/component/Tabs.spec.tsx +++ b/cypress/component/Tabs.spec.tsx @@ -75,7 +75,7 @@ describe('Tabs', () => { context('when the tab key is pressed', () => { beforeEach(() => { - cy.tab(); + cy.realPress('Tab'); }); it('should move focus to the tabpanel', () => { @@ -148,7 +148,7 @@ describe('Tabs', () => { context('when the tab key is pressed', () => { beforeEach(() => { - cy.tab(); + cy.realPress('Tab'); }); it('should focus on the tab panel of the first tab', () => { @@ -158,7 +158,9 @@ describe('Tabs', () => { // verify the original intent is no longer a tab stop context('when shift + tab keys are pressed', () => { beforeEach(() => { - cy.tab({shift: true}); + // wait for tabindex to reset + cy.findByRole('tab', {name: 'First Tab'}).should('not.have.attr', 'tabindex', '-1'); + cy.realPress(['Shift', 'Tab']); }); it('should not have tabindex=-1 on the first tab', () => { @@ -248,7 +250,7 @@ describe('Tabs', () => { context('when the first tab is active and focused', () => { beforeEach(() => { - cy.findByRole('tab', {name: 'First Tab'}).click().focus(); + cy.findByRole('tab', {name: 'First Tab'}).click(); }); context('when the right arrow key is pressed', () => { @@ -416,7 +418,7 @@ describe('Tabs', () => { context('when the tab key is pressed', () => { beforeEach(() => { - cy.tab(); + cy.realPress('Tab'); }); it('should move focus to the tabpanel', () => { @@ -547,12 +549,15 @@ describe('Tabs', () => { context('when the "First Tab" is focused', () => { beforeEach(() => { - cy.findByRole('tab', {name: 'First Tab'}).focus().tab(); + cy.findByRole('tab', {name: 'First Tab'}).focus(); }); context('when the Tab key is pressed', () => { + beforeEach(() => { + cy.realPress('Tab'); + }); + it('should focus on the "More" button', () => { - cy.findByRole('button', {name: 'More'}).focus(); cy.findByRole('button', {name: 'More'}).should('have.focus'); }); }); diff --git a/modules/preview-react/multi-select/lib/MultiSelectInput.tsx b/modules/preview-react/multi-select/lib/MultiSelectInput.tsx index 03eca34017..f3ca49d0e4 100644 --- a/modules/preview-react/multi-select/lib/MultiSelectInput.tsx +++ b/modules/preview-react/multi-select/lib/MultiSelectInput.tsx @@ -11,16 +11,11 @@ import { import {createStencil, CSProps, handleCsProp} from '@workday/canvas-kit-styling'; import {InputGroup, TextInput} from '@workday/canvas-kit-react/text-input'; import {SystemIcon} from '@workday/canvas-kit-react/icon'; -import { - ListBox, - useListItemRegister, - useListItemRovingFocus, - useListModel, -} from '@workday/canvas-kit-react/collection'; import {useComboboxInput, useComboboxInputConstrained} from '@workday/canvas-kit-react/combobox'; -import {Pill} from '@workday/canvas-kit-preview-react/pill'; import {useMultiSelectModel} from './useMultiSelectModel'; +import {MultiSelectedItemProps} from './MultiSelectedItem'; +import {MultiSelectedList} from './MultiSelectedList'; export const multiSelectStencil = createStencil({ base: { @@ -121,61 +116,28 @@ export const useMultiSelectInput = composeHooks( useComboboxInput ); -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; - if (model.state.cursorId === id) { - // We're removing the currently focused item. Focus next item - model.events.goTo({id: nextId}); - } -}; - -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) => { - return ( - - {children} - - - ); -}); - export interface MultiSelectInputProps extends CSProps, Pick< React.InputHTMLAttributes, 'disabled' | 'className' | 'style' | 'aria-labelledby' - > {} + >, + Pick {} export const MultiSelectInput = createSubcomponent(TextInput)({ modelHook: useMultiSelectModel, elemPropsHook: useMultiSelectInput, })( ( - {className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ...elemProps}, + { + className, + cs, + style, + 'aria-labelledby': ariaLabelledBy, + removeLabel, + formInputProps, + ...elemProps + }, Element, model ) => { @@ -194,20 +156,7 @@ export const MultiSelectInput = createSubcomponent(TextInput)({ - {model.selected.state.items.length ? ( - <> -
- - {item => {item.textValue}} - - - ) : null} +
); } @@ -218,7 +167,16 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({ elemPropsHook: useMultiSelectInput, })( ( - {className, cs, style, 'aria-labelledby': ariaLabelledBy, formInputProps, ref, ...elemProps}, + { + className, + cs, + style, + 'aria-labelledby': ariaLabelledBy, + removeLabel, + formInputProps, + ref, + ...elemProps + }, Element, model ) => { @@ -228,34 +186,25 @@ export const MultiSelectSearchInput = createSubcomponent(TextInput)({ - + - + - {model.selected.state.items.length ? ( - <> -
- - {item => {item.textValue}} - - - ) : null} +
); } diff --git a/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx b/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx new file mode 100644 index 0000000000..9e23587392 --- /dev/null +++ b/modules/preview-react/multi-select/lib/MultiSelectedItem.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import { + composeHooks, + createElemPropsHook, + createSubModelElemPropsHook, + createSubcomponent, +} from '@workday/canvas-kit-react/common'; +import {useListItemRegister, useListItemRovingFocus} from '@workday/canvas-kit-react/collection'; +import {Pill} from '@workday/canvas-kit-preview-react/pill'; + +import {useMultiSelectItemRemove} from './useMultiSelectItemRemove'; +import {useMultiSelectModel} from './useMultiSelectModel'; + +export interface MultiSelectedItemProps { + /** + * Remove label on a MultiSelectedItem. In English, the label may be "Remove" and the screen + * reader will read out "Remove {option}". + * + * @default "remove" + */ + removeLabel?: string; +} + +export const useMultiSelectedItem = composeHooks( + createElemPropsHook(useMultiSelectModel)(model => { + return { + 'aria-selected': true, + }; + }), + useMultiSelectItemRemove, + createSubModelElemPropsHook(useMultiSelectModel)(m => m.selected, useListItemRovingFocus), + createSubModelElemPropsHook(useMultiSelectModel)(m => m.selected, useListItemRegister) +); + +export const MultiSelectedItem = createSubcomponent('span')({ + modelHook: useMultiSelectModel, + elemPropsHook: useMultiSelectedItem, +})(({children, removeLabel, ref, ...elemProps}, Element) => { + return ( + + {children} + + + ); +}); diff --git a/modules/preview-react/multi-select/lib/MultiSelectedList.tsx b/modules/preview-react/multi-select/lib/MultiSelectedList.tsx new file mode 100644 index 0000000000..671a9092d8 --- /dev/null +++ b/modules/preview-react/multi-select/lib/MultiSelectedList.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import {createSubcomponent} from '@workday/canvas-kit-react/common'; +import {ListBox} from '@workday/canvas-kit-react/collection'; + +import {useMultiSelectModel} from './useMultiSelectModel'; +import {MultiSelectedItem, MultiSelectedItemProps} from './MultiSelectedItem'; + +export interface MultiSelectedListProps + extends MultiSelectedItemProps, + React.HTMLAttributes {} + +export const MultiSelectedList = createSubcomponent()({ + modelHook: useMultiSelectModel, +})(({'aria-labelledby': ariaLabelledBy, removeLabel}, Element, model) => { + return model.selected.state.items.length ? ( + <> +
+ + {item => {item.textValue}} + + + ) : null; +}); diff --git a/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts b/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts new file mode 100644 index 0000000000..1c183d6224 --- /dev/null +++ b/modules/preview-react/multi-select/lib/useMultiSelectItemRemove.ts @@ -0,0 +1,48 @@ +import React from 'react'; +import {createElemPropsHook} from '@workday/canvas-kit-react/common'; + +import {useMultiSelectModel} from './useMultiSelectModel'; +import {focusOnCurrentCursor, listItemRemove} from '@workday/canvas-kit-react/collection'; + +/** + * 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 useMultiSelectItemRemove = createElemPropsHook(useMultiSelectModel)((model, _ref) => { + return { + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Backspace' || event.key === 'Delete') { + const id = event.currentTarget.dataset.id || ''; + const nextId = listItemRemove(id, model.selected); + model.selected.events.remove({id, event}); + if (nextId) { + focusOnCurrentCursor(model.selected, nextId, event.currentTarget); + } else { + model.state.inputRef.current?.focus(); + } + } + }, + onClick(event: React.MouseEvent) { + const id = event.currentTarget.dataset.id || ''; + const nextId = listItemRemove(id, model.selected); + model.selected.events.remove({id, nextId, event}); + if (nextId) { + focusOnCurrentCursor(model.selected, nextId, event.currentTarget); + } else { + model.state.inputRef.current?.focus(); + } + }, + }; +}); diff --git a/modules/preview-react/multi-select/lib/useMultiSelectModel.ts b/modules/preview-react/multi-select/lib/useMultiSelectModel.ts index f96377619f..dc4a369028 100644 --- a/modules/preview-react/multi-select/lib/useMultiSelectModel.ts +++ b/modules/preview-react/multi-select/lib/useMultiSelectModel.ts @@ -34,6 +34,10 @@ export const useMultiSelectModel = createModelHook({ useComboboxModel.mergeConfig(config, { onHide() { setSelectedItems(cachedSelected); + model.events.goTo({id: ''}); + }, + onFilterChange() { + model.events.goTo({id: ''}); }, }) ); @@ -60,7 +64,7 @@ export const useMultiSelectModel = createModelHook({ // The `listbox` of pills under the MultiSelect combobox input. const selected = useListModel({ orientation: 'horizontal', - onSelect({id}) { + onRemove({id}) { model.events.select({id}); }, shouldVirtualize: false, @@ -75,5 +79,22 @@ export const useMultiSelectModel = createModelHook({ ...model.events, }; - return {selected, ...model, state, events}; + return { + selected: { + ...selected, + state: { + ...selected.state, + cursorId: React.useMemo( + () => + selected.state.items.find(item => item.id === selected.state.cursorId) + ? selected.state.cursorId + : selected.state.items[0]?.id || '', + [selected.state.items, selected.state.cursorId] + ), + }, + }, + ...model, + state, + events, + }; }); diff --git a/modules/preview-react/multi-select/stories/examples/Basic.tsx b/modules/preview-react/multi-select/stories/examples/Basic.tsx index 01267e597b..1fe783607f 100644 --- a/modules/preview-react/multi-select/stories/examples/Basic.tsx +++ b/modules/preview-react/multi-select/stories/examples/Basic.tsx @@ -8,10 +8,14 @@ const items = ['Cheese', 'Olives', 'Onions', 'Pepperoni', 'Peppers']; export const Basic = () => { return ( <> - + Toppings - + diff --git a/modules/preview-react/multi-select/stories/examples/Complex.tsx b/modules/preview-react/multi-select/stories/examples/Complex.tsx index a247532c8b..04d34f3844 100644 --- a/modules/preview-react/multi-select/stories/examples/Complex.tsx +++ b/modules/preview-react/multi-select/stories/examples/Complex.tsx @@ -39,6 +39,7 @@ export const Complex = () => { { const value = e.currentTarget.value; diff --git a/modules/preview-react/multi-select/stories/examples/Controlled.tsx b/modules/preview-react/multi-select/stories/examples/Controlled.tsx index ae03e4886f..5aa4bfdbe6 100644 --- a/modules/preview-react/multi-select/stories/examples/Controlled.tsx +++ b/modules/preview-react/multi-select/stories/examples/Controlled.tsx @@ -46,6 +46,7 @@ export const Controlled = () => { { Controls - + diff --git a/modules/preview-react/multi-select/stories/examples/Searching.tsx b/modules/preview-react/multi-select/stories/examples/Searching.tsx index 7d08f00652..8034ad1362 100644 --- a/modules/preview-react/multi-select/stories/examples/Searching.tsx +++ b/modules/preview-react/multi-select/stories/examples/Searching.tsx @@ -88,6 +88,7 @@ export const Searching = () => { { setValue(e.currentTarget.value); diff --git a/modules/react/collection/index.ts b/modules/react/collection/index.ts index 77681d1026..f390fcb936 100644 --- a/modules/react/collection/index.ts +++ b/modules/react/collection/index.ts @@ -15,6 +15,9 @@ export * from './lib/useGridModel'; export * from './lib/useListActiveDescendant'; export * from './lib/useListItemActiveDescendant'; export * from './lib/useListItemAllowChildStrings'; +export * from './lib/useListItemRemoveOnDeleteKey'; +export * from './lib/focusOnCurrentCursor'; +export * from './lib/listItemRemove'; export {ListBox, ListBoxProps} from './lib/ListBox'; export {keyboardEventToCursorEvents} from './lib/keyUtils'; export { diff --git a/modules/react/collection/lib/focusOnCurrentCursor.ts b/modules/react/collection/lib/focusOnCurrentCursor.ts new file mode 100644 index 0000000000..f6b51abae4 --- /dev/null +++ b/modules/react/collection/lib/focusOnCurrentCursor.ts @@ -0,0 +1,55 @@ +import {useCursorListModel} from './useCursorListModel'; + +// 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, reject?: (reason?: any) => void) => { + if (cb() === false && iterations > 1) { + requestAnimationFrame(() => retryEachFrame(cb, iterations - 1)); + } + reject?.('Retry timeout'); +}; + +export const focusOnCurrentCursor = ( + model: ReturnType, + nextId: string, + /** + * This can be any element in the list. It is used only to get the client-id from the element in + * case it is different than the server ID when DOM is hydrated. + */ + element?: HTMLElement +) => { + return new Promise((resolve, reject) => { + // 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 = (element?.dataset?.focusId || '').split('-')[0] || model.state.id; + + const item = model.navigation.getItem(nextId, model); + + if (item) { + // If the list is virtualized, we need to manually call out to the virtual list's + // `scrollToIndex` + if (model.state.isVirtualized) { + model.state.UNSTABLE_virtual.scrollToIndex(item.index); + } + + const getElement = (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( + () => { + const element = getElement(clientId) || getElement(model.state.id); + + if (element) { + element.focus(); + resolve(element); + } + return !!element; + }, + 5, + reject + ); // 5 should be enough, right?! + } + }); +}; diff --git a/modules/react/collection/lib/listItemRemove.ts b/modules/react/collection/lib/listItemRemove.ts new file mode 100644 index 0000000000..dac25170cf --- /dev/null +++ b/modules/react/collection/lib/listItemRemove.ts @@ -0,0 +1,21 @@ +import {useSelectionListModel} from './useSelectionListModel'; + +export const listItemRemove = ( + id: string, + model: ReturnType +): string | undefined => { + // bail early if an ID isn't available + if (!id) { + return; + } + + 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; + if (nextId && model.state.cursorId === id) { + // We're removing the currently focused item. Focus next item + model.events.goTo({id: nextId}); + } + + return nextId; +}; diff --git a/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx b/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx new file mode 100644 index 0000000000..1671ac23ed --- /dev/null +++ b/modules/react/collection/lib/useListItemRemoveOnDeleteKey.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import {createElemPropsHook} from '@workday/canvas-kit-react/common'; + +import {useSelectionListModel} from './useSelectionListModel'; +import {focusOnCurrentCursor} from './focusOnCurrentCursor'; +import {listItemRemove} from './listItemRemove'; + +/** + * 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 useListItemRemoveOnDeleteKey = createElemPropsHook(useSelectionListModel)(model => { + return { + onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Backspace' || event.key === 'Delete') { + const id = event.currentTarget.dataset.id || ''; + const nextId = listItemRemove(id, model); + model.events.remove({id, nextId, event}); + if (nextId) { + // use an animation frame to wait for any other model changes that may happen + requestAnimationFrame(() => { + focusOnCurrentCursor(model, nextId, event.currentTarget); + }); + } + } + }, + }; +}); diff --git a/modules/react/collection/lib/useListItemRovingFocus.tsx b/modules/react/collection/lib/useListItemRovingFocus.tsx index c7eebd0695..36894a477e 100644 --- a/modules/react/collection/lib/useListItemRovingFocus.tsx +++ b/modules/react/collection/lib/useListItemRovingFocus.tsx @@ -3,13 +3,7 @@ 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)); - } -}; +import {focusOnCurrentCursor} from './focusOnCurrentCursor'; /** * This elemProps hook is used for cursor navigation by using [Roving @@ -33,36 +27,16 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)( const stateRef = React.useRef(model.state); stateRef.current = model.state; - const keyElementRef = React.useRef(null); + const keyElementRef = React.useRef(null); const isRTL = useIsRTL(); React.useEffect(() => { + // If the cursor change was triggered by this hook, we should change focus if (keyElementRef.current) { - const item = model.navigation.getItem(model.state.cursorId, model); - if (item) { - 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?! - } + focusOnCurrentCursor(model, model.state.cursorId, keyElementRef.current).then(() => { + // Reset key element since focus was successful + keyElementRef.current = null; + }); } // we only want to run this effect if the cursor changes and not any other time // eslint-disable-next-line react-hooks/exhaustive-deps @@ -76,7 +50,7 @@ export const useListItemRovingFocus = createElemPropsHook(useCursorListModel)( }, [model.state.cursorId, model.state.items, model.events]); return { - onKeyDown(event: React.KeyboardEvent) { + onKeyDown(event: React.KeyboardEvent) { const handled = keyboardEventToCursorEvents(event, model, isRTL); if (handled) { event.preventDefault(); diff --git a/modules/react/collection/lib/useListLoader.ts b/modules/react/collection/lib/useListLoader.ts index a64589f16f..b85d77b52f 100644 --- a/modules/react/collection/lib/useListLoader.ts +++ b/modules/react/collection/lib/useListLoader.ts @@ -269,6 +269,8 @@ export function useListLoader< const model = modelHook( modelHook.mergeConfig(config, { + // Loaders should virtualize by default. If they do not, it is an infinite scroll list + shouldVirtualize: true, items, shouldGoToNext: shouldLoadIndex('getNext', 'goToNext'), shouldGoToPrevious: shouldLoadIndex('getPrevious', 'goToPrevious'), diff --git a/modules/react/collection/lib/useListResetCursorOnBlur.tsx b/modules/react/collection/lib/useListResetCursorOnBlur.tsx index 4de675189d..7b32650e89 100644 --- a/modules/react/collection/lib/useListResetCursorOnBlur.tsx +++ b/modules/react/collection/lib/useListResetCursorOnBlur.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {createElemPropsHook} from '@workday/canvas-kit-react/common'; +import {createElemPropsHook, useMountLayout} from '@workday/canvas-kit-react/common'; import {orientationKeyMap} from './keyUtils'; import {useListModel} from './useListModel'; @@ -20,6 +20,15 @@ import {useListModel} from './useListModel'; */ export const useListResetCursorOnBlur = createElemPropsHook(useListModel)(({state, events}) => { const programmaticFocusRef = React.useRef(false); + const requestAnimationFrameRef = React.useRef(0); + + useMountLayout(() => { + return () => { + // Cancelling the animation frame prevents React unmount errors + cancelAnimationFrame(requestAnimationFrameRef.current); + }; + }); + return { onKeyDown(event: React.KeyboardEvent) { // Programmatic focus only on any focus change via keyboard @@ -32,7 +41,12 @@ export const useListResetCursorOnBlur = createElemPropsHook(useListModel)(({stat }, onBlur() { if (!programmaticFocusRef.current) { - events.goTo({id: state.selectedIds[0]}); + // use an animation frame to wait for any other model changes that may happen on a blur + requestAnimationFrameRef.current = requestAnimationFrame(() => { + if (state.selectedIds[0] !== state.cursorId) { + events.goTo({id: state.selectedIds[0]}); + } + }); } }, }; diff --git a/modules/react/collection/lib/useSelectionListModel.tsx b/modules/react/collection/lib/useSelectionListModel.tsx index 6363860bb9..259e35832a 100644 --- a/modules/react/collection/lib/useSelectionListModel.tsx +++ b/modules/react/collection/lib/useSelectionListModel.tsx @@ -109,6 +109,16 @@ export const useSelectionListModel = createModelHook({ setSelectedIds(ids: 'all' | string[]) { setSelectedIds(ids); }, + /** + * The `remove` event can be called by Behavior Hooks based on user interaction. The `onRemove` + * can be added to the model config to signal the user wishes to remove the item in the list. + * The `remove` event requires the dynamic API where `items` are passed to the model. It is up + * to you to remove the item from the list. Focus redirection should be automatically managed, + * if necessary. + */ + remove(data: {id: string; nextId?: string; event?: Event | React.SyntheticEvent}) { + // nothing to do here. It is a signal event + }, }; return {...cursor, state, events, selection}; diff --git a/modules/react/combobox/lib/hooks/useComboboxInput.ts b/modules/react/combobox/lib/hooks/useComboboxInput.ts index 8fe6b4303d..9702bf21a2 100644 --- a/modules/react/combobox/lib/hooks/useComboboxInput.ts +++ b/modules/react/combobox/lib/hooks/useComboboxInput.ts @@ -26,16 +26,13 @@ export const useComboboxInput = composeHooks( if (model.state.isVirtualized && item) { model.state.UNSTABLE_virtual.scrollToIndex(item.index); } else { - const listboxId = model.state.inputRef.current?.getAttribute('aria-controls'); - if (listboxId) { - const menuItem = document.querySelector( - `[id="${listboxId}"] [data-id="${model.state.cursorId}"]` - ); - if (menuItem) { - requestAnimationFrame(() => { - menuItem.scrollIntoView({block: 'nearest'}); - }); - } + const menuItem = document.querySelector( + `[id="${model.state.id}-list"] [data-id="${model.state.cursorId}"]` + ); + if (menuItem) { + requestAnimationFrame(() => { + menuItem.scrollIntoView({block: 'nearest'}); + }); } } } diff --git a/modules/react/select/stories/examples/Controlled.tsx b/modules/react/select/stories/examples/Controlled.tsx index f170db78d4..c6984ad29f 100644 --- a/modules/react/select/stories/examples/Controlled.tsx +++ b/modules/react/select/stories/examples/Controlled.tsx @@ -36,7 +36,12 @@ export const Controlled = () => { Contact