diff --git a/.changeset/theme-toggle-footer-slot.md b/.changeset/theme-toggle-footer-slot.md new file mode 100644 index 0000000000000..47c0949f8bb83 --- /dev/null +++ b/.changeset/theme-toggle-footer-slot.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +Add an optional `footer` prop to `ThemeToggle` that renders custom content (e.g. keyboard shortcut hints) at the bottom of the dropdown, below the theme options. diff --git a/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx b/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx index 91acf689ec00c..6e53ae3cd4b79 100644 --- a/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx +++ b/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx @@ -104,4 +104,56 @@ describe('ThemeToggle', () => { assert.equal(selected, 'system'); }); + + it('renders the footer slot when provided', async () => { + render( + Use ⌘+L for Light, ⌘+D for Dark} + /> + ); + + await userEvent.click(screen.getByRole('button', { name: 'Select theme' })); + + assert.ok( + screen.getByText('Use ⌘+L for Light, ⌘+D for Dark'), + 'footer content should be rendered inside the dropdown' + ); + + const footerId = screen.getByRole('menu').getAttribute('aria-describedby'); + assert.ok( + footerId, + 'menu content should reference the footer via aria-describedby' + ); + assert.equal( + document.getElementById(footerId)?.textContent, + 'Use ⌘+L for Light, ⌘+D for Dark', + 'aria-describedby should point to the footer element' + ); + }); + + it('does not render a footer separator when no footer is provided', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Select theme' })); + + assert.equal( + document.querySelector('[class*="dropDownFooter"]'), + null, + 'footer element should not exist without the footer prop' + ); + assert.equal( + screen.getByRole('menu').getAttribute('aria-describedby'), + null, + 'menu content should not reference a footer without the footer prop' + ); + }); }); diff --git a/packages/ui-components/src/Common/ThemeToggle/index.module.css b/packages/ui-components/src/Common/ThemeToggle/index.module.css index a9e28f2325466..bac7a74af1834 100644 --- a/packages/ui-components/src/Common/ThemeToggle/index.module.css +++ b/packages/ui-components/src/Common/ThemeToggle/index.module.css @@ -47,3 +47,14 @@ @apply bg-brand-600 text-white; } + +.dropDownFooter { + @apply border-t + border-neutral-200 + px-2.5 + py-1.5 + text-xs + text-neutral-500 + dark:border-neutral-800 + dark:text-neutral-400; +} diff --git a/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx b/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx index ca2e2419c547b..b7a078896aa82 100644 --- a/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx +++ b/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx @@ -34,4 +34,14 @@ export const DarkSelected: Story = { }, }; +export const WithFooter: Story = { + args: { + ariaLabel: 'Select theme', + currentTheme: 'system', + themeLabels: defaultLabels, + onChange: () => {}, + footer: ⌘+L Light · ⌘+D Dark, + }, +}; + export default { component: ThemeToggle } as Meta; diff --git a/packages/ui-components/src/Common/ThemeToggle/index.tsx b/packages/ui-components/src/Common/ThemeToggle/index.tsx index adf2da435e05f..62578ffd5fbae 100644 --- a/packages/ui-components/src/Common/ThemeToggle/index.tsx +++ b/packages/ui-components/src/Common/ThemeToggle/index.tsx @@ -5,8 +5,9 @@ import { } from '@heroicons/react/24/outline'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import classNames from 'classnames'; +import { useId } from 'react'; -import type { FC } from 'react'; +import type { FC, ReactNode } from 'react'; import styles from './index.module.css'; @@ -17,6 +18,7 @@ type ThemeToggleProps = { currentTheme?: Theme; ariaLabel?: string; themeLabels?: { system: string; light: string; dark: string }; + footer?: ReactNode; }; const themeIcons: Record = { @@ -32,7 +34,9 @@ const ThemeToggle: FC = ({ currentTheme = 'system', ariaLabel = 'Select theme', themeLabels = { system: 'System', light: 'Light', dark: 'Dark' }, + footer, }) => { + const footerId = useId(); const TriggerIcon = themeIcons[currentTheme]; return ( @@ -52,6 +56,7 @@ const ThemeToggle: FC = ({ align="end" className={styles.dropDownContent} sideOffset={5} + aria-describedby={footer ? footerId : undefined} > {themes.map(theme => { const Icon = themeIcons[theme]; @@ -68,6 +73,11 @@ const ThemeToggle: FC = ({ ); })} + {footer ? ( +
+ {footer} +
+ ) : null}