diff --git a/packages/radix-ui-themes/src/components/theme-panel.tsx b/packages/radix-ui-themes/src/components/theme-panel.tsx index c6633a18..dd972c81 100644 --- a/packages/radix-ui-themes/src/components/theme-panel.tsx +++ b/packages/radix-ui-themes/src/components/theme-panel.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { AccessibleIcon, Box, @@ -23,605 +24,628 @@ import { themePropDefs } from '../props/index.js'; import type { ComponentPropsWithout, RemovedProps } from '../helpers/index.js'; import type { GetPropDefTypes } from '../props/index.js'; -interface ThemePanelProps extends Omit { +const keyboardInputElement = ` + [contenteditable], + [role="combobox"], + [role="listbox"], + [role="menu"], + input:not([type="radio"], [type="checkbox"]), + select, + textarea +`; + +/** Listen to keydown events and fire a callback when a hotkey is pressed */ +const useHotKey = ( + key: string | null | false, + callback: () => void, + options?: { + /** Determines whether the hotkey listener is active or not */ + enabled?: boolean; + } +) => { + const { enabled = true } = options ?? {}; + const callbackRef = useCallbackRef(callback); + React.useEffect(() => { + if (!key) return; + if (enabled === false) return; + function handleKeydown(event: KeyboardEvent) { + const isModifierActive = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; + const isKeyboardInputActive = document.activeElement?.closest(keyboardInputElement); + const isKeyActive = event.key?.toUpperCase() === (key as string).toUpperCase(); + if (isKeyActive && !isModifierActive && !isKeyboardInputActive) { + callbackRef(); + } + } + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); + }, [key, callbackRef, enabled]); +}; + +type ThemePanelElement = React.ElementRef<'div'>; + +interface ThemePanelProps extends ComponentPropsWithout<'div', RemovedProps> { + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** Whether the theme panel is open by default. + * Doesn't have any effect if `open` is also set. + * @default true + */ defaultOpen?: boolean; + onAppearanceChange?: (value: 'light' | 'dark') => void; + /** A hotkey to quickly show/hide the panel. + * Set to `null` or `false` to disable the hotkey. + * @default "T" + */ + openHotkey?: string | null | false; + /** A hotkey to quickly toggle the appearance. + * Set to `null` or `false` to disable the hotkey. + * @default "D" + * */ + toogleAppearanceHotkey?: string | null | false; } -const ThemePanel = React.forwardRef( - ({ defaultOpen = true, ...props }, forwardedRef) => { - const [open, setOpen] = React.useState(defaultOpen); - return ; + +const ThemePanel = React.forwardRef((props, forwardedRef) => { + const { + open: openProp, + defaultOpen, + onOpenChange, + onAppearanceChange: onAppearanceChangeProp, + openHotkey = 'T', + toogleAppearanceHotkey = 'D', + ...panelProps + } = props; + const themeContext = useThemeContext(); + const { + appearance, + onAppearanceChange, + accentColor, + onAccentColorChange, + grayColor, + onGrayColorChange, + panelBackground, + onPanelBackgroundChange, + radius, + onRadiusChange, + scaling, + onScalingChange, + } = themeContext; + + const [open = true, setOpen] = useControllableState({ + prop: openProp, + defaultProp: defaultOpen, + onChange: onOpenChange, + }); + + const hasOnAppearanceChangeProp = onAppearanceChangeProp !== undefined; + const handleAppearanceChangeProp = useCallbackRef(onAppearanceChangeProp); + const handleAppearanceChange = React.useCallback( + (value: 'light' | 'dark') => { + const cleanup = disableAnimation(); + + if (appearance !== 'inherit') { + onAppearanceChange(value); + return; + } + + if (hasOnAppearanceChangeProp) { + handleAppearanceChangeProp(value); + } else { + setResolvedAppearance(value); + updateRootAppearanceClass(value); + } + + cleanup(); + }, + [appearance, onAppearanceChange, hasOnAppearanceChangeProp, handleAppearanceChangeProp] + ); + + const autoMatchedGray = getMatchingGrayColor(accentColor); + const resolvedGrayColor = grayColor === 'auto' ? autoMatchedGray : grayColor; + + const [copyState, setCopyState] = React.useState<'idle' | 'copying' | 'copied'>('idle'); + async function handleCopyThemeConfig() { + const theme = { + appearance: appearance === themePropDefs.appearance.default ? undefined : appearance, + accentColor: accentColor === themePropDefs.accentColor.default ? undefined : accentColor, + grayColor: grayColor === themePropDefs.grayColor.default ? undefined : grayColor, + panelBackground: + panelBackground === themePropDefs.panelBackground.default ? undefined : panelBackground, + radius: radius === themePropDefs.radius.default ? undefined : radius, + scaling: scaling === themePropDefs.scaling.default ? undefined : scaling, + } satisfies GetPropDefTypes; + + const props = Object.keys(theme) + .filter((key) => theme[key as keyof typeof theme] !== undefined) + .map((key) => `${key}="${theme[key as keyof typeof theme]}"`) + .join(' '); + + const textToCopy = props ? `` : ''; + + setCopyState('copying'); + await navigator.clipboard.writeText(textToCopy); + setCopyState('copied'); + setTimeout(() => setCopyState('idle'), 2000); } -); -ThemePanel.displayName = 'ThemePanel'; -type ThemePanelImplElement = React.ElementRef<'div'>; -interface ThemePanelImplPrivateProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} -interface ThemePanelImplProps - extends ComponentPropsWithout<'div', RemovedProps>, - ThemePanelImplPrivateProps { - onAppearanceChange?: (value: 'light' | 'dark') => void; -} -const ThemePanelImpl = React.forwardRef( - (props, forwardedRef) => { - const { open, onOpenChange, onAppearanceChange: onAppearanceChangeProp, ...panelProps } = props; - const themeContext = useThemeContext(); - const { - appearance, - onAppearanceChange, - accentColor, - onAccentColorChange, - grayColor, - onGrayColorChange, - panelBackground, - onPanelBackgroundChange, - radius, - onRadiusChange, - scaling, - onScalingChange, - } = themeContext; - - const hasOnAppearanceChangeProp = onAppearanceChangeProp !== undefined; - const handleAppearanceChangeProp = useCallbackRef(onAppearanceChangeProp); - const handleAppearanceChange = React.useCallback( - (value: 'light' | 'dark') => { - const cleanup = disableAnimation(); - - if (appearance !== 'inherit') { - onAppearanceChange(value); - return; - } + const [resolvedAppearance, setResolvedAppearance] = React.useState<'light' | 'dark' | null>( + appearance === 'inherit' ? null : appearance + ); - if (hasOnAppearanceChangeProp) { - handleAppearanceChangeProp(value); - } else { - setResolvedAppearance(value); - updateRootAppearanceClass(value); - } + // quickly show/hide using "T" keypress + const toggleOpen = React.useCallback(() => setOpen((prev) => !prev), [setOpen]); + useHotKey(openHotkey, toggleOpen); - cleanup(); - }, - [appearance, onAppearanceChange, hasOnAppearanceChangeProp, handleAppearanceChangeProp] - ); - - const autoMatchedGray = getMatchingGrayColor(accentColor); - const resolvedGrayColor = grayColor === 'auto' ? autoMatchedGray : grayColor; - - const [copyState, setCopyState] = React.useState<'idle' | 'copying' | 'copied'>('idle'); - async function handleCopyThemeConfig() { - const theme = { - appearance: appearance === themePropDefs.appearance.default ? undefined : appearance, - accentColor: accentColor === themePropDefs.accentColor.default ? undefined : accentColor, - grayColor: grayColor === themePropDefs.grayColor.default ? undefined : grayColor, - panelBackground: - panelBackground === themePropDefs.panelBackground.default ? undefined : panelBackground, - radius: radius === themePropDefs.radius.default ? undefined : radius, - scaling: scaling === themePropDefs.scaling.default ? undefined : scaling, - } satisfies GetPropDefTypes; - - const props = Object.keys(theme) - .filter((key) => theme[key as keyof typeof theme] !== undefined) - .map((key) => `${key}="${theme[key as keyof typeof theme]}"`) - .join(' '); - - const textToCopy = props ? `` : ''; - - setCopyState('copying'); - await navigator.clipboard.writeText(textToCopy); - setCopyState('copied'); - setTimeout(() => setCopyState('idle'), 2000); - } + // quickly toggle appearance using "D" keypress + const toggleAppearance = React.useCallback( + () => handleAppearanceChange(resolvedAppearance === 'light' ? 'dark' : 'light'), + [handleAppearanceChange, resolvedAppearance] + ); + useHotKey(toogleAppearanceHotkey, toggleAppearance); - const [resolvedAppearance, setResolvedAppearance] = React.useState<'light' | 'dark' | null>( - appearance === 'inherit' ? null : appearance - ); - - const keyboardInputElement = ` - [contenteditable], - [role="combobox"], - [role="listbox"], - [role="menu"], - input:not([type="radio"], [type="checkbox"]), - select, - textarea - `; - - // quickly show/hide using "T" keypress - React.useEffect(() => { - function handleKeydown(event: KeyboardEvent) { - const isModifierActive = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; - const isKeyboardInputActive = document.activeElement?.closest(keyboardInputElement); - const isKeyT = event.key?.toUpperCase() === 'T' && !isModifierActive; - if (isKeyT && !isKeyboardInputActive) { - onOpenChange(!open); - } - } - document.addEventListener('keydown', handleKeydown); - return () => document.removeEventListener('keydown', handleKeydown); - }, [onOpenChange, open, keyboardInputElement]); - - // quickly toggle appearance using "D" keypress - React.useEffect(() => { - function handleKeydown(event: KeyboardEvent) { - const isModifierActive = event.altKey || event.ctrlKey || event.shiftKey || event.metaKey; - const isKeyboardInputActive = document.activeElement?.closest(keyboardInputElement); - const isKeyD = event.key?.toUpperCase() === 'D' && !isModifierActive; - if (isKeyD && !isKeyboardInputActive) { - handleAppearanceChange(resolvedAppearance === 'light' ? 'dark' : 'light'); - } - } - document.addEventListener('keydown', handleKeydown); - return () => document.removeEventListener('keydown', handleKeydown); - }, [handleAppearanceChange, resolvedAppearance, keyboardInputElement]); - - React.useEffect(() => { - const root = document.documentElement; - const body = document.body; - - function update() { - const hasDarkClass = - root.classList.contains('dark') || - root.classList.contains('dark-theme') || - body.classList.contains('dark') || - body.classList.contains('dark-theme'); - - if (appearance === 'inherit') { - setResolvedAppearance(hasDarkClass ? 'dark' : 'light'); - } else { - setResolvedAppearance(appearance); - } + React.useEffect(() => { + const root = document.documentElement; + const body = document.body; + + function update() { + const hasDarkClass = + root.classList.contains('dark') || + root.classList.contains('dark-theme') || + body.classList.contains('dark') || + body.classList.contains('dark-theme'); + + if (appearance === 'inherit') { + setResolvedAppearance(hasDarkClass ? 'dark' : 'light'); + } else { + setResolvedAppearance(appearance); } + } - const classNameObserver = new MutationObserver(function (mutations) { - mutations.forEach(function (mutation) { - if (mutation.attributeName === 'class') { - update(); - } - }); + const classNameObserver = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + if (mutation.attributeName === 'class') { + update(); + } }); + }); - update(); + update(); - // Observe and for `class` changes only when the appearance is inherited from them - if (appearance === 'inherit') { - classNameObserver.observe(root, { attributes: true }); - classNameObserver.observe(body, { attributes: true }); - } + // Observe and for `class` changes only when the appearance is inherited from them + if (appearance === 'inherit') { + classNameObserver.observe(root, { attributes: true }); + classNameObserver.observe(body, { attributes: true }); + } - return () => classNameObserver.disconnect(); - }, [appearance]); - - return ( - - - - + return () => classNameObserver.disconnect(); + }, [appearance]); + + return ( + + + + + {!!openHotkey && ( - + - - - Theme - - - - Accent color + )} + + + Theme + + + + Accent color + + + + {themePropDefs.accentColor.values.map((color) => ( + + ))} + + + + + Gray color + - - {themePropDefs.accentColor.values.map((color) => ( + + {themePropDefs.grayColor.values.map((gray) => ( + - ))} - - - - - Gray color - - - - - {themePropDefs.grayColor.values.map((gray) => ( - - + + + Appearance + + + + {(['light', 'dark'] as const).map((value) => ( + + + + ) : ( + + + + )} + + {upperFirst(value)} + - ))} - - - - Appearance - - - - {(['light', 'dark'] as const).map((value) => ( - + ))} + + + + Radius + + + + {themePropDefs.radius.values.map((value) => ( + + - ))} - - - - Radius - - - - {themePropDefs.radius.values.map((value) => ( - - - - - - - + + + + + + + + + ))} + + + + Scaling + + + + {themePropDefs.scaling.values.map((value) => ( + + + ))} + - - Scaling + + + Panel background - - {themePropDefs.scaling.values.map((value) => ( - - ))} - - - - - Panel background - - - - - - - + + + + + + + + + + + + + Whether Card and Table panels are translucent, showing some of the background + behind them. + + + + + + + {themePropDefs.panelBackground.values.map((value) => ( + - - ); - } -); -ThemePanelImpl.displayName = 'ThemePanelImpl'; + + + ))} + + + + + + + + ); +}); + +ThemePanel.displayName = 'ThemePanel'; // https://github.com/pacocoursey/next-themes/blob/main/packages/next-themes/src/index.tsx#L285 function disableAnimation() {