From 919df8211ce60539d784a93f93712076141159b7 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Tue, 7 Jan 2025 11:24:12 -0500 Subject: [PATCH] feat(theming): add `ColorSchemeProvider` (#1991) ...and associated `useColorScheme` hook --- .storybook/preview.js | 7 +- packages/theming/README.md | 32 ++++- .../demo/colorSchemeProvider.stories.mdx | 22 +++ .../demo/stories/ColorSchemeProviderStory.tsx | 131 ++++++++++++++++++ .../theming/demo/themeProvider.stories.mdx | 43 ++++++ packages/theming/demo/utilities.stories.mdx | 36 +---- .../src/elements/ColorSchemeProvider.tsx | 95 +++++++++++++ packages/theming/src/index.ts | 5 + packages/theming/src/types/index.ts | 25 ++++ .../theming/src/utils/useColorScheme.spec.tsx | 75 ++++++++++ packages/theming/src/utils/useColorScheme.ts | 24 ++++ 11 files changed, 461 insertions(+), 34 deletions(-) create mode 100644 packages/theming/demo/colorSchemeProvider.stories.mdx create mode 100644 packages/theming/demo/stories/ColorSchemeProviderStory.tsx create mode 100644 packages/theming/demo/themeProvider.stories.mdx create mode 100644 packages/theming/src/elements/ColorSchemeProvider.tsx create mode 100644 packages/theming/src/utils/useColorScheme.spec.tsx create mode 100644 packages/theming/src/utils/useColorScheme.ts diff --git a/.storybook/preview.js b/.storybook/preview.js index 9a51f314d65..3c9f2283ddd 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -45,11 +45,14 @@ export const parameters = { }; const GlobalPreviewStyling = createGlobalStyle` - body { + html { background-color: ${p => getColor({ theme: p.theme, variable: 'background.default' })}; + color: ${p => getColor({ theme: p.theme, variable: 'foreground.default' })}; + } + + body { /* stylelint-disable-next-line declaration-no-important */ padding: 0 !important; - color: ${p => getColor({ theme: p.theme, variable: 'foreground.default' })}; font-family: ${p => p.theme.fonts.system}; } `; diff --git a/packages/theming/README.md b/packages/theming/README.md index 796491a344b..2aa559116eb 100644 --- a/packages/theming/README.md +++ b/packages/theming/README.md @@ -42,7 +42,37 @@ complex, depending on your needs: behavior and RTL layout of Garden's tabs component with an alternate visual design (i.e. closer to the look of browser tabs). -### RTL +#### Color scheme + +The `ColorSchemeProvider` and `useColorScheme` hook add the capability for a +user to persist a preferred system color scheme (`'light'`, `'dark'`, or +`'system'`). See +[Storybook](https://zendeskgarden.github.io/react-components/?path=/docs/packages-theming-colorschemeprovider--color-scheme-provider) +for more details. + +```jsx +import { + useColorScheme, + ColorSchemeProvider, + ThemeProvider, + DEFAULT_THEME +} from '@zendeskgarden/react-theming'; + +const ThemedApp = ({ children }) => { + const { colorScheme } = useColorScheme(); + const theme = { ...DEFAULT_THEME, colors: { ...DEFAULT_THEME.colors, base: colorScheme } }; + + return {children}; +}; + +const App = ({ children }) => ( + + {children} + +); +``` + +#### RTL ```jsx import { ThemeProvider, DEFAULT_THEME } from '@zendeskgarden/react-theming'; diff --git a/packages/theming/demo/colorSchemeProvider.stories.mdx b/packages/theming/demo/colorSchemeProvider.stories.mdx new file mode 100644 index 00000000000..bc4a49513ed --- /dev/null +++ b/packages/theming/demo/colorSchemeProvider.stories.mdx @@ -0,0 +1,22 @@ +import { Meta, ArgsTable, Canvas, Story, Markdown } from '@storybook/addon-docs'; +import { ColorSchemeProvider } from '@zendeskgarden/react-theming'; +import { ColorSchemeProviderStory } from './stories/ColorSchemeProviderStory'; +import README from '../README.md'; + + + +# API + + + +# Demo + +## ColorSchemeProvider + + + + {args => } + + + +{README} diff --git a/packages/theming/demo/stories/ColorSchemeProviderStory.tsx b/packages/theming/demo/stories/ColorSchemeProviderStory.tsx new file mode 100644 index 00000000000..654df6d1804 --- /dev/null +++ b/packages/theming/demo/stories/ColorSchemeProviderStory.tsx @@ -0,0 +1,131 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { useEffect, useState } from 'react'; +import styled, { ThemeProvider, useTheme } from 'styled-components'; +import { StoryFn } from '@storybook/react'; +import ClearIcon from '@zendeskgarden/svg-icons/src/16/x-stroke.svg'; +import DarkIcon from '@zendeskgarden/svg-icons/src/16/moon-stroke.svg'; +import LightIcon from '@zendeskgarden/svg-icons/src/16/sun-stroke.svg'; +import SystemIcon from '@zendeskgarden/svg-icons/src/16/monitor-stroke.svg'; +import { + ColorScheme, + ColorSchemeProvider, + getColor, + IColorSchemeProviderProps, + IGardenTheme, + useColorScheme, + useWindow +} from '@zendeskgarden/react-theming'; +import { Grid } from '@zendeskgarden/react-grid'; +import { IconButton } from '@zendeskgarden/react-buttons'; +import { IMenuProps, Item, ItemGroup, Menu } from '@zendeskgarden/react-dropdowns'; +import { Field, Input } from '@zendeskgarden/react-forms'; +import { Code } from '@zendeskgarden/react-typography'; +import { Tooltip } from '@zendeskgarden/react-tooltips'; + +const StyledGrid = styled(Grid)` + background-color: ${p => getColor({ theme: p.theme, variable: 'background.default' })}; +`; + +const StyledIconButton = styled(IconButton)` + position: absolute; + right: ${p => p.theme.space.base * 3}px; + bottom: ${p => p.theme.space.base}px; +`; + +const Content = ({ + colorSchemeKey = 'color-scheme' +}: { + colorSchemeKey: IColorSchemeProviderProps['colorSchemeKey']; +}) => { + const win = useWindow(); + const localStorage = win?.localStorage; + const { colorScheme, isSystem, setColorScheme } = useColorScheme(); + const [inputValue, setInputValue] = useState(''); + const _theme = useTheme() as IGardenTheme; + const theme = { ..._theme, colors: { ..._theme.colors, base: colorScheme } }; + + const handleChange: IMenuProps['onChange'] = changes => { + if (changes.value) { + setColorScheme(changes.value as ColorScheme); + } + }; + + const handleClear = () => { + localStorage?.removeItem(colorSchemeKey); + setInputValue(''); + }; + + useEffect(() => { + setInputValue(localStorage?.getItem(colorSchemeKey) || ''); + }, [colorSchemeKey, colorScheme, isSystem, localStorage]); + + return ( + + + + +
+ + + Local {!!colorSchemeKey && {colorSchemeKey}} storage + + + {!!inputValue && ( + + + + + + )} + +
+
+ + ( + + {theme.colors.base === 'dark' ? : } + + )} + onChange={handleChange} + placement="bottom-end" + selectedItems={[{ value: isSystem ? 'system' : colorScheme }]} + > + + } value="light"> + Light + + } value="dark"> + Dark + + } isSelected value="system"> + System + + + + +
+
+
+ ); +}; + +export const ColorSchemeProviderStory: StoryFn = ({ + colorSchemeKey, + initialColorScheme +}) => ( + + + +); diff --git a/packages/theming/demo/themeProvider.stories.mdx b/packages/theming/demo/themeProvider.stories.mdx new file mode 100644 index 00000000000..abf037797b9 --- /dev/null +++ b/packages/theming/demo/themeProvider.stories.mdx @@ -0,0 +1,43 @@ +import { Meta, ArgsTable, Canvas, Story, Markdown } from '@storybook/addon-docs'; +import { ThemeProvider, DEFAULT_THEME, PALETTE } from '@zendeskgarden/react-theming'; +import { PaletteStory } from './stories/PaletteStory'; +import README from '../README.md'; + + + +# API + + + +# Demo + +## ThemeProvider + + + + {args => } + + + +## PALETTE + + + + {args => } + + + +{README} diff --git a/packages/theming/demo/utilities.stories.mdx b/packages/theming/demo/utilities.stories.mdx index 75d41898fdc..1f796704e76 100644 --- a/packages/theming/demo/utilities.stories.mdx +++ b/packages/theming/demo/utilities.stories.mdx @@ -1,39 +1,15 @@ -import { Meta, ArgsTable, Canvas, Story, Markdown } from '@storybook/addon-docs'; -import { ThemeProvider, DEFAULT_THEME, PALETTE } from '@zendeskgarden/react-theming'; -import { PaletteStory } from './stories/PaletteStory'; +import { Meta, Canvas, Story, Markdown } from '@storybook/addon-docs'; +import { DEFAULT_THEME } from '@zendeskgarden/react-theming'; import { ArrowStylesStory } from './stories/ArrowStylesStory'; import { MenuStylesStory } from './stories/MenuStylesStory'; import { GetColorStory } from './stories/GetColorStory'; import { ARROW_POSITIONS, MENU_POSITIONS } from './stories/data'; import README from '../README.md'; - - -# API - - + # Demo -## PALETTE - - - - {args => } - - - ## arrowStyles() @@ -50,8 +26,7 @@ import README from '../README.md'; argTypes={{ position: { control: 'select', options: ARROW_POSITIONS }, size: { control: { type: 'range', min: 2, max: 10, step: 1 } }, - inset: { control: { type: 'range', min: -4, max: 4, step: 1 } }, - theme: { control: false } + inset: { control: { type: 'range', min: -4, max: 4, step: 1 } } }} > {args => } @@ -99,8 +74,7 @@ import README from '../README.md'; isAnimated: true }} argTypes={{ - position: { control: 'radio', options: MENU_POSITIONS }, - theme: { control: false } + position: { control: 'radio', options: MENU_POSITIONS } }} > {args => } diff --git a/packages/theming/src/elements/ColorSchemeProvider.tsx b/packages/theming/src/elements/ColorSchemeProvider.tsx new file mode 100644 index 00000000000..ea7a5a67921 --- /dev/null +++ b/packages/theming/src/elements/ColorSchemeProvider.tsx @@ -0,0 +1,95 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { + createContext, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; +import { + ColorScheme, + IColorSchemeContext, + IColorSchemeProviderProps, + IGardenTheme +} from '../types'; + +const useColorScheme = (initialState?: ColorScheme, colorSchemeKey = 'color-scheme') => { + /* eslint-disable-next-line n/no-unsupported-features/node-builtins */ + const localStorage = typeof window === 'undefined' ? undefined : window.localStorage; + const mediaQuery = + typeof window === 'undefined' ? undefined : window.matchMedia('(prefers-color-scheme: dark)'); + + const getState = useCallback( + (_state?: ColorScheme | null) => { + const isSystem = _state === 'system' || _state === undefined || _state === null; + let colorScheme: IGardenTheme['colors']['base']; + + if (isSystem) { + colorScheme = mediaQuery?.matches ? 'dark' : 'light'; + } else { + colorScheme = _state; + } + + return { isSystem, colorScheme }; + }, + [mediaQuery?.matches] + ); + + const [state, setState] = useState<{ + isSystem: boolean; + colorScheme: IGardenTheme['colors']['base']; + }>(getState((localStorage?.getItem(colorSchemeKey) as ColorScheme) || initialState)); + + useEffect(() => { + // Listen for changes to the system color scheme + /* istanbul ignore next */ + const eventListener = () => { + setState(getState('system')); + }; + + if (state.isSystem) { + mediaQuery?.addEventListener('change', eventListener); + } else { + mediaQuery?.removeEventListener('change', eventListener); + } + + return () => { + mediaQuery?.removeEventListener('change', eventListener); + }; + }, [getState, state.isSystem, mediaQuery]); + + return { + isSystem: state.isSystem, + colorScheme: state.colorScheme, + setColorScheme: (colorScheme: ColorScheme) => { + setState(getState(colorScheme)); + localStorage?.setItem(colorSchemeKey, colorScheme); + } + }; +}; + +export const ColorSchemeContext = createContext(undefined); + +export const ColorSchemeProvider = ({ + children, + colorSchemeKey, + initialColorScheme +}: PropsWithChildren) => { + const { isSystem, colorScheme, setColorScheme } = useColorScheme( + initialColorScheme, + colorSchemeKey + ); + const contextValue = useMemo( + () => ({ colorScheme, isSystem, setColorScheme }), + [isSystem, colorScheme, setColorScheme] + ); + + return {children}; +}; diff --git a/packages/theming/src/index.ts b/packages/theming/src/index.ts index c4548f585dc..9dac3124f46 100644 --- a/packages/theming/src/index.ts +++ b/packages/theming/src/index.ts @@ -5,6 +5,7 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ +export { ColorSchemeProvider } from './elements/ColorSchemeProvider'; export { ThemeProvider } from './elements/ThemeProvider'; export { default as DEFAULT_THEME } from './elements/theme'; export { default as PALETTE } from './elements/palette'; @@ -20,6 +21,7 @@ export { default as getLineHeight } from './utils/getLineHeight'; export { getMenuPosition } from './utils/getMenuPosition'; export { default as mediaQuery } from './utils/mediaQuery'; export { default as arrowStyles } from './utils/arrowStyles'; +export { useColorScheme } from './utils/useColorScheme'; export { useDocument } from './utils/useDocument'; export { useWindow } from './utils/useWindow'; export { useText } from './utils/useText'; @@ -31,12 +33,15 @@ export { ARROW_POSITION, MENU_POSITION, PLACEMENT, + type IColorSchemeContext, + type IColorSchemeProviderProps, type IGardenTheme, type IStyledBaseIconProps, type IThemeProviderProps, type ArrowPosition, type CheckeredBackgroundParameters, type ColorParameters, + type ColorScheme, type FocusBoxShadowParameters, type FocusStylesParameters, type MenuPosition, diff --git a/packages/theming/src/types/index.ts b/packages/theming/src/types/index.ts index d7954d77a6e..25e3d7e5350 100644 --- a/packages/theming/src/types/index.ts +++ b/packages/theming/src/types/index.ts @@ -205,6 +205,31 @@ export interface IGardenTheme { }; } +export type ColorScheme = IGardenTheme['colors']['base'] | 'system'; + +export interface IColorSchemeContext { + /** Returns the current color scheme */ + colorScheme: IGardenTheme['colors']['base']; + /** Indicates whether the `colorScheme` is determined by the system */ + isSystem: boolean; + /** Provides the mechanism for updating the current color scheme */ + setColorScheme: (colorScheme: ColorScheme) => void; +} + +export interface IColorSchemeProviderProps { + /** + * Sets the initial color scheme and provides `localStorage` persistence (see + * the `useColorScheme` hook). A user's stored preference overrides this + * value. + */ + initialColorScheme?: ColorScheme; + /** + * Specifies the key used to store the user's preferred color scheme in + * `localStorage` + */ + colorSchemeKey?: string; +} + export interface IThemeProviderProps extends Partial> { /** * Provides values for component styling. See styled-components diff --git a/packages/theming/src/utils/useColorScheme.spec.tsx b/packages/theming/src/utils/useColorScheme.spec.tsx new file mode 100644 index 00000000000..967c5aadde3 --- /dev/null +++ b/packages/theming/src/utils/useColorScheme.spec.tsx @@ -0,0 +1,75 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import React, { useEffect } from 'react'; +import { render } from 'garden-test-utils'; +import { useColorScheme } from './useColorScheme'; +import { ColorSchemeProvider } from '../elements/ColorSchemeProvider'; + +const ColorSchemeConsumer = () => { + const { colorScheme, setColorScheme } = useColorScheme(); + + useEffect( + () => { + setColorScheme('system'); + }, + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + [] + ); + + return
{!!colorScheme && 'it worked'}
; +}; + +describe('useColorScheme', () => { + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn() + })) + }); + }); + + it('sets the color scheme as expected', () => { + const Test = () => ( + + + + ); + + expect(() => { + render(); + }).not.toThrow(); + + /* eslint-disable-next-line n/no-unsupported-features/node-builtins */ + expect(window.localStorage.getItem('color-scheme')).toBe('system'); + }); + + describe('Errors', () => { + let originalError: typeof console.error; + + beforeEach(() => { + originalError = console.error; + console.error = jest.fn(); + }); + + it('throws if called outside of `ColorSchemeProvider`', () => { + const Test = () => ; + + expect(() => { + render(); + }).toThrow(); + }); + + afterEach(() => { + console.error = originalError; + }); + }); +}); diff --git a/packages/theming/src/utils/useColorScheme.ts b/packages/theming/src/utils/useColorScheme.ts new file mode 100644 index 00000000000..ee61e916396 --- /dev/null +++ b/packages/theming/src/utils/useColorScheme.ts @@ -0,0 +1,24 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import { useContext } from 'react'; +import { ColorSchemeContext } from '../elements/ColorSchemeProvider'; + +/** + * Provides the current color scheme for the context `ThemeProvider`. + * + * @returns {object} Current color scheme accessor and mutator. + */ +export const useColorScheme = () => { + const context = useContext(ColorSchemeContext); + + if (!context) { + throw new Error('Error: this component must be rendered within a .'); + } + + return context; +};