From d4a037b90e5f50d8eeceeb014d1b56a97139cc86 Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Sat, 7 Nov 2026 12:22:35 +0800 Subject: [PATCH 1/3] feat(ui-components): add footer slot to ThemeToggle dropdown --- .../ThemeToggle/__tests__/index.test.jsx | 36 +++++++++++++++++++ .../src/Common/ThemeToggle/index.module.css | 11 ++++++ .../src/Common/ThemeToggle/index.stories.tsx | 10 ++++++ .../src/Common/ThemeToggle/index.tsx | 7 +++- 4 files changed, 63 insertions(+), 1 deletion(-) 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..d6e1be0b51466 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,40 @@ 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' + ); + }); + + 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' + ); + }); }); 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..58d9cd4e93886 100644 --- a/packages/ui-components/src/Common/ThemeToggle/index.tsx +++ b/packages/ui-components/src/Common/ThemeToggle/index.tsx @@ -6,7 +6,7 @@ import { import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import classNames from 'classnames'; -import type { FC } from 'react'; +import type { FC, ReactNode } from 'react'; import styles from './index.module.css'; @@ -17,6 +17,7 @@ type ThemeToggleProps = { currentTheme?: Theme; ariaLabel?: string; themeLabels?: { system: string; light: string; dark: string }; + footer?: ReactNode; }; const themeIcons: Record = { @@ -32,6 +33,7 @@ const ThemeToggle: FC = ({ currentTheme = 'system', ariaLabel = 'Select theme', themeLabels = { system: 'System', light: 'Light', dark: 'Dark' }, + footer, }) => { const TriggerIcon = themeIcons[currentTheme]; @@ -68,6 +70,9 @@ const ThemeToggle: FC = ({ ); })} + {footer ? ( +
{footer}
+ ) : null} From e8671785a76a00a64b5ea180f4b4cd97a592e6c6 Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Sat, 7 Nov 2026 12:28:56 +0800 Subject: [PATCH 2/3] feat: add changeset --- .changeset/theme-toggle-footer-slot.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/theme-toggle-footer-slot.md 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. From 81848be3638ee650114b8216665970a1cc558f2b Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Sat, 7 Nov 2026 22:13:48 +0800 Subject: [PATCH 3/3] feat: update --- .../Common/ThemeToggle/__tests__/index.test.jsx | 16 ++++++++++++++++ .../src/Common/ThemeToggle/index.tsx | 7 ++++++- 2 files changed, 22 insertions(+), 1 deletion(-) 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 d6e1be0b51466..6e53ae3cd4b79 100644 --- a/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx +++ b/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx @@ -121,6 +121,17 @@ describe('ThemeToggle', () => { 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 () => { @@ -139,5 +150,10 @@ describe('ThemeToggle', () => { 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.tsx b/packages/ui-components/src/Common/ThemeToggle/index.tsx index 58d9cd4e93886..62578ffd5fbae 100644 --- a/packages/ui-components/src/Common/ThemeToggle/index.tsx +++ b/packages/ui-components/src/Common/ThemeToggle/index.tsx @@ -5,6 +5,7 @@ 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, ReactNode } from 'react'; @@ -35,6 +36,7 @@ const ThemeToggle: FC = ({ themeLabels = { system: 'System', light: 'Light', dark: 'Dark' }, footer, }) => { + const footerId = useId(); const TriggerIcon = themeIcons[currentTheme]; return ( @@ -54,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]; @@ -71,7 +74,9 @@ const ThemeToggle: FC = ({ ); })} {footer ? ( -
{footer}
+
+ {footer} +
) : null}