From d35b353d880779df83e369ef3e12647757d5f951 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 13 Dec 2024 12:42:23 -0500 Subject: [PATCH 1/7] feat(theming): add `initialColorScheme` to `ThemeProvider` to configure a color scheme provider --- packages/theming/demo/utilities.stories.mdx | 16 ++- .../src/elements/ColorSchemeProvider.tsx | 29 +++++ .../theming/src/elements/ThemeProvider.tsx | 104 +++++++++++++++++- packages/theming/src/index.ts | 3 + packages/theming/src/types/index.ts | 20 ++++ packages/theming/src/utils/useColorScheme.ts | 26 +++++ 6 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 packages/theming/src/elements/ColorSchemeProvider.tsx create mode 100644 packages/theming/src/utils/useColorScheme.ts diff --git a/packages/theming/demo/utilities.stories.mdx b/packages/theming/demo/utilities.stories.mdx index 75d41898fdc..708b5fff39e 100644 --- a/packages/theming/demo/utilities.stories.mdx +++ b/packages/theming/demo/utilities.stories.mdx @@ -27,7 +27,9 @@ import README from '../README.md'; args={{ palette: PALETTE }} argTypes={{ palette: { control: { type: 'object' }, name: 'PALETTE' }, - theme: { control: false } + colorSchemeKey: { table: { disable: true } }, + initialColorScheme: { table: { disable: true } }, + theme: { table: { disable: true } } }} > {args => } @@ -51,7 +53,9 @@ import README from '../README.md'; 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 } + colorSchemeKey: { table: { disable: true } }, + initialColorScheme: { table: { disable: true } }, + theme: { table: { disable: true } } }} > {args => } @@ -82,7 +86,9 @@ import README from '../README.md'; transparency: { control: { type: 'number', min: 100, max: 1200, step: 100 } }, variable: { control: { type: 'text' } }, 'colors.dark': { control: false, table: { disable: true } }, - 'colors.light': { control: false, table: { disable: true } } + 'colors.light': { control: false, table: { disable: true } }, + colorSchemeKey: { table: { disable: true } }, + initialColorScheme: { table: { disable: true } } }} > {args => } @@ -100,7 +106,9 @@ import README from '../README.md'; }} argTypes={{ position: { control: 'radio', options: MENU_POSITIONS }, - theme: { control: false } + colorSchemeKey: { table: { disable: true } }, + initialColorScheme: { table: { disable: true } }, + theme: { table: { disable: true } } }} > {args => } diff --git a/packages/theming/src/elements/ColorSchemeProvider.tsx b/packages/theming/src/elements/ColorSchemeProvider.tsx new file mode 100644 index 00000000000..d268bfa1c57 --- /dev/null +++ b/packages/theming/src/elements/ColorSchemeProvider.tsx @@ -0,0 +1,29 @@ +/** + * 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, useMemo } from 'react'; +import { ColorScheme, IColorSchemeContext } from '../types'; + +interface IColorSchemeProviderProps extends PropsWithChildren { + isSystem: boolean; +} + +export const ColorSchemeContext = createContext(undefined); + +export const ColorSchemeProvider = ({ + children, + isSystem, + colorScheme, + setColorScheme +}: IColorSchemeProviderProps) => { + const contextValue = useMemo( + () => ({ colorScheme: isSystem ? 'system' : (colorScheme as ColorScheme), setColorScheme }), + [isSystem, colorScheme, setColorScheme] + ); + + return {children}; +}; diff --git a/packages/theming/src/elements/ThemeProvider.tsx b/packages/theming/src/elements/ThemeProvider.tsx index f0a13e63944..8c1f16bd93a 100644 --- a/packages/theming/src/elements/ThemeProvider.tsx +++ b/packages/theming/src/elements/ThemeProvider.tsx @@ -5,14 +5,108 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import { ThemeProvider as StyledThemeProvider } from 'styled-components'; -import { IGardenTheme, IThemeProviderProps } from '../types'; +import { ColorScheme, IGardenTheme, IThemeProviderProps } from '../types'; +import { ColorSchemeProvider } from './ColorSchemeProvider'; import DEFAULT_THEME from './theme'; +export 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 + 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); + } + }; +}; + +const ColorSchemeThemeProvider = ({ + children, + colorSchemeKey, + initialColorScheme, + theme, + ...other +}: IThemeProviderProps) => { + const { isSystem, colorScheme, setColorScheme } = useColorScheme( + initialColorScheme, + colorSchemeKey + ); + const _theme = { + ...theme, + colors: { ...(theme as IGardenTheme).colors, base: colorScheme } + } as IGardenTheme; + + return ( + + + {children} + + + ); +}; + export const ThemeProvider = ({ theme = DEFAULT_THEME, + colorSchemeKey, + initialColorScheme, ...other -}: PropsWithChildren) => ( - -); +}: PropsWithChildren) => + initialColorScheme ? ( + + ) : ( + + ); diff --git a/packages/theming/src/index.ts b/packages/theming/src/index.ts index c4548f585dc..f0e57e8ccc3 100644 --- a/packages/theming/src/index.ts +++ b/packages/theming/src/index.ts @@ -20,6 +20,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 +32,14 @@ export { ARROW_POSITION, MENU_POSITION, PLACEMENT, + type IColorSchemeContext, 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..42bbabba220 100644 --- a/packages/theming/src/types/index.ts +++ b/packages/theming/src/types/index.ts @@ -205,6 +205,15 @@ export interface IGardenTheme { }; } +export type ColorScheme = IGardenTheme['colors']['base'] | 'system'; + +export interface IColorSchemeContext { + /** Returns the current color scheme */ + colorScheme: ColorScheme; + /** Provides the mechanism for updating the current color scheme */ + setColorScheme: (colorScheme: ColorScheme) => void; +} + export interface IThemeProviderProps extends Partial> { /** * Provides values for component styling. See styled-components @@ -212,6 +221,17 @@ export interface IThemeProviderProps extends Partial IGardenTheme); + /** + * Sets the initial color scheme and provides `localStorage` persistence (see + * the `useColorScheme` hook). Once a user's preference is stored, it will be + * used as the default. + */ + initialColorScheme?: ColorScheme; + /** + * Specifies the key used to store the user's preferred color scheme in + * `localStorage` + */ + colorSchemeKey?: string; } export interface IStyledBaseIconProps extends PropsWithChildren> { diff --git a/packages/theming/src/utils/useColorScheme.ts b/packages/theming/src/utils/useColorScheme.ts new file mode 100644 index 00000000000..5801226a8ab --- /dev/null +++ b/packages/theming/src/utils/useColorScheme.ts @@ -0,0 +1,26 @@ +/** + * 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; +}; From 7134a0b2104291429af2f4f61e4c96823498a4b4 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 13 Dec 2024 12:43:15 -0500 Subject: [PATCH 2/7] Apply background/foreground colors whether or not CSS bedrock is linked --- .storybook/preview.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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}; } `; From 2a17668831a740833fa363ef435e4df8373501c2 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 13 Dec 2024 15:52:06 -0500 Subject: [PATCH 3/7] Fill out `ThemeProvider` storybook demo --- .../demo/stories/ThemeProviderStory.tsx | 133 ++++++++++++++++++ .../theming/demo/themeProvider.stories.mdx | 48 +++++++ packages/theming/demo/utilities.stories.mdx | 46 +----- 3 files changed, 187 insertions(+), 40 deletions(-) create mode 100644 packages/theming/demo/stories/ThemeProviderStory.tsx create mode 100644 packages/theming/demo/themeProvider.stories.mdx diff --git a/packages/theming/demo/stories/ThemeProviderStory.tsx b/packages/theming/demo/stories/ThemeProviderStory.tsx new file mode 100644 index 00000000000..207c73e3e3a --- /dev/null +++ b/packages/theming/demo/stories/ThemeProviderStory.tsx @@ -0,0 +1,133 @@ +/** + * 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, { 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, + getColor, + IGardenTheme, + IThemeProviderProps, + ThemeProvider, + 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: IThemeProviderProps['colorSchemeKey']; +}) => { + const theme = useTheme() as IGardenTheme; + const win = useWindow(); + const localStorage = win?.localStorage; + const { colorScheme, setColorScheme } = useColorScheme(); + const [inputValue, setInputValue] = useState(''); + + 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, localStorage]); + + return ( + + + +
+ + + Local {!!colorSchemeKey && {colorSchemeKey}} storage + + + {!!inputValue && ( + + + + + + )} + +
+
+ + ( + + {theme.colors.base === 'dark' ? : } + + )} + onChange={handleChange} + placement="bottom-end" + selectedItems={[{ value: colorScheme }]} + > + + } value="light"> + Light + + } value="dark"> + Dark + + } isSelected value="system"> + System + + + + +
+
+ ); +}; + +export const ThemeProviderStory: StoryFn = ({ + colorSchemeKey, + initialColorScheme +}) => { + const theme = useTheme(); + + return ( + + + + ); +}; diff --git a/packages/theming/demo/themeProvider.stories.mdx b/packages/theming/demo/themeProvider.stories.mdx new file mode 100644 index 00000000000..69dbf9ae22d --- /dev/null +++ b/packages/theming/demo/themeProvider.stories.mdx @@ -0,0 +1,48 @@ +import { Meta, ArgsTable, Canvas, Story, Markdown } from '@storybook/addon-docs'; +import { ThemeProvider, DEFAULT_THEME, PALETTE } from '@zendeskgarden/react-theming'; +import { ThemeProviderStory } from './stories/ThemeProviderStory'; +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 708b5fff39e..1f796704e76 100644 --- a/packages/theming/demo/utilities.stories.mdx +++ b/packages/theming/demo/utilities.stories.mdx @@ -1,41 +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() @@ -52,10 +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 } }, - colorSchemeKey: { table: { disable: true } }, - initialColorScheme: { table: { disable: true } }, - theme: { table: { disable: true } } + inset: { control: { type: 'range', min: -4, max: 4, step: 1 } } }} > {args => } @@ -86,9 +57,7 @@ import README from '../README.md'; transparency: { control: { type: 'number', min: 100, max: 1200, step: 100 } }, variable: { control: { type: 'text' } }, 'colors.dark': { control: false, table: { disable: true } }, - 'colors.light': { control: false, table: { disable: true } }, - colorSchemeKey: { table: { disable: true } }, - initialColorScheme: { table: { disable: true } } + 'colors.light': { control: false, table: { disable: true } } }} > {args => } @@ -105,10 +74,7 @@ import README from '../README.md'; isAnimated: true }} argTypes={{ - position: { control: 'radio', options: MENU_POSITIONS }, - colorSchemeKey: { table: { disable: true } }, - initialColorScheme: { table: { disable: true } }, - theme: { table: { disable: true } } + position: { control: 'radio', options: MENU_POSITIONS } }} > {args => } From b01aa3450345bbc7c42756cbb928c75b898b2fed Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 13 Dec 2024 16:35:04 -0500 Subject: [PATCH 4/7] Add unit tests --- .../demo/stories/ThemeProviderStory.tsx | 2 +- .../theming/src/elements/ThemeProvider.tsx | 3 +- .../theming/src/utils/useColorScheme.spec.tsx | 87 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/theming/src/utils/useColorScheme.spec.tsx diff --git a/packages/theming/demo/stories/ThemeProviderStory.tsx b/packages/theming/demo/stories/ThemeProviderStory.tsx index 207c73e3e3a..b0fa803f14b 100644 --- a/packages/theming/demo/stories/ThemeProviderStory.tsx +++ b/packages/theming/demo/stories/ThemeProviderStory.tsx @@ -65,7 +65,7 @@ const Content = ({ }, [colorSchemeKey, colorScheme, localStorage]); return ( - +
diff --git a/packages/theming/src/elements/ThemeProvider.tsx b/packages/theming/src/elements/ThemeProvider.tsx index 8c1f16bd93a..edaaad24a47 100644 --- a/packages/theming/src/elements/ThemeProvider.tsx +++ b/packages/theming/src/elements/ThemeProvider.tsx @@ -11,7 +11,7 @@ import { ColorScheme, IGardenTheme, IThemeProviderProps } from '../types'; import { ColorSchemeProvider } from './ColorSchemeProvider'; import DEFAULT_THEME from './theme'; -export const useColorScheme = (initialState?: ColorScheme, colorSchemeKey = 'color-scheme') => { +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 = @@ -40,6 +40,7 @@ export const useColorScheme = (initialState?: ColorScheme, colorSchemeKey = 'col useEffect(() => { // Listen for changes to the system color scheme + /* istanbul ignore next */ const eventListener = () => { setState(getState('system')); }; diff --git a/packages/theming/src/utils/useColorScheme.spec.tsx b/packages/theming/src/utils/useColorScheme.spec.tsx new file mode 100644 index 00000000000..64efde1d7de --- /dev/null +++ b/packages/theming/src/utils/useColorScheme.spec.tsx @@ -0,0 +1,87 @@ +/** + * 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 { ThemeProvider } from '../elements/ThemeProvider'; + +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('works as expected when called within ``', () => { + 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 `ThemeProvider`', () => { + const Test = () => ; + + expect(() => { + render(); + }).toThrow(); + }); + + it('throws if called inside of `ThemeProvider` without `initialColorScheme`', () => { + const Test = () => ( + + + + ); + + expect(() => { + render(); + }).toThrow(); + }); + + afterEach(() => { + console.error = originalError; + }); + }); +}); From a13a1e0f300d5493eca3b64bb57cf1175a90b9f6 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Thu, 19 Dec 2024 16:27:00 -0500 Subject: [PATCH 5/7] Restructure to expose `ColorSchemeProvider` for consumer use --- .../demo/colorSchemeProvider.stories.mdx | 22 +++ .../demo/stories/ColorSchemeProviderStory.tsx | 131 +++++++++++++++++ .../demo/stories/ThemeProviderStory.tsx | 133 ------------------ .../theming/demo/themeProvider.stories.mdx | 9 +- .../src/elements/ColorSchemeProvider.tsx | 86 +++++++++-- .../theming/src/elements/ThemeProvider.tsx | 105 +------------- packages/theming/src/index.ts | 2 + packages/theming/src/types/index.ts | 21 +-- .../theming/src/utils/useColorScheme.spec.tsx | 22 +-- packages/theming/src/utils/useColorScheme.ts | 4 +- 10 files changed, 257 insertions(+), 278 deletions(-) create mode 100644 packages/theming/demo/colorSchemeProvider.stories.mdx create mode 100644 packages/theming/demo/stories/ColorSchemeProviderStory.tsx delete mode 100644 packages/theming/demo/stories/ThemeProviderStory.tsx 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/stories/ThemeProviderStory.tsx b/packages/theming/demo/stories/ThemeProviderStory.tsx deleted file mode 100644 index b0fa803f14b..00000000000 --- a/packages/theming/demo/stories/ThemeProviderStory.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * 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, { 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, - getColor, - IGardenTheme, - IThemeProviderProps, - ThemeProvider, - 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: IThemeProviderProps['colorSchemeKey']; -}) => { - const theme = useTheme() as IGardenTheme; - const win = useWindow(); - const localStorage = win?.localStorage; - const { colorScheme, setColorScheme } = useColorScheme(); - const [inputValue, setInputValue] = useState(''); - - 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, localStorage]); - - return ( - - - -
- - - Local {!!colorSchemeKey && {colorSchemeKey}} storage - - - {!!inputValue && ( - - - - - - )} - -
-
- - ( - - {theme.colors.base === 'dark' ? : } - - )} - onChange={handleChange} - placement="bottom-end" - selectedItems={[{ value: colorScheme }]} - > - - } value="light"> - Light - - } value="dark"> - Dark - - } isSelected value="system"> - System - - - - -
-
- ); -}; - -export const ThemeProviderStory: StoryFn = ({ - colorSchemeKey, - initialColorScheme -}) => { - const theme = useTheme(); - - return ( - - - - ); -}; diff --git a/packages/theming/demo/themeProvider.stories.mdx b/packages/theming/demo/themeProvider.stories.mdx index 69dbf9ae22d..abf037797b9 100644 --- a/packages/theming/demo/themeProvider.stories.mdx +++ b/packages/theming/demo/themeProvider.stories.mdx @@ -1,6 +1,5 @@ import { Meta, ArgsTable, Canvas, Story, Markdown } from '@storybook/addon-docs'; import { ThemeProvider, DEFAULT_THEME, PALETTE } from '@zendeskgarden/react-theming'; -import { ThemeProviderStory } from './stories/ThemeProviderStory'; import { PaletteStory } from './stories/PaletteStory'; import README from '../README.md'; @@ -19,12 +18,8 @@ import README from '../README.md'; ## ThemeProvider - - {args => } + + {args => } diff --git a/packages/theming/src/elements/ColorSchemeProvider.tsx b/packages/theming/src/elements/ColorSchemeProvider.tsx index d268bfa1c57..ea7a5a67921 100644 --- a/packages/theming/src/elements/ColorSchemeProvider.tsx +++ b/packages/theming/src/elements/ColorSchemeProvider.tsx @@ -5,23 +5,89 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { createContext, PropsWithChildren, useMemo } from 'react'; -import { ColorScheme, IColorSchemeContext } from '../types'; +import React, { + createContext, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState +} from 'react'; +import { + ColorScheme, + IColorSchemeContext, + IColorSchemeProviderProps, + IGardenTheme +} from '../types'; -interface IColorSchemeProviderProps extends PropsWithChildren { - isSystem: boolean; -} +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, - isSystem, - colorScheme, - setColorScheme -}: IColorSchemeProviderProps) => { + colorSchemeKey, + initialColorScheme +}: PropsWithChildren) => { + const { isSystem, colorScheme, setColorScheme } = useColorScheme( + initialColorScheme, + colorSchemeKey + ); const contextValue = useMemo( - () => ({ colorScheme: isSystem ? 'system' : (colorScheme as ColorScheme), setColorScheme }), + () => ({ colorScheme, isSystem, setColorScheme }), [isSystem, colorScheme, setColorScheme] ); diff --git a/packages/theming/src/elements/ThemeProvider.tsx b/packages/theming/src/elements/ThemeProvider.tsx index edaaad24a47..f0a13e63944 100644 --- a/packages/theming/src/elements/ThemeProvider.tsx +++ b/packages/theming/src/elements/ThemeProvider.tsx @@ -5,109 +5,14 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'; +import React, { PropsWithChildren } from 'react'; import { ThemeProvider as StyledThemeProvider } from 'styled-components'; -import { ColorScheme, IGardenTheme, IThemeProviderProps } from '../types'; -import { ColorSchemeProvider } from './ColorSchemeProvider'; +import { IGardenTheme, IThemeProviderProps } from '../types'; import DEFAULT_THEME from './theme'; -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); - } - }; -}; - -const ColorSchemeThemeProvider = ({ - children, - colorSchemeKey, - initialColorScheme, - theme, - ...other -}: IThemeProviderProps) => { - const { isSystem, colorScheme, setColorScheme } = useColorScheme( - initialColorScheme, - colorSchemeKey - ); - const _theme = { - ...theme, - colors: { ...(theme as IGardenTheme).colors, base: colorScheme } - } as IGardenTheme; - - return ( - - - {children} - - - ); -}; - export const ThemeProvider = ({ theme = DEFAULT_THEME, - colorSchemeKey, - initialColorScheme, ...other -}: PropsWithChildren) => - initialColorScheme ? ( - - ) : ( - - ); +}: PropsWithChildren) => ( + +); diff --git a/packages/theming/src/index.ts b/packages/theming/src/index.ts index f0e57e8ccc3..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'; @@ -33,6 +34,7 @@ export { MENU_POSITION, PLACEMENT, type IColorSchemeContext, + type IColorSchemeProviderProps, type IGardenTheme, type IStyledBaseIconProps, type IThemeProviderProps, diff --git a/packages/theming/src/types/index.ts b/packages/theming/src/types/index.ts index 42bbabba220..995e84272a0 100644 --- a/packages/theming/src/types/index.ts +++ b/packages/theming/src/types/index.ts @@ -209,18 +209,14 @@ export type ColorScheme = IGardenTheme['colors']['base'] | 'system'; export interface IColorSchemeContext { /** Returns the current color scheme */ - colorScheme: ColorScheme; + 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 IThemeProviderProps extends Partial> { - /** - * Provides values for component styling. See styled-components - * [`ThemeProvider`](https://styled-components.com/docs/api#themeprovider) - * for details. - */ - theme?: IGardenTheme | ((theme: IGardenTheme) => IGardenTheme); +export interface IColorSchemeProviderProps { /** * Sets the initial color scheme and provides `localStorage` persistence (see * the `useColorScheme` hook). Once a user's preference is stored, it will be @@ -234,6 +230,15 @@ export interface IThemeProviderProps extends Partial> { + /** + * Provides values for component styling. See styled-components + * [`ThemeProvider`](https://styled-components.com/docs/api#themeprovider) + * for details. + */ + theme?: IGardenTheme | ((theme: IGardenTheme) => IGardenTheme); +} + export interface IStyledBaseIconProps extends PropsWithChildren> { theme?: DefaultTheme; } diff --git a/packages/theming/src/utils/useColorScheme.spec.tsx b/packages/theming/src/utils/useColorScheme.spec.tsx index 64efde1d7de..967c5aadde3 100644 --- a/packages/theming/src/utils/useColorScheme.spec.tsx +++ b/packages/theming/src/utils/useColorScheme.spec.tsx @@ -8,7 +8,7 @@ import React, { useEffect } from 'react'; import { render } from 'garden-test-utils'; import { useColorScheme } from './useColorScheme'; -import { ThemeProvider } from '../elements/ThemeProvider'; +import { ColorSchemeProvider } from '../elements/ColorSchemeProvider'; const ColorSchemeConsumer = () => { const { colorScheme, setColorScheme } = useColorScheme(); @@ -37,11 +37,11 @@ describe('useColorScheme', () => { }); }); - it('works as expected when called within ``', () => { + it('sets the color scheme as expected', () => { const Test = () => ( - + - + ); expect(() => { @@ -60,7 +60,7 @@ describe('useColorScheme', () => { console.error = jest.fn(); }); - it('throws if called outside of `ThemeProvider`', () => { + it('throws if called outside of `ColorSchemeProvider`', () => { const Test = () => ; expect(() => { @@ -68,18 +68,6 @@ describe('useColorScheme', () => { }).toThrow(); }); - it('throws if called inside of `ThemeProvider` without `initialColorScheme`', () => { - 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 index 5801226a8ab..ee61e916396 100644 --- a/packages/theming/src/utils/useColorScheme.ts +++ b/packages/theming/src/utils/useColorScheme.ts @@ -17,9 +17,7 @@ export const useColorScheme = () => { const context = useContext(ColorSchemeContext); if (!context) { - throw new Error( - 'Error: this component must be rendered within a .' - ); + throw new Error('Error: this component must be rendered within a .'); } return context; From 4d55e11d65bfff817d29d24ba729b989269a2dc3 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 20 Dec 2024 14:16:00 -0500 Subject: [PATCH 6/7] Update README --- packages/theming/README.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) 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'; From 41addfc6bc6c0ce1ea12efc4c192d28d5b525787 Mon Sep 17 00:00:00 2001 From: Jonathan Zempel Date: Fri, 20 Dec 2024 14:43:20 -0500 Subject: [PATCH 7/7] Update prop description --- packages/theming/src/types/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theming/src/types/index.ts b/packages/theming/src/types/index.ts index 995e84272a0..25e3d7e5350 100644 --- a/packages/theming/src/types/index.ts +++ b/packages/theming/src/types/index.ts @@ -219,8 +219,8 @@ export interface IColorSchemeContext { export interface IColorSchemeProviderProps { /** * Sets the initial color scheme and provides `localStorage` persistence (see - * the `useColorScheme` hook). Once a user's preference is stored, it will be - * used as the default. + * the `useColorScheme` hook). A user's stored preference overrides this + * value. */ initialColorScheme?: ColorScheme; /**