diff --git a/apps/docs/src/components/playground/color-mode-toggle.tsx b/apps/docs/src/components/playground/color-mode-toggle.tsx index ee200b46..18e0d60d 100644 --- a/apps/docs/src/components/playground/color-mode-toggle.tsx +++ b/apps/docs/src/components/playground/color-mode-toggle.tsx @@ -5,7 +5,7 @@ import { IconToggleButtonGroup } from './icon-toggle-button-group.js'; const COLOR_MODES = [ { icon: 'sun', label: 'Light theme', value: 'light' }, { icon: 'moon', label: 'Dark theme', value: 'dark' }, - { icon: 'monitor', label: 'System theme', value: 'system' }, + { icon: 'circleHalf', label: 'System theme', value: 'system' }, ] as const; type ColorMode = (typeof COLOR_MODES)[number]['value']; diff --git a/apps/docs/src/components/playground/icon-toggle-button-group.tsx b/apps/docs/src/components/playground/icon-toggle-button-group.tsx index c4f1137d..941763ef 100644 --- a/apps/docs/src/components/playground/icon-toggle-button-group.tsx +++ b/apps/docs/src/components/playground/icon-toggle-button-group.tsx @@ -5,6 +5,29 @@ import type { Selection } from 'react-aria-components/GridList'; import { ToggleButton } from 'react-aria-components/ToggleButton'; import { ToggleButtonGroup } from 'react-aria-components/ToggleButtonGroup'; +/** Rounded pill group shared by every toggle group in this file, matched on shape and focus. */ +const GROUP_CLASS_NAME = 'flex items-center rounded-full bg-fd-secondary p-0.5'; + +/** Selected/unselected pill treatment shared by icon and text toggle buttons. */ +const PILL_CLASS_NAME = + 'cursor-pointer rounded-full text-fd-muted-foreground transition-colors data-hovered:bg-fd-accent data-hovered:text-fd-accent-foreground data-pressed:bg-fd-accent'; +const PILL_SELECTED_CLASS_NAME = 'bg-fd-background text-fd-foreground shadow-sm'; + +function toSelectionChangeHandler( + options: ReadonlyArray<{ value: Value }>, + onChange: (value: Value) => void, +) { + return (selection: Selection) => { + if (selection === 'all') return; + + const selectedKey = selection.values().next().value; + const selectedOption = options.find((option) => option.value === selectedKey); + if (!selectedOption) return; + + onChange(selectedOption.value); + }; +} + type IconToggleItem = { icon: IconName; label: string; @@ -18,28 +41,19 @@ type IconToggleButtonGroupProps = { value: Value | null; }; +/** A round icon-only pill group, for choices with well-known glyphs such as light/dark/system. */ export function IconToggleButtonGroup({ label, onChange, options, value, }: IconToggleButtonGroupProps) { - const handleSelectionChange = (selection: Selection) => { - if (selection === 'all') return; - - const selectedKey = selection.values().next().value; - const selectedOption = options.find((option) => option.value === selectedKey); - if (!selectedOption) return; - - onChange(selectedOption.value); - }; - return ( ({ {options.map(({ icon, label: optionLabel, value: optionValue }) => ( { - return cx( - 'flex size-8 cursor-pointer items-center justify-center rounded-full text-fd-muted-foreground transition-colors data-hovered:bg-fd-accent data-hovered:text-fd-accent-foreground data-pressed:bg-fd-accent', - isSelected && 'bg-fd-background text-fd-foreground shadow-sm', - ); - }} + className={({ isSelected }) => + cx( + 'flex size-8 items-center justify-center', + PILL_CLASS_NAME, + isSelected && PILL_SELECTED_CLASS_NAME, + ) + } id={optionValue} key={optionValue} > @@ -62,3 +77,54 @@ export function IconToggleButtonGroup({ ); } + +type TextToggleItem = { + label: string; + value: Value; +}; + +type TextToggleButtonGroupProps = { + label: string; + onChange: (value: Value) => void; + options: ReadonlyArray>; + value: Value; +}; + +/** + * A round pill group with visible text labels, for choices without an established glyph, such as + * a named theme identity. Matches `IconToggleButtonGroup` in height, radius, and focus treatment. + */ +export function TextToggleButtonGroup({ + label, + onChange, + options, + value, +}: TextToggleButtonGroupProps) { + return ( + + {options.map(({ label: optionLabel, value: optionValue }) => ( + + cx( + 'flex h-8 items-center justify-center whitespace-nowrap px-3 font-medium text-xs', + PILL_CLASS_NAME, + isSelected && PILL_SELECTED_CLASS_NAME, + ) + } + id={optionValue} + key={optionValue} + > + {optionLabel} + + ))} + + ); +} diff --git a/apps/docs/src/components/theme-controls.browser.test.tsx b/apps/docs/src/components/theme-controls.browser.test.tsx index ecc75ce7..520fc420 100644 --- a/apps/docs/src/components/theme-controls.browser.test.tsx +++ b/apps/docs/src/components/theme-controls.browser.test.tsx @@ -1,6 +1,8 @@ import '../styles/app.css'; import '@luke-ui/react/themes/paper.css'; import '@luke-ui/react/themes/tactile.css'; +import { IconSpritesheetProvider } from '@luke-ui/react/icon'; +import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; import { paperThemeClassName } from '@luke-ui/react/themes'; import { ThemeProvider } from 'next-themes'; import { act } from 'react'; @@ -36,13 +38,13 @@ test('persists theme identity and colour mode independently', async () => { , ); - const profile = page.getByRole('combobox', { name: 'Theme profile' }); + const paperProfile = page.getByRole('radio', { name: 'Paper' }); const darkMode = page.getByRole('radio', { name: 'Dark theme' }); const themeRoot = getThemeRoot(); - await userEvent.selectOptions(profile, 'paper'); + await userEvent.click(paperProfile, { force: true }); - expect(profile).toHaveValue('paper'); + expect(paperProfile).toBeChecked(); expect(themeRoot).toHaveClass(paperThemeClassName); expect(themeRoot.dataset.colorMode).toBe('light'); @@ -54,12 +56,34 @@ test('persists theme identity and colour mode independently', async () => { unmountTheme(); renderTheme(); - expect(page.getByRole('combobox', { name: 'Theme profile' })).toHaveValue('paper'); + expect(page.getByRole('radio', { name: 'Paper' })).toBeChecked(); expect(page.getByRole('radio', { name: 'Dark theme' })).toBeChecked(); expect(getThemeRoot()).toHaveClass(paperThemeClassName); await expect.poll(() => getThemeRoot().dataset.colorMode).toBe('dark'); }); +test('theme profile is labelled and operable with arrow keys', async () => { + renderTheme(); + + const group = page.getByRole('radiogroup', { name: 'Theme profile' }); + const tactileProfile = page.getByRole('radio', { name: 'Tactile' }); + const paperProfile = page.getByRole('radio', { name: 'Paper' }); + + expect(group.element()).toBeInTheDocument(); + expect(tactileProfile).toBeChecked(); + + await userEvent.tab(); + expect(tactileProfile.element()).toHaveFocus(); + + await userEvent.keyboard('{ArrowRight}'); + expect(paperProfile.element()).toHaveFocus(); + + await userEvent.keyboard('{Enter}'); + + expect(paperProfile).toBeChecked(); + expect(getThemeRoot()).toHaveClass(paperThemeClassName); +}); + test('system colour mode follows the platform preference and drives the docs chrome', async () => { await emulateColorScheme('dark'); renderTheme(, { defaultTheme: 'system', enableSystem: true }); @@ -165,7 +189,9 @@ function renderTheme( defaultTheme={options.defaultTheme ?? 'light'} enableSystem={options.enableSystem ?? false} > - {children} + + {children} + , ); }); diff --git a/apps/docs/src/components/theme-controls.tsx b/apps/docs/src/components/theme-controls.tsx index d232f2c8..c1136f35 100644 --- a/apps/docs/src/components/theme-controls.tsx +++ b/apps/docs/src/components/theme-controls.tsx @@ -1,15 +1,21 @@ import { themeRootClassName } from '@luke-ui/react/theme'; import { paperThemeClassName, tactileThemeClassName } from '@luke-ui/react/themes'; import { cx } from '@luke-ui/react/utils'; -import type { ChangeEvent, ComponentProps, PropsWithChildren } from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; import { createContext, useContext, useMemo, useSyncExternalStore } from 'react'; import { ColorModeToggle, useHydratedColorMode } from './playground/color-mode-toggle.js'; +import { TextToggleButtonGroup } from './playground/icon-toggle-button-group.js'; export type ThemeIdentity = 'paper' | 'tactile'; const THEME_IDENTITY_STORAGE_KEY = 'luke-ui-docs-theme'; const THEME_IDENTITY_CHANGE_EVENT = 'luke-ui-docs-theme-change'; +const THEME_IDENTITIES = [ + { label: 'Tactile', value: 'tactile' }, + { label: 'Paper', value: 'paper' }, +] as const satisfies ReadonlyArray<{ label: string; value: ThemeIdentity }>; + interface ThemeIdentitySettings { setThemeIdentity: (themeIdentity: ThemeIdentity) => void; themeIdentity: ThemeIdentity; @@ -43,24 +49,14 @@ export function DocsThemeRoot({ children }: PropsWithChildren) { export function ThemeControls({ className, ...props }: ComponentProps<'div'>) { const { setThemeIdentity, themeIdentity } = useDocsThemeIdentity(); - function handleThemeChange(event: ChangeEvent) { - setThemeIdentity(event.target.value === 'paper' ? 'paper' : 'tactile'); - } - return (
- +
); diff --git a/packages/@luke-ui/react/icons/circleHalf.svg b/packages/@luke-ui/react/icons/circleHalf.svg new file mode 100644 index 00000000..1f37069b --- /dev/null +++ b/packages/@luke-ui/react/icons/circleHalf.svg @@ -0,0 +1,4 @@ +