From b0122ce4912dbd26971c9fdeaa71ef307bed60c3 Mon Sep 17 00:00:00 2001 From: Manuel Carrera Date: Wed, 6 Nov 2024 11:04:04 -0700 Subject: [PATCH] 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} + + )} + + + + + + + ); +};