+ 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.
+
+
+
+
+
+
+
+
+
+ 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) {
+ // A disabled expandable group can't be opened, so its children are never rendered — skip them.
+ if (next.parent?.disabled && isExpandable(next.parent)) {
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..75685ed16e 100644
--- a/src/button-dropdown/utils/utils.ts
+++ b/src/button-dropdown/utils/utils.ts
@@ -6,6 +6,9 @@ import { traverseItems } from './create-items-tree';
export const isItemGroup = (item: ButtonDropdownProps.ItemOrGroup): item is ButtonDropdownProps.ItemGroup =>
item && (item as ButtonDropdownProps.ItemGroup).items !== undefined;
+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;