From e225f9d2eb5df2d6ac8be8918a0ac6a57449a2d0 Mon Sep 17 00:00:00 2001 From: Ryan Evezard Date: Fri, 28 Aug 2026 13:23:25 +0000 Subject: [PATCH] feat: add per-group expandable control to button dropdown --- .../button-dropdown/expandable-mixed.page.tsx | 162 ++++++++++++++ .../__tests__/is-group-expandable.test.ts | 35 +++ .../__tests__/move-highlight.test.ts | 207 ++++++++++++------ .../__tests__/use-highlighted-menu.test.ts | 4 +- src/button-dropdown/interfaces.ts | 5 + src/button-dropdown/internal-interfaces.ts | 2 +- src/button-dropdown/internal.tsx | 4 +- src/button-dropdown/items-list.tsx | 4 +- src/button-dropdown/utils/move-highlight.ts | 43 ++-- .../utils/use-button-dropdown.ts | 19 +- .../utils/use-highlighted-menu.ts | 24 +- src/button-dropdown/utils/utils.ts | 5 + 12 files changed, 401 insertions(+), 113 deletions(-) create mode 100644 pages/button-dropdown/expandable-mixed.page.tsx create mode 100644 src/button-dropdown/__tests__/is-group-expandable.test.ts diff --git a/pages/button-dropdown/expandable-mixed.page.tsx b/pages/button-dropdown/expandable-mixed.page.tsx new file mode 100644 index 0000000000..9476b5e5a3 --- /dev/null +++ b/pages/button-dropdown/expandable-mixed.page.tsx @@ -0,0 +1,162 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useState } from 'react'; + +import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown'; +import SpaceBetween from '~components/space-between'; + +import styles from './styles.scss'; + +// Prototype for per-group expandability (inherit-override): `expandableGroups` is the +// dropdown-level default, and each group's `expandable` flag overrides it — `false` forces a +// group flat even when the default is on, `true` forces it expandable even when the default is +// off, and an unset flag inherits the default. Toggle the global switch to watch the forced +// groups hold their behavior while the inheriting ones follow. +export const items: ButtonDropdownProps['items'] = [ + { + id: 'expandable-1', + text: 'Expandable category 1', + items: [...Array(3)].map((_, index) => ({ + id: 'expandable-1-item-' + index, + text: 'Expandable 1 · sub item ' + index, + })), + }, + { + id: 'flat-2', + text: 'Flat category 2 (expandable: false)', + expandable: false, + items: [...Array(3)].map((_, index) => ({ + id: 'flat-2-item-' + index, + text: 'Flat 2 · inline item ' + index, + })), + }, + { + id: 'forced-expandable', + text: 'Forced-expandable category (expandable: true)', + expandable: true, + items: [...Array(3)].map((_, index) => ({ + id: 'forced-expandable-item-' + index, + text: 'Forced · sub item ' + index, + })), + }, + ...[...Array(3)].map((_, index) => ({ + id: 'top-item-' + index, + text: 'Top-level item ' + index, + })), + { + id: 'expandable-3', + text: 'Expandable category 3', + items: [...Array(4)].map((_, index) => ({ + id: 'expandable-3-item-' + index, + text: 'Expandable 3 · sub item ' + index, + })), + }, + { + id: 'flat-4-disabled', + text: 'Flat category 4 (disabled, expandable: false)', + expandable: false, + disabled: true, + items: [{ id: 'flat-4-item', text: 'Flat 4 · inline item' }], + }, + { + id: 'top-item-last', + text: 'Top-level item (last)', + secondaryText: 'End of the list', + }, +]; + +export default function MixedExpandableScenario() { + const [expandToViewport, setExpandToViewport] = useState(false); + const [expandableGroups, setExpandableGroups] = useState(true); + const [forceMobile, setForceMobile] = useState(false); + + // Force the component into its mobile "restricted view" navigation model without resizing + // the window, using the built-in override symbol read by the useMobile hook. Dispatching a + // resize event makes the useMobile singleton re-evaluate immediately. + useEffect(() => { + const forceMobileModeSymbol = Symbol.for('awsui-force-mobile-mode'); + if (forceMobile) { + (globalThis as Record)[forceMobileModeSymbol] = true; + } else { + delete (globalThis as Record)[forceMobileModeSymbol]; + } + window.dispatchEvent(new Event('resize')); + return () => { + delete (globalThis as Record)[forceMobileModeSymbol]; + window.dispatchEvent(new Event('resize')); + }; + }, [forceMobile]); + + return ( +
+
+

Mixed expandable / flat groups (prototype)

+

+ Each group's expandable flag overrides the dropdown-level expandableGroups: + category 2 is forced flat (expandable: false), the forced-expandable category is forced open ( + expandable: true), and the rest inherit the global. Toggle expandableGroups — the + forced groups hold their behavior while the inheriting ones follow. Use arrow keys to verify navigation + confines to the current plane and steps across flat groups inline. +

+

+ Turn on Force mobile mode and open a dropdown with the keyboard: on mobile the flat group + renders inline and arrow keys step onto its children (the top-plane fix). +

+ + + + + + +
+
+ + Mixed groups + + + Mixed groups + filtering + +
+
+
+
+ ); +} diff --git a/src/button-dropdown/__tests__/is-group-expandable.test.ts b/src/button-dropdown/__tests__/is-group-expandable.test.ts new file mode 100644 index 0000000000..75c7bfb2f6 --- /dev/null +++ b/src/button-dropdown/__tests__/is-group-expandable.test.ts @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ButtonDropdownProps } from '../interfaces'; +import { isGroupExpandable } from '../utils/utils'; + +const group = (expandable?: boolean): ButtonDropdownProps.ItemGroup => ({ + text: 'group', + items: [{ id: 'child', text: 'child' }], + ...(expandable === undefined ? {} : { expandable }), +}); + +const action: ButtonDropdownProps.Item = { id: 'a', text: 'action' }; +const checkbox: ButtonDropdownProps.CheckboxItem = { id: 'c', text: 'checkbox', itemType: 'checkbox', checked: false }; + +describe('isGroupExpandable (inherit-override)', () => { + test('unset flag inherits the dropdown-level default', () => { + expect(isGroupExpandable(group(undefined), true)).toBe(true); + expect(isGroupExpandable(group(undefined), false)).toBe(false); + }); + + test('explicit true overrides the default in both directions', () => { + expect(isGroupExpandable(group(true), false)).toBe(true); + expect(isGroupExpandable(group(true), true)).toBe(true); + }); + + test('explicit false overrides the default in both directions', () => { + expect(isGroupExpandable(group(false), true)).toBe(false); + expect(isGroupExpandable(group(false), false)).toBe(false); + }); + + test('non-group items are never expandable', () => { + expect(isGroupExpandable(action, true)).toBe(false); + expect(isGroupExpandable(checkbox, true)).toBe(false); + }); +}); diff --git a/src/button-dropdown/__tests__/move-highlight.test.ts b/src/button-dropdown/__tests__/move-highlight.test.ts index e5bb4bd46e..c4c08dde52 100644 --- a/src/button-dropdown/__tests__/move-highlight.test.ts +++ b/src/button-dropdown/__tests__/move-highlight.test.ts @@ -1,7 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { ButtonDropdownProps } from '../interfaces'; +import { TreeIndex } from '../utils/create-items-tree'; import moveHighlight from '../utils/move-highlight'; +import { isItemGroup } from '../utils/utils'; const items: { index: number[]; item: ButtonDropdownProps.ItemOrGroup; parent?: ButtonDropdownProps.ItemOrGroup }[] = [ { index: [0], item: { id: '00', text: 'item-00' } }, @@ -29,124 +31,185 @@ function getNext(index: number[]) { return items[seqIndex + 1]; } +function itemAt(index: TreeIndex): ButtonDropdownProps.ItemOrGroup | undefined { + const joined = index.join('-'); + return items.find(it => it.index.join('-') === joined)?.item; +} + +// Reproduces the former global `hasExpandableGroups` flag on top of the per-node engine: +// when expandable, every group is expandable; when not, none are. This lets the existing +// cases keep asserting the uniform behavior while exercising the new isExpandable/planeOf API. +function runMove(params: { + startIndex: TreeIndex; + expandedIndex: TreeIndex; + hasExpandableGroups: boolean; + isInRestrictedView: boolean; +}) { + const isExpandable = (item: ButtonDropdownProps.ItemOrGroup) => params.hasExpandableGroups && isItemGroup(item); + const planeOf = (index: TreeIndex): TreeIndex => { + if (index.length <= 1) { + return []; + } + const parentIndex = index.slice(0, -1); + const parent = itemAt(parentIndex); + return parent && isItemGroup(parent) && isExpandable(parent) ? parentIndex : []; + }; + + return moveHighlight({ + startIndex: params.startIndex, + expandedIndex: params.expandedIndex, + getNext, + isExpandable, + planeOf, + isInRestrictedView: params.isInRestrictedView, + }); +} + describe('move-highlight util', () => { test('moves to the next item', () => { expect( - moveHighlight({ - startIndex: [0], - expandedIndex: [], - getNext, - hasExpandableGroups: false, - isInRestrictedView: false, - }) + runMove({ startIndex: [0], expandedIndex: [], hasExpandableGroups: false, isInRestrictedView: false }) ).toEqual([1]); }); test('returns null when cannot move further', () => { - expect( - moveHighlight({ - startIndex: [5], - expandedIndex: [], - getNext, - hasExpandableGroups: false, - isInRestrictedView: false, - }) - ).toBe(null); + expect(runMove({ startIndex: [5], expandedIndex: [], hasExpandableGroups: false, isInRestrictedView: false })).toBe( + null + ); }); test('includes disabled items', () => { expect( - moveHighlight({ - startIndex: [1], - expandedIndex: [], - getNext, - hasExpandableGroups: true, - isInRestrictedView: false, - }) + runMove({ startIndex: [1], expandedIndex: [], hasExpandableGroups: true, isInRestrictedView: false }) ).toEqual([2]); }); test('includes disabled nested group items', () => { expect( - moveHighlight({ - startIndex: [3], - expandedIndex: [], - getNext, - hasExpandableGroups: false, - isInRestrictedView: false, - }) + runMove({ startIndex: [3], expandedIndex: [], hasExpandableGroups: false, isInRestrictedView: false }) ).toEqual([3, 0]); }); test('skip disabled group when expandable group', () => { expect( - moveHighlight({ - startIndex: [4], - expandedIndex: [], - getNext, - hasExpandableGroups: true, - isInRestrictedView: false, - }) + runMove({ startIndex: [4], expandedIndex: [], hasExpandableGroups: true, isInRestrictedView: false }) ).toEqual([5]); }); test('skip disabled group when expandable group and restricted view', () => { expect( - moveHighlight({ - startIndex: [4], - expandedIndex: [], - getNext, - hasExpandableGroups: true, - isInRestrictedView: true, - }) + runMove({ startIndex: [4], expandedIndex: [], hasExpandableGroups: true, isInRestrictedView: true }) ).toEqual([5]); }); test('navigates disabled nested group', () => { expect( - moveHighlight({ - startIndex: [4], - expandedIndex: [], - getNext, - hasExpandableGroups: false, - isInRestrictedView: false, - }) + runMove({ startIndex: [4], expandedIndex: [], hasExpandableGroups: false, isInRestrictedView: false }) ).toEqual([4, 0]); }); test('skips expandable group in restricted', () => { expect( - moveHighlight({ - startIndex: [3], - expandedIndex: [], - getNext, - hasExpandableGroups: true, - isInRestrictedView: true, - }) + runMove({ startIndex: [3], expandedIndex: [], hasExpandableGroups: true, isInRestrictedView: true }) ).toEqual([4]); }); test('cannot exit group when not restricted', () => { expect( - moveHighlight({ - startIndex: [3, 0], - expandedIndex: [3], - getNext, - hasExpandableGroups: true, - isInRestrictedView: false, - }) + runMove({ startIndex: [3, 0], expandedIndex: [3], hasExpandableGroups: true, isInRestrictedView: false }) ).toEqual(null); }); test('can exit group when restricted', () => { expect( - moveHighlight({ - startIndex: [3, 0], - expandedIndex: [3], - getNext, - hasExpandableGroups: true, - isInRestrictedView: true, - }) + runMove({ startIndex: [3, 0], expandedIndex: [3], hasExpandableGroups: true, isInRestrictedView: true }) ).toEqual([4]); }); }); + +describe('move-highlight util - mixed expandable and flat groups', () => { + // A dropdown where group-03 is expandable but group-04 opts out (flat). + const mixedItems: { + index: number[]; + item: ButtonDropdownProps.ItemOrGroup; + parent?: ButtonDropdownProps.ItemOrGroup; + }[] = [ + { index: [0], item: { id: '00', text: 'item-00' } }, + { index: [1], item: { id: '03', text: 'group-03', items: [] } }, + { index: [1, 0], item: { id: '30', text: 'item-30' }, parent: { id: '03', text: 'group-03', items: [] } }, + { + index: [2], + item: { id: '04', text: 'group-04', expandable: false, items: [] }, + }, + { + index: [2, 0], + item: { id: '40', text: 'item-40' }, + parent: { id: '04', text: 'group-04', expandable: false, items: [] }, + }, + { index: [3], item: { id: '05', text: 'item-05' } }, + ]; + + const getNextMixed = (index: number[]) => { + const joined = index.join('-'); + const seqIndex = mixedItems.findIndex(it => it.index.join('-') === joined); + return mixedItems[seqIndex + 1]; + }; + + const itemAtMixed = (index: TreeIndex) => mixedItems.find(it => it.index.join('-') === index.join('-'))?.item; + + const isExpandable = (item: ButtonDropdownProps.ItemOrGroup) => isItemGroup(item) && item.expandable !== false; + + const planeOf = (index: TreeIndex): TreeIndex => { + if (index.length <= 1) { + return []; + } + const parentIndex = index.slice(0, -1); + const parent = itemAtMixed(parentIndex); + return parent && isItemGroup(parent) && isExpandable(parent) ? parentIndex : []; + }; + + const run = (startIndex: TreeIndex, expandedIndex: TreeIndex = [], isInRestrictedView = false) => + moveHighlight({ startIndex, expandedIndex, getNext: getNextMixed, isExpandable, planeOf, isInRestrictedView }); + + test('highlights an expandable group header', () => { + // From the top item, the next highlight is the expandable group-03 header. + expect(run([0])).toEqual([1]); + }); + + test("skips a collapsed expandable group's children on the top plane", () => { + // group-03 is collapsed: its child [1,0] is in group-03's plane and is skipped; + // the flat group-04 header is skipped too, landing on its hoisted child [2,0]. + expect(run([1])).toEqual([2, 0]); + }); + + test("navigates the flat group's hoisted children inline", () => { + // Flat group-04's children share the top plane, so [2,0] flows to the next top item. + expect(run([2, 0])).toEqual([3]); + }); + + test("confines navigation to an expanded group's plane", () => { + // With group-03 expanded, from its child [1,0] there is no same-plane successor. + expect(run([1, 0], [1])).toEqual(null); + }); + + describe('restricted (mobile) view', () => { + test("reaches a flat group's hoisted children (top plane)", () => { + // Mobile: from the collapsed expandable header the expandable child is gated, but the + // flat group's children live on the top plane and must remain keyboard-reachable. + expect(run([1], [], true)).toEqual([2, 0]); + }); + + test('continues from a flat child to the next top-level item', () => { + expect(run([2, 0], [], true)).toEqual([3]); + }); + + test("still gates a collapsed expandable group's children", () => { + // group-03 collapsed: its own child [1,0] must NOT be the next landing spot. + expect(run([1], [], true)).not.toEqual([1, 0]); + }); + + test("enters an expanded group's children", () => { + expect(run([1], [1], true)).toEqual([1, 0]); + }); + }); +}); diff --git a/src/button-dropdown/__tests__/use-highlighted-menu.test.ts b/src/button-dropdown/__tests__/use-highlighted-menu.test.ts index ef1aee7dd5..4ba034cc8e 100644 --- a/src/button-dropdown/__tests__/use-highlighted-menu.test.ts +++ b/src/button-dropdown/__tests__/use-highlighted-menu.test.ts @@ -3,6 +3,7 @@ import { act, renderHook } from '../../__tests__/render-hook'; import { ButtonDropdownProps } from '../interfaces'; import useHighlightedMenu from '../utils/use-highlighted-menu'; +import { isItemGroup } from '../utils/utils'; const itemGroup1: ButtonDropdownProps.ItemGroup = { text: 'category1', @@ -32,8 +33,9 @@ const testItems2: ButtonDropdownProps.Items = [ ]; function render({ items = testItems, hasExpandableGroups = false, isInRestrictedView = false }) { + const isExpandable = (item: ButtonDropdownProps.ItemOrGroup) => hasExpandableGroups && isItemGroup(item); return renderHook(useHighlightedMenu, { - initialProps: { items, hasExpandableGroups, isInRestrictedView }, + initialProps: { items, isExpandable, isInRestrictedView }, }); } diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index b88d197ed0..ae308304fe 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -364,6 +364,11 @@ export namespace ButtonDropdownProps { id?: string; text?: string; items: Items; + /** + * Controls whether this group renders as an expandable submenu, overriding `expandableGroups` + * for this group. Defaults to the `expandableGroups` value when unset. + */ + expandable?: boolean; } export type ItemOrGroup = Item | CheckboxItem | ItemGroup; diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 65dd6ba282..1018d79754 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -36,7 +36,7 @@ export interface ItemListProps extends HighlightProps { onGroupToggle: GroupToggle; onItemActivate: ItemActivate; categoryDisabled?: boolean; - hasExpandableGroups?: boolean; + isExpandable?: (item: ButtonDropdownProps.ItemOrGroup) => boolean; hasCategoryHeader?: boolean; lastInDropdown: boolean; expandToViewport?: boolean; diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 6f62cc522d..de362b4789 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -129,7 +129,7 @@ const InternalButtonDropdown = React.forwardRef( filteringValue, setFilteringValue, filteredItems, - showExpandableGroups, + isExpandable, } = useButtonDropdown({ items, onItemClick, @@ -508,7 +508,7 @@ const InternalButtonDropdown = React.forwardRef( items={filteredItems} onItemActivate={onItemActivate} onGroupToggle={onGroupToggle} - hasExpandableGroups={showExpandableGroups} + isExpandable={isExpandable} targetItem={targetItem} isHighlighted={isHighlighted} isKeyboardHighlight={isKeyboardHighlight} diff --git a/src/button-dropdown/items-list.tsx b/src/button-dropdown/items-list.tsx index a4b0df7578..258fc22064 100644 --- a/src/button-dropdown/items-list.tsx +++ b/src/button-dropdown/items-list.tsx @@ -21,7 +21,7 @@ export default function ItemsList({ lastInDropdown, highlightItem, categoryDisabled = false, - hasExpandableGroups = false, + isExpandable = () => false, hasCategoryHeader = false, expandToViewport = false, variant = 'normal', @@ -66,7 +66,7 @@ export default function ItemsList({ /> ); } - if (hasExpandableGroups) { + if (isExpandable(item)) { return item.text ? ( isMobile ? ( boolean; + planeOf: (index: TreeIndex) => TreeIndex; isInRestrictedView: boolean; } @@ -24,7 +30,8 @@ export default function moveHighlight({ startIndex, expandedIndex, getNext, - hasExpandableGroups, + isExpandable, + planeOf, isInRestrictedView, }: MoveHighlightProps): TreeIndex | null { const tryMove = (currentIndex: TreeIndex): TreeIndex | null => { @@ -34,28 +41,26 @@ export default function moveHighlight({ return null; } - // Prevents stepping into disabled expandable groups. However, - // it's possible to navigate nested groups. - if (next.parent?.disabled && hasExpandableGroups) { + // don't step into a disabled expandable group; a flat group's children stay navigable + if (next.parent && isExpandable(next.parent) && next.parent.disabled) { return tryMove(next.index); } - // it is not allowed to highlight groups when non-expandable - if (isItemGroup(next.item) && !hasExpandableGroups) { + // only an expandable group's header is highlightable; a flat group's is skipped + if (isItemGroup(next.item) && !isExpandable(next.item)) { return tryMove(next.index); } - // can only move within same parent unless is in restricted view - if (hasExpandableGroups && !isInRestrictedView && !isSameParent(startIndex, next.index)) { + // confine to the current plane; in fully-flat mode every index is top plane so this never clamps + if (!isInRestrictedView && !isSamePlane(planeOf(startIndex), planeOf(next.index))) { return tryMove(next.index); } - // in restricted view can only navigate to children if group is expanded + // in restricted view, admit the top plane plus the currently-expanded group's plane if ( - hasExpandableGroups && isInRestrictedView && - !isSameLevel(next.index, expandedIndex) && - !isIncluded(expandedIndex, next.index) + !isSamePlane(planeOf(next.index), []) && + !isSamePlane(planeOf(next.index), expandedIndex) ) { return tryMove(next.index); } @@ -66,14 +71,6 @@ export default function moveHighlight({ return tryMove(startIndex); } -function isSameParent(left: TreeIndex, right: TreeIndex) { - return indexEquals(left.slice(0, -1), right.slice(0, -1)); -} - -function isSameLevel(left: TreeIndex, right: TreeIndex) { - return left.length === right.length; -} - -function isIncluded(parent: TreeIndex, child: TreeIndex) { - return indexEquals(parent, child.slice(0, -1)); +function isSamePlane(left: TreeIndex, right: TreeIndex) { + return indexEquals(left, right); } diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index 8eebdbca5f..51dd1f778d 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useOpenState } from '../../internal/components/options-list/utils/use-open-state'; import { fireCancelableEvent, isPlainLeftClick } from '../../internal/events'; @@ -9,7 +9,7 @@ import { CancelableEventHandler } from '../../types/events'; import { ButtonDropdownProps, ButtonDropdownSettings, GroupToggle, HighlightProps, ItemActivate } from '../interfaces'; import { filterItems } from './filter-items'; import useHighlightedMenu from './use-highlighted-menu'; -import { getItemTarget, isCheckboxItem, isItemGroup, isLinkItem } from './utils'; +import { getItemTarget, isCheckboxItem, isGroupExpandable, isItemGroup, isLinkItem } from './utils'; interface UseButtonDropdownOptions extends ButtonDropdownSettings { items: ButtonDropdownProps.Items; @@ -34,7 +34,7 @@ interface UseButtonDropdownApi extends HighlightProps { filteringValue: string; setFilteringValue: (value: string) => void; filteredItems: ButtonDropdownProps.Items; - showExpandableGroups: boolean; + isExpandable: (item: ButtonDropdownProps.ItemOrGroup) => boolean; } export function useButtonDropdown({ @@ -54,7 +54,12 @@ export function useButtonDropdown({ [hasFiltering, filteringValue, items] ); - const showExpandableGroups = hasExpandableGroups && !filteringValue; + // an active filter flattens every group; otherwise a group's own `expandable` flag wins, falling + // back to the dropdown-level `expandableGroups` default + const isExpandable = useCallback( + (item: ButtonDropdownProps.ItemOrGroup) => !filteringValue && isGroupExpandable(item, hasExpandableGroups), + [filteringValue, hasExpandableGroups] + ); const { targetItem, @@ -69,7 +74,7 @@ export function useButtonDropdown({ setIsUsingMouse, } = useHighlightedMenu({ items: filteredItems, - hasExpandableGroups: showExpandableGroups, + isExpandable, isInRestrictedView, }); @@ -233,7 +238,7 @@ export function useButtonDropdown({ } if (targetItem && !targetItem.disabled && isItemGroup(targetItem) && !isExpanded(targetItem)) { expandGroup(); - } else if (hasExpandableGroups) { + } else { collapseGroup(); } @@ -296,6 +301,6 @@ export function useButtonDropdown({ filteringValue, setFilteringValue, filteredItems, - showExpandableGroups, + isExpandable, }; } diff --git a/src/button-dropdown/utils/use-highlighted-menu.ts b/src/button-dropdown/utils/use-highlighted-menu.ts index 713d9d28f3..4c8e9a7829 100644 --- a/src/button-dropdown/utils/use-highlighted-menu.ts +++ b/src/button-dropdown/utils/use-highlighted-menu.ts @@ -5,11 +5,11 @@ import { useCallback, useMemo, useState } from 'react'; import { ButtonDropdownProps, HighlightProps } from '../interfaces'; import createItemsTree, { TreeIndex } from './create-items-tree'; import moveHighlightOneStep from './move-highlight'; -import { indexEquals, indexIncludes } from './utils'; +import { indexEquals, indexIncludes, isItemGroup } from './utils'; interface UseHighlightedMenuOptions { items: ButtonDropdownProps.Items; - hasExpandableGroups: boolean; + isExpandable: (item: ButtonDropdownProps.ItemOrGroup) => boolean; isInRestrictedView?: boolean; } @@ -23,7 +23,7 @@ interface UseHighlightedMenuApi extends HighlightProps { export default function useHighlightedMenu({ items, - hasExpandableGroups, + isExpandable, isInRestrictedView = false, }: UseHighlightedMenuOptions): UseHighlightedMenuApi { const [targetIndex, setTargetIndex] = useState([]); @@ -60,6 +60,19 @@ export default function useHighlightedMenu({ [expandedIndex, getItemIndex] ); + // an index's navigable plane: an expandable group's children form its plane, everything else is top plane + const planeOf = useCallback( + (index: TreeIndex): TreeIndex => { + if (index.length <= 1) { + return []; + } + const parentIndex = index.slice(0, -1); + const parent = getItem(parentIndex); + return parent && isItemGroup(parent) && isExpandable(parent) ? parentIndex : []; + }, + [getItem, isExpandable] + ); + const moveHighlight = useCallback( (direction: -1 | 1, loop?: boolean) => { const getNext = (index: TreeIndex) => { @@ -80,7 +93,8 @@ export default function useHighlightedMenu({ startIndex: targetIndex, expandedIndex, getNext, - hasExpandableGroups, + isExpandable, + planeOf, isInRestrictedView, }); @@ -88,7 +102,7 @@ export default function useHighlightedMenu({ setTargetIndex(nextIndex); } }, - [targetIndex, expandedIndex, getItem, getSequentialIndex, getParentIndex, hasExpandableGroups, isInRestrictedView] + [targetIndex, expandedIndex, getItem, getSequentialIndex, getParentIndex, isExpandable, planeOf, isInRestrictedView] ); const highlightItem = useCallback( diff --git a/src/button-dropdown/utils/utils.ts b/src/button-dropdown/utils/utils.ts index 3484102a45..396c050bb3 100644 --- a/src/button-dropdown/utils/utils.ts +++ b/src/button-dropdown/utils/utils.ts @@ -6,6 +6,11 @@ import { traverseItems } from './create-items-tree'; export const isItemGroup = (item: ButtonDropdownProps.ItemOrGroup): item is ButtonDropdownProps.ItemGroup => item && (item as ButtonDropdownProps.ItemGroup).items !== undefined; +// A group's own `expandable` flag decides whether it renders as an expandable submenu, falling +// back to the dropdown-level `expandableGroups` default when unset. Non-group items are never expandable. +export const isGroupExpandable = (item: ButtonDropdownProps.ItemOrGroup, expandableGroupsDefault: boolean): boolean => + isItemGroup(item) && (item.expandable ?? expandableGroupsDefault); + export const isLinkItem = (item: LinkItem | ButtonDropdownProps.ItemOrGroup): item is LinkItem => item && (item as LinkItem).href !== undefined;