Skip to content

Commit 7be99ca

Browse files
authored
feat(Dropdown): add Dropdown.Shortcut & improve a11y (#2710)
1 parent aa34763 commit 7be99ca

7 files changed

Lines changed: 198 additions & 39 deletions

File tree

.changeset/sour-eyes-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@frontify/fondue-components": minor
3+
---
4+
5+
feat(`Dropdown`): add `Dropdown.Shortcut` & improve a11y

packages/components/src/components/Dropdown/Dropdown.metadata.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"category": "overlay",
33
"description": "A contextual action menu that opens relative to a trigger element. Use for lists of actions or navigation options that don't require value selection.",
4-
"instructions": "Compose as Dropdown.Root > Dropdown.Trigger + Dropdown.Content > Dropdown.Item. Use Dropdown.Group to group related items — provide a heading prop for labeled groups. Use Dropdown.SubMenu > Dropdown.SubTrigger + Dropdown.SubContent for nested menus. Use Dropdown.Slot with name='left' or 'right' inside Dropdown.Item to add icon decorators. Use emphasis='danger' on Dropdown.Item for destructive actions. Use Select instead when users need to pick a value from a list.",
4+
"instructions": "Compose as Dropdown.Root > Dropdown.Trigger + Dropdown.Content > Dropdown.Item. Use Dropdown.Group to group related items — provide a heading prop for labeled groups. Use Dropdown.SubMenu > Dropdown.SubTrigger + Dropdown.SubContent for nested menus. Use Dropdown.Slot with name='left' or 'right' inside Dropdown.Item to add icon decorators. Use Dropdown.Shortcut inside Dropdown.Item to display the keyboard shortcut for actions that have one (e.g. 'K + E', '⌘ + N') and set aria-keyshortcuts on the matching Dropdown.Item; register the actual key listener in the consuming app. Use emphasis='danger' on Dropdown.Item for destructive actions. Use Select instead when users need to pick a value from a list.",
55
"name": "Dropdown",
66
"relatedComponents": ["Select", "Flyout"],
77
"storyFilePaths": ["src/components/Dropdown/Dropdown.stories.tsx"],

packages/components/src/components/Dropdown/Dropdown.stories.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* (c) Copyright Frontify Ltd., all rights reserved. */
22

3-
import { IconIcon } from '@frontify/fondue-icons';
3+
import { IconArrowMove, IconIcon, IconPen, IconPlus, IconTrashBin } from '@frontify/fondue-icons';
44
import { type Meta, type StoryObj } from '@storybook/react-vite';
55
import { useState } from 'react';
66

@@ -13,6 +13,7 @@ import {
1313
DropdownGroup,
1414
DropdownItem,
1515
DropdownRoot,
16+
DropdownShortcut,
1617
DropdownSubContent,
1718
DropdownSubMenu,
1819
DropdownSubTrigger,
@@ -31,6 +32,7 @@ const meta: Meta<typeof DropdownRoot> = {
3132
'Dropdown.SubMenu': DropdownSubMenu,
3233
'Dropdown.SubTrigger': DropdownSubTrigger,
3334
'Dropdown.SubContent': DropdownSubContent,
35+
'Dropdown.Shortcut': DropdownShortcut,
3436
},
3537
tags: ['autodocs'],
3638
parameters: {
@@ -250,6 +252,45 @@ export const OverflowingText: Story = {
250252
),
251253
};
252254

255+
export const KeyboardShortcut: Story = {
256+
render: ({ ...args }) => (
257+
<Dropdown.Root {...args}>
258+
<Dropdown.Trigger>
259+
<Button>Trigger</Button>
260+
</Dropdown.Trigger>
261+
<Dropdown.Content>
262+
<Dropdown.Item onSelect={() => {}} aria-keyshortcuts="Meta+E">
263+
<Dropdown.Slot name="left">
264+
<IconPen size={16} />
265+
</Dropdown.Slot>
266+
Edit
267+
<Dropdown.Shortcut>⌘ + E</Dropdown.Shortcut>
268+
</Dropdown.Item>
269+
<Dropdown.Item onSelect={() => {}}>
270+
<Dropdown.Slot name="left">
271+
<IconArrowMove size={16} />
272+
</Dropdown.Slot>
273+
Move
274+
</Dropdown.Item>
275+
<Dropdown.Item onSelect={() => {}} aria-keyshortcuts="Meta+N">
276+
<Dropdown.Slot name="left">
277+
<IconPlus size={16} />
278+
</Dropdown.Slot>
279+
Add
280+
<Dropdown.Shortcut>⌘ + N</Dropdown.Shortcut>
281+
</Dropdown.Item>
282+
<Dropdown.Item onSelect={() => {}} emphasis="danger" aria-keyshortcuts="Backspace">
283+
<Dropdown.Slot name="left">
284+
<IconTrashBin size={16} />
285+
</Dropdown.Slot>
286+
Delete
287+
<Dropdown.Shortcut></Dropdown.Shortcut>
288+
</Dropdown.Item>
289+
</Dropdown.Content>
290+
</Dropdown.Root>
291+
),
292+
};
293+
253294
export const Decorator: Story = {
254295
render: ({ ...args }) => (
255296
<Dropdown.Root {...args}>

packages/components/src/components/Dropdown/Dropdown.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { IconCaretRight } from '@frontify/fondue-icons';
44
import * as RadixDropdown from '@radix-ui/react-dropdown-menu';
55
import { Children, forwardRef, useMemo, useRef, type ForwardedRef, type ReactNode } from 'react';
66

7+
import { type CommonAriaProps } from '#/helpers/aria';
8+
79
import { ThemeProvider, useFondueTheme } from '../ThemeProvider/ThemeProvider';
810

911
import { useProcessedChildren } from './hooks/useProcessedChildren';
@@ -72,6 +74,7 @@ DropdownTrigger.displayName = 'Dropdown.Trigger';
7274

7375
type DropdownSpacing = 'compact' | 'comfortable' | 'spacious';
7476
type DropdownViewportCollisionPadding = 'compact' | 'spacious';
77+
type DropdownItemAriaProps = Omit<CommonAriaProps, 'role' | 'aria-expanded' | 'aria-haspopup'>;
7578
export type DropdownContentProps = {
7679
children?: ReactNode;
7780
'data-test-id'?: string;
@@ -333,7 +336,7 @@ export type DropdownItemProps = {
333336
*/
334337
asChild?: boolean;
335338
'data-test-id'?: string;
336-
};
339+
} & DropdownItemAriaProps;
337340

338341
export const DropdownItem = (
339342
{
@@ -396,13 +399,35 @@ export const DropdownSlot = (
396399
};
397400
DropdownSlot.displayName = 'Dropdown.Slot';
398401

402+
export type DropdownShortcutProps = Pick<CommonAriaProps, 'aria-hidden'> & {
403+
children: ReactNode;
404+
'data-test-id'?: string;
405+
};
406+
407+
export const DropdownShortcut = (
408+
{
409+
children,
410+
'aria-hidden': ariaHidden = true,
411+
'data-test-id': dataTestId = 'fondue-dropdown-shortcut',
412+
}: DropdownShortcutProps,
413+
ref: ForwardedRef<HTMLElement>,
414+
) => {
415+
return (
416+
<kbd aria-hidden={ariaHidden} className={styles.shortcut} data-test-id={dataTestId} ref={ref}>
417+
{children}
418+
</kbd>
419+
);
420+
};
421+
DropdownShortcut.displayName = 'Dropdown.Shortcut';
422+
399423
const ForwardedRefDropdownTrigger = forwardRef<HTMLButtonElement, DropdownTriggerProps>(DropdownTrigger);
400424
const ForwardedRefDropdownContent = forwardRef<HTMLDivElement, DropdownContentProps>(DropdownContent);
401425
const ForwardedRefDropdownGroup = forwardRef<HTMLDivElement, DropdownGroupProps>(DropdownGroup);
402426
const ForwardedRefDropdownSubTrigger = forwardRef<HTMLDivElement, DropdownSubTriggerProps>(DropdownSubTrigger);
403427
const ForwardedRefDropdownSubContent = forwardRef<HTMLDivElement, DropdownSubContentProps>(DropdownSubContent);
404428
const ForwardedRefDropdownItem = forwardRef<HTMLDivElement, DropdownItemProps>(DropdownItem);
405429
const ForwardedRefDropdownSlot = forwardRef<HTMLDivElement, DropdownSlotProps>(DropdownSlot);
430+
const ForwardedRefDropdownShortcut = forwardRef<HTMLElement, DropdownShortcutProps>(DropdownShortcut);
406431

407432
export const Dropdown = {
408433
Root: DropdownRoot,
@@ -414,4 +439,5 @@ export const Dropdown = {
414439
SubContent: ForwardedRefDropdownSubContent,
415440
Item: ForwardedRefDropdownItem,
416441
Slot: ForwardedRefDropdownSlot,
442+
Shortcut: ForwardedRefDropdownShortcut,
417443
};

packages/components/src/components/Dropdown/__tests__/Dropdown.ct.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const DROPDOWN_SUB_TRIGGER_TEST_ID = 'fondue-dropdown-sub-trigger';
1616
const DROPDOWN_SUB_CONTENT_TEST_ID = 'fondue-dropdown-sub-content';
1717
const DROPDOWN_ITEM_TEST_ID = 'fondue-dropdown-item';
1818
const DROPDOWN_ICON_TEST_ID = 'fondue-dropdown-icon';
19+
const DROPDOWN_SHORTCUT_TEST_ID = 'fondue-dropdown-shortcut';
1920

2021
test('should render without error', async ({ mount, page }) => {
2122
const component = await mount(
@@ -677,3 +678,79 @@ test('should call onEscapeKeyDown when escape is pressed', async ({ mount, page
677678
await expect(page.getByTestId(DROPDOWN_CONTENT_TEST_ID)).not.toBeVisible();
678679
expect(onEscapeKeyDown.callCount).toBe(1);
679680
});
681+
682+
test('should render Dropdown.Shortcut as a kbd element inside the item', async ({ mount, page }) => {
683+
const component = await mount(
684+
<Dropdown.Root>
685+
<Dropdown.Trigger>
686+
<Button data-test-id={DROPDOWN_TRIGGER_TEST_ID}>Trigger</Button>
687+
</Dropdown.Trigger>
688+
<Dropdown.Content data-test-id={DROPDOWN_CONTENT_TEST_ID}>
689+
<Dropdown.Item data-test-id={DROPDOWN_ITEM_TEST_ID} onSelect={() => {}}>
690+
Edit
691+
<Dropdown.Shortcut data-test-id={DROPDOWN_SHORTCUT_TEST_ID}>K + E</Dropdown.Shortcut>
692+
</Dropdown.Item>
693+
</Dropdown.Content>
694+
</Dropdown.Root>,
695+
);
696+
697+
await expect(component).toBeVisible();
698+
await page.getByTestId(DROPDOWN_TRIGGER_TEST_ID).click();
699+
await expect(page.getByTestId(DROPDOWN_CONTENT_TEST_ID)).toBeVisible();
700+
701+
const shortcut = page.getByTestId(DROPDOWN_SHORTCUT_TEST_ID);
702+
await expect(shortcut).toBeVisible();
703+
await expect(shortcut).toHaveText('K + E');
704+
await expect(shortcut).toHaveJSProperty('tagName', 'KBD');
705+
await expect(shortcut).toHaveAttribute('aria-hidden', 'true');
706+
await expect(page.getByTestId(DROPDOWN_ITEM_TEST_ID).getByTestId(DROPDOWN_SHORTCUT_TEST_ID)).toBeVisible();
707+
await expect(page.getByTestId(DROPDOWN_ITEM_TEST_ID)).toHaveAccessibleName('Edit');
708+
});
709+
710+
test('should forward aria-keyshortcuts to the underlying menuitem', async ({ mount, page }) => {
711+
const component = await mount(
712+
<Dropdown.Root>
713+
<Dropdown.Trigger>
714+
<Button data-test-id={DROPDOWN_TRIGGER_TEST_ID}>Trigger</Button>
715+
</Dropdown.Trigger>
716+
<Dropdown.Content data-test-id={DROPDOWN_CONTENT_TEST_ID}>
717+
<Dropdown.Item data-test-id={DROPDOWN_ITEM_TEST_ID} aria-keyshortcuts="Meta+E" onSelect={() => {}}>
718+
Edit
719+
<Dropdown.Shortcut>⌘ + E</Dropdown.Shortcut>
720+
</Dropdown.Item>
721+
</Dropdown.Content>
722+
</Dropdown.Root>,
723+
);
724+
725+
await expect(component).toBeVisible();
726+
await page.getByTestId(DROPDOWN_TRIGGER_TEST_ID).click();
727+
await expect(page.getByTestId(DROPDOWN_CONTENT_TEST_ID)).toBeVisible();
728+
729+
await expect(page.getByTestId(DROPDOWN_ITEM_TEST_ID)).toHaveAttribute('aria-keyshortcuts', 'Meta+E');
730+
});
731+
732+
test('should keep the shortcut adjacent to a right slot', async ({ mount, page }) => {
733+
const component = await mount(
734+
<Dropdown.Root>
735+
<Dropdown.Trigger>
736+
<Button data-test-id={DROPDOWN_TRIGGER_TEST_ID}>Trigger</Button>
737+
</Dropdown.Trigger>
738+
<Dropdown.Content data-test-id={DROPDOWN_CONTENT_TEST_ID}>
739+
<Dropdown.Item onSelect={() => {}}>
740+
Share
741+
<Dropdown.Slot name="right" data-test-id="dropdown-right-slot">
742+
<IconCaretDown size={16} />
743+
</Dropdown.Slot>
744+
<Dropdown.Shortcut data-test-id={DROPDOWN_SHORTCUT_TEST_ID}>S</Dropdown.Shortcut>
745+
</Dropdown.Item>
746+
</Dropdown.Content>
747+
</Dropdown.Root>,
748+
);
749+
750+
await expect(component).toBeVisible();
751+
await page.getByTestId(DROPDOWN_TRIGGER_TEST_ID).click();
752+
await expect(page.getByTestId(DROPDOWN_CONTENT_TEST_ID)).toBeVisible();
753+
754+
await expect(page.getByTestId('dropdown-right-slot')).toBeVisible();
755+
await expect(page.getByTestId(DROPDOWN_SHORTCUT_TEST_ID)).toHaveCSS('margin-inline-start', '0px');
756+
});

packages/components/src/components/Dropdown/styles/dropdown.module.scss

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
transition: none;
6565
}
6666

67-
&:not([data-show-focus-ring="false"]) {
67+
&:not([data-show-focus-ring='false']) {
6868
@include focusStyle.focus-outline;
6969
}
7070

@@ -110,7 +110,7 @@
110110
color: var(--color-disabled-default);
111111
}
112112

113-
span {
113+
> span {
114114
width: 100%;
115115
overflow: hidden;
116116
white-space: nowrap;
@@ -128,8 +128,8 @@
128128
}
129129

130130
.group {
131-
.item~&,
132-
.group~& {
131+
.item ~ &,
132+
.group ~ & {
133133
border-top: var(--border-width-default) solid var(--color-line-subtle);
134134
}
135135

@@ -165,18 +165,34 @@
165165
order: 1;
166166
margin-inline-start: auto;
167167

168-
&~.subMenuIndicator {
168+
& ~ .subMenuIndicator {
169169
margin-inline-start: sizeToken.get(1.5);
170170
}
171171
}
172172

173173
// 2 slots without any name should be implicitly ordered
174-
&:not([data-name='right'])~.slot:not([data-name='left']) {
174+
&:not([data-name='right']) ~ .slot:not([data-name='left']) {
175175
order: 1;
176176
margin-inline-start: auto;
177177

178-
&~.subMenuIndicator {
178+
& ~ .subMenuIndicator {
179179
margin-inline-start: 0;
180180
}
181181
}
182182
}
183+
184+
.shortcut {
185+
flex-shrink: 0;
186+
order: 1;
187+
margin-inline-start: auto;
188+
color: var(--color-secondary-default);
189+
font-family: inherit;
190+
font-size: var(--text-body-small-default-font-size);
191+
line-height: var(--text-body-small-default-line-height);
192+
193+
// When a right slot is also present, the shortcut sits next to it instead of pushing against the edge alone
194+
.slot[data-name='right'] ~ & {
195+
margin-inline-start: 0;
196+
}
197+
198+
}
Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,26 @@
11
/* (c) Copyright Frontify Ltd., all rights reserved. */
22

3-
export type CommonAriaProps = {
4-
/**
5-
* Aria label for the component.
6-
*/
7-
'aria-label'?: string;
8-
/**
9-
* Aria label for the component when it is hidden.
10-
*/
11-
'aria-hidden'?: boolean;
12-
/**
13-
* Aria role for the component.
14-
*/
15-
role?: string;
16-
/**
17-
* Aria described by for the component.
18-
*/
19-
'aria-describedby'?: string;
20-
/**
21-
* Aria labelled by for the component.
22-
*/
23-
'aria-labelledby'?: string;
24-
/**
25-
* Aria expanded for the component.
26-
*/
27-
'aria-expanded'?: boolean;
28-
/**
29-
* Aria has popup for the component.
30-
*/
31-
'aria-haspopup'?: boolean;
3+
import { type AriaAttributes, type AriaRole } from 'react';
4+
5+
/**
6+
* The small, curated subset of ARIA attributes (plus `role`) that we expose on most
7+
* Fondue components. Each attribute reuses React's canonical type from `AriaAttributes`
8+
* so the shape stays in lockstep with what consumers spread from `HTMLAttributes` —
9+
* no hand-typed `boolean`-vs-`Booleanish` drift.
10+
*
11+
* Keep this set intentionally small. If a component needs an ARIA attribute outside
12+
* this list, declare it explicitly on that component's props rather than expanding
13+
* the common surface.
14+
*/
15+
export type CommonAriaProps = Pick<
16+
AriaAttributes,
17+
| 'aria-label'
18+
| 'aria-labelledby'
19+
| 'aria-describedby'
20+
| 'aria-hidden'
21+
| 'aria-expanded'
22+
| 'aria-haspopup'
23+
| 'aria-keyshortcuts'
24+
> & {
25+
role?: AriaRole;
3226
};

0 commit comments

Comments
 (0)