From 85ccd4db8928a1177784b2f3851805f34031d4a7 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 10 Oct 2025 11:25:43 -0400 Subject: [PATCH 01/12] Add component --- .../ActionBar/ActionBar.examples.stories.tsx | 25 ++++ packages/react/src/ActionBar/ActionBar.tsx | 132 ++++++++++++++++-- packages/react/src/ActionBar/index.ts | 3 +- 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx index d7c18e79123..e8fdc884034 100644 --- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx @@ -28,6 +28,31 @@ export default { title: 'Experimental/Components/ActionBar/Examples', } as Meta +export const Groups = () => ( + + + <> + + + + + + + + + + + + + + + + + + + +) + export const TextLabels = () => ( diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index eacec560051..0ce2c64a458 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -26,8 +26,11 @@ type ChildProps = icon: ActionBarIconButtonProps['icon'] onClick: MouseEventHandler width: number + groupId?: string + groupLabel?: string } | {type: 'divider'; width: number} + | {type: 'group'; width: number; label: string} /** * Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are @@ -38,9 +41,18 @@ type ChildRegistry = ReadonlyMap const ActionBarContext = React.createContext<{ size: Size registerChild: (id: string, props: ChildProps) => void - unregisterChild: (id: string) => void + unregisterChild: (id: string, groupId?: string) => void isVisibleChild: (id: string) => boolean -}>({size: 'medium', registerChild: () => {}, unregisterChild: () => {}, isVisibleChild: () => true}) + groupId?: string + groupLabel?: string +}>({ + size: 'medium', + registerChild: () => {}, + unregisterChild: () => {}, + isVisibleChild: () => true, + groupId: undefined, + groupLabel: undefined, +}) /* small (28px), medium (32px), large (40px) @@ -107,7 +119,10 @@ const getMenuItems = ( childRegistry: ChildRegistry, hasActiveMenu: boolean, ): Set | void => { - const registryEntries = Array.from(childRegistry).filter((entry): entry is [string, ChildProps] => entry[1] !== null) + const registryEntries = Array.from(childRegistry).filter( + (entry): entry is [string, ChildProps] => + entry[1] !== null && (entry[1].type !== 'action' || entry[1].groupId === undefined), + ) if (registryEntries.length === 0) return new Set() const numberOfItemsPossible = calculatePossibleItems(registryEntries, navWidth) @@ -155,11 +170,17 @@ export const ActionBar: React.FC> = prop const [childRegistry, setChildRegistry] = useState(() => new Map()) - const registerChild = useCallback( - (id: string, childProps: ChildProps) => setChildRegistry(prev => new Map(prev).set(id, childProps)), - [], - ) - const unregisterChild = useCallback((id: string) => setChildRegistry(prev => new Map(prev).set(id, null)), []) + const registerChild = useCallback((id: string, childProps: ChildProps) => { + setChildRegistry(prev => { + return new Map(prev).set(id, childProps) + }) + }, []) + + const unregisterChild = useCallback((id: string) => { + setChildRegistry(prev => { + return new Map(prev).set(id, null) + }) + }, []) const [menuItemIds, setMenuItemIds] = useState>(() => new Set()) @@ -234,11 +255,11 @@ export const ActionBar: React.FC> = prop if (menuItem.type === 'divider') { return - } else { + } else if (menuItem.type === 'action' && !menuItem.groupLabel) { const {onClick, icon: Icon, label, disabled} = menuItem return ( ) => { closeOverlay() @@ -254,6 +275,49 @@ export const ActionBar: React.FC> = prop ) } + + const groupedItems = Array.from(childRegistry).filter(([, childProps]) => { + if (childProps?.type !== 'action') return false + if (childProps.groupId !== id) return false + return true + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (menuItem.type === 'group') { + return ( + + + {menuItem.label} + + + + {groupedItems.map(([key, childProps]) => { + if (childProps && childProps.type === 'action') { + const {onClick, icon: Icon, label, disabled} = childProps + return ( + { + closeOverlay() + focusOnMoreMenuBtn() + typeof onClick === 'function' && onClick(event as React.MouseEvent) + }} + disabled={disabled} + > + + + + {label} + + ) + } + return null + })} + + + + ) + } })} @@ -272,6 +336,7 @@ export const ActionBarIconButton = forwardRef( const id = useId() const {size, registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext) + const {groupId} = React.useContext(ActionBarGroupContext) // Storing the width in a ref ensures we don't forget about it when not visible const widthRef = useRef() @@ -288,9 +353,12 @@ export const ActionBarIconButton = forwardRef( disabled: !!disabled, onClick: onClick as MouseEventHandler, width: widthRef.current, + groupId: groupId ?? undefined, // todo: remove conditional }) - return () => unregisterChild(id) + return () => { + unregisterChild(id) + } }, [registerChild, unregisterChild, props['aria-label'], props.icon, disabled, onClick]) const clickHandler = useCallback( @@ -301,7 +369,7 @@ export const ActionBarIconButton = forwardRef( [disabled, onClick], ) - if (!isVisibleChild(id)) return null + if (!isVisibleChild(id) || (groupId && !isVisibleChild(groupId))) return null return ( ) }, ) +const ActionBarGroupContext = React.createContext<{ + groupId: string | null + label: string | undefined +}>({groupId: null, label: undefined}) + +type ActionBarGroupProps = { + label: string +} + +export const ActionBarGroup = forwardRef( + ({label, children}: React.PropsWithChildren, forwardedRef) => { + const backupRef = useRef(null) + const ref = (forwardedRef ?? backupRef) as RefObject + const id = useId() + const {registerChild, unregisterChild} = React.useContext(ActionBarContext) + + // Storing the width in a ref ensures we don't forget about it when not visible + const widthRef = useRef() + + useIsomorphicLayoutEffect(() => { + const width = ref.current?.getBoundingClientRect().width + if (width) widthRef.current = width + if (!widthRef.current) return + + registerChild(id, {type: 'group', width: widthRef.current, label}) + + return () => { + unregisterChild(id) + } + }, [registerChild, unregisterChild]) + + return ( + +
{children}
+
+ ) + }, +) + export const VerticalDivider = () => { const ref = useRef(null) const id = useId() diff --git a/packages/react/src/ActionBar/index.ts b/packages/react/src/ActionBar/index.ts index 64bf265ed8f..68db283a292 100644 --- a/packages/react/src/ActionBar/index.ts +++ b/packages/react/src/ActionBar/index.ts @@ -1,9 +1,10 @@ -import {ActionBar as Bar, ActionBarIconButton, VerticalDivider} from './ActionBar' +import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup} from './ActionBar' export type {ActionBarProps} from './ActionBar' const ActionBar = Object.assign(Bar, { IconButton: ActionBarIconButton, Divider: VerticalDivider, + Group: ActionBarGroup, }) export default ActionBar From 2b1871d3212251a080ec7b31b3950e59656536ef Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 10 Oct 2025 11:28:07 -0400 Subject: [PATCH 02/12] Clean up function --- packages/react/src/ActionBar/ActionBar.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 0ce2c64a458..909edf59ab4 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -170,17 +170,11 @@ export const ActionBar: React.FC> = prop const [childRegistry, setChildRegistry] = useState(() => new Map()) - const registerChild = useCallback((id: string, childProps: ChildProps) => { - setChildRegistry(prev => { - return new Map(prev).set(id, childProps) - }) - }, []) - - const unregisterChild = useCallback((id: string) => { - setChildRegistry(prev => { - return new Map(prev).set(id, null) - }) - }, []) + const registerChild = useCallback( + (id: string, childProps: ChildProps) => setChildRegistry(prev => new Map(prev).set(id, childProps)), + [], + ) + const unregisterChild = useCallback((id: string) => setChildRegistry(prev => new Map(prev).set(id, null)), []) const [menuItemIds, setMenuItemIds] = useState>(() => new Set()) From f8e21be0c9c8add838bc94e209561c5b63f5b56d Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 10 Oct 2025 11:29:20 -0400 Subject: [PATCH 03/12] Add comment --- packages/react/src/ActionBar/ActionBar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 909edf59ab4..1ac522c2862 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -395,7 +395,8 @@ export const ActionBarGroup = forwardRef( const id = useId() const {registerChild, unregisterChild} = React.useContext(ActionBarContext) - // Storing the width in a ref ensures we don't forget about it when not visible + // Like IconButton, we store the width in a ref ensures we don't forget about it when not visible + // If a child has a groupId, it won't be visible if the group isn't visible, so we don't need to check isVisibleChild here const widthRef = useRef() useIsomorphicLayoutEffect(() => { From dbcc75bb1bbb22ef14cfc308d9c4feeb407115e0 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 10 Oct 2025 12:29:53 -0400 Subject: [PATCH 04/12] Update story --- packages/react/src/ActionBar/ActionBar.examples.stories.tsx | 3 +-- packages/react/src/ActionBar/ActionBar.module.css | 4 ++++ packages/react/src/ActionBar/ActionBar.tsx | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx index e8fdc884034..ea418bf2cdb 100644 --- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx @@ -43,12 +43,11 @@ export const Groups = () => ( - - +
) diff --git a/packages/react/src/ActionBar/ActionBar.module.css b/packages/react/src/ActionBar/ActionBar.module.css index d795c7b57a8..5381eeee94e 100644 --- a/packages/react/src/ActionBar/ActionBar.module.css +++ b/packages/react/src/ActionBar/ActionBar.module.css @@ -33,3 +33,7 @@ background: var(--borderColor-muted); } } + +.Group { + display: flex; +} \ No newline at end of file diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 1ac522c2862..d6eac5a32ae 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -413,7 +413,9 @@ export const ActionBarGroup = forwardRef( return ( -
{children}
+
+ {children} +
) }, From b2bff28a474899a203d615c018eb5e6f408277f9 Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Fri, 10 Oct 2025 12:42:28 -0400 Subject: [PATCH 05/12] Fix lint issue --- packages/react/src/ActionBar/ActionBar.module.css | 2 +- packages/react/src/ActionBar/ActionBar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.module.css b/packages/react/src/ActionBar/ActionBar.module.css index 5381eeee94e..3dffb07cae5 100644 --- a/packages/react/src/ActionBar/ActionBar.module.css +++ b/packages/react/src/ActionBar/ActionBar.module.css @@ -36,4 +36,4 @@ .Group { display: flex; -} \ No newline at end of file +} diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index d6eac5a32ae..884233aca12 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -270,13 +270,13 @@ export const ActionBar: React.FC> = prop ) } + // TODO: refine this so that we don't have to loop through the registry multiple times const groupedItems = Array.from(childRegistry).filter(([, childProps]) => { if (childProps?.type !== 'action') return false if (childProps.groupId !== id) return false return true }) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (menuItem.type === 'group') { return ( From 20dd785dc7caed033325dfaba6e9e66eb7f2b51c Mon Sep 17 00:00:00 2001 From: Tyler Jones Date: Mon, 13 Oct 2025 10:33:33 -0400 Subject: [PATCH 06/12] Update docs, tests --- .../react/src/ActionBar/ActionBar.docs.json | 16 ++++++++++ .../ActionBar/ActionBar.examples.stories.tsx | 2 +- .../react/src/ActionBar/ActionBar.test.tsx | 30 ++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json index 79a9c68f9f6..3257f05f766 100644 --- a/packages/react/src/ActionBar/ActionBar.docs.json +++ b/packages/react/src/ActionBar/ActionBar.docs.json @@ -82,6 +82,22 @@ { "name": "ActionBar.Divider", "props": [] + }, + { + "name": "ActionBar.Group", + "props": [ + { + "name": "label", + "type": "string", + "required": true, + "description": "Label for the group. This is utilized within the overflow menu to provide context for the group of actions." + }, + { + "name": "children", + "type": "React.ReactNode", + "defaultValue": "" + } + ] } ] } diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx index ea418bf2cdb..d9001921788 100644 --- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx @@ -28,7 +28,7 @@ export default { title: 'Experimental/Components/ActionBar/Examples', } as Meta -export const Groups = () => ( +export const WithGroups = () => ( <> diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index 21139a9cb8e..a0c10351f37 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -100,6 +100,32 @@ describe('ActionBar Registry System', () => { expect(buttons[2]).toHaveAccessibleName('Third') }) + it('should preserve group order with deep nesting', () => { + render( + +
+ + + +
+ + + +
+ + + +
+
, + ) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + expect(buttons[0]).toHaveAccessibleName('First') + expect(buttons[1]).toHaveAccessibleName('Second') + expect(buttons[2]).toHaveAccessibleName('Third') + }) + it('should handle conditional rendering without breaking order', async () => { const ConditionalTest = () => { const [show, setShow] = useState([true, true, true]) @@ -108,7 +134,9 @@ describe('ActionBar Registry System', () => {
{show[0] && } - {show[1] && } + + {show[1] && } + {show[2] && }