diff --git a/.changeset/styled-react-themeprovider.md b/.changeset/styled-react-themeprovider.md new file mode 100644 index 00000000000..dc5aa5e0a94 --- /dev/null +++ b/.changeset/styled-react-themeprovider.md @@ -0,0 +1,7 @@ +--- +"@primer/react": patch +"@primer/styled-react": minor +--- + +@primer/react: Export `useId` and `useSyncedState` +@primer/styled-react: Add `ThemeProvider` and `BaseStyles` diff --git a/e2e/components/IconButton.test.ts b/e2e/components/IconButton.test.ts index 99a4fde5546..955d22588f4 100644 --- a/e2e/components/IconButton.test.ts +++ b/e2e/components/IconButton.test.ts @@ -45,9 +45,9 @@ const stories = [ disableAnimations: true, async setup(page: Page) { await page.keyboard.press('Tab') // focus on icon button - await page.getByText('Bold').waitFor({ - state: 'visible', - }) + await page.getByText('Bold').waitFor({state: 'visible'}) + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000) // wait until after "tooltip delay" for a stable screenshot }, }, { diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 2247c3c611f..e38c6799d5b 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@primer/react": "38.0.0-rc.6", + "@primer/styled-react": "1.0.0-rc.7", "next": "^15.2.3", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/examples/nextjs/src/app/layout.tsx b/examples/nextjs/src/app/layout.tsx index ec8ff0f19a7..8e845a6afba 100644 --- a/examples/nextjs/src/app/layout.tsx +++ b/examples/nextjs/src/app/layout.tsx @@ -1,5 +1,5 @@ import './global.css' -import {BaseStyles, ThemeProvider} from '@primer/react' +import {ThemeProvider, BaseStyles} from '@primer/styled-react' import {StyledComponentsRegistry} from './registry' export const metadata = { diff --git a/examples/nextjs/src/app/page.tsx b/examples/nextjs/src/app/page.tsx index 7b9ad9f2e0d..4910ebc2e78 100644 --- a/examples/nextjs/src/app/page.tsx +++ b/examples/nextjs/src/app/page.tsx @@ -1,5 +1,39 @@ -import {Button} from '@primer/react' +'use client' + +import {Button, Stack, Box} from '@primer/react' +import {useTheme} from '@primer/styled-react' +import styled from 'styled-components' + +const StyledDiv = styled.div(({theme}) => { + return { + padding: theme.space[5], + backgroundColor: theme.colors.btn.primary.bg, + } +}) + +const ThemeUser = () => { + const {theme} = useTheme() + return ( +
+ Hello world +
+ ) +} export default function IndexPage() { - return + return ( + + + Hello world + Hello world + + + ) } diff --git a/package-lock.json b/package-lock.json index 7af8c9703ea..8f0bd5d7c2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "version": "0.0.0", "dependencies": { "@primer/react": "38.0.0-rc.6", + "@primer/styled-react": "1.0.0-rc.7", "next": "^15.2.3", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts index f60c9687834..7a1112d7e37 100644 --- a/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts +++ b/packages/react/src/FeatureFlags/DefaultFeatureFlags.ts @@ -8,4 +8,5 @@ export const DefaultFeatureFlags = FeatureFlagScope.create({ primer_react_select_panel_fullscreen_on_narrow: false, primer_react_select_panel_order_selected_at_top: false, primer_react_select_panel_remove_active_descendant: false, + primer_react_use_styled_react_theming: false, }) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 6a9e1c692f9..4057c681ce2 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -214,6 +214,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useFocusTrap", "useFocusZone", "useFormControlForwardedProps", + "useId", "useIsomorphicLayoutEffect", "useOnEscapePress", "useOnOutsideClick", @@ -224,6 +225,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useResizeObserver", "useResponsiveValue", "useSafeTimeout", + "useSyncedState", "useTheme", "VisuallyHidden", "type VisuallyHiddenProps", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f37dbea38d8..f4fc3fa7669 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -46,6 +46,8 @@ export {useResizeObserver} from './hooks/useResizeObserver' export {useResponsiveValue, type ResponsiveValue} from './hooks/useResponsiveValue' export {default as useIsomorphicLayoutEffect} from './utils/useIsomorphicLayoutEffect' export {useProvidedRefOrCreate} from './hooks/useProvidedRefOrCreate' +export {useId} from './hooks/useId' +export {useSyncedState} from './hooks/useSyncedState' // Utils export {createComponent} from './utils/create-component' diff --git a/packages/styled-react/rollup.config.js b/packages/styled-react/rollup.config.js index 8aae93d5045..60091798713 100644 --- a/packages/styled-react/rollup.config.js +++ b/packages/styled-react/rollup.config.js @@ -2,6 +2,7 @@ import babel from '@rollup/plugin-babel' import {defineConfig} from 'rollup' import typescript from 'rollup-plugin-typescript2' import packageJson from './package.json' with {type: 'json'} +import MagicString from 'magic-string' const dependencies = [ ...Object.keys(packageJson.peerDependencies ?? {}), @@ -26,9 +27,111 @@ export default defineConfig({ extensions: ['.ts', '.tsx'], babelHelpers: 'bundled', }), + /** + * This custom rollup plugin allows us to preserve directives in source + * code, such as "use client", in order to support React Server Components. + * + * The source for this plugin is inspired by: + * https://github.com/Ephem/rollup-plugin-preserve-directives + */ + { + name: 'preserve-directives', + transform(code) { + const ast = this.parse(code) + if (ast.type !== 'Program' || !ast.body) { + return { + code, + ast, + map: null, + } + } + + let hasClientDirective = false + + for (const node of ast.body) { + if (!node) { + continue + } + + if (node.type !== 'ExpressionStatement') { + continue + } + + if (node.directive === 'use client') { + hasClientDirective = true + break + } + } + + if (hasClientDirective) { + return { + code, + ast, + map: null, + meta: { + hasClientDirective: true, + }, + } + } + + return { + code, + ast, + map: null, + } + }, + renderChunk: { + order: 'post', + handler(code, chunk, options) { + // If `preserveModules` is not set to true, we can't be sure if the client + // directive corresponds to the whole chunk or just a part of it. + if (!options.preserveModules) { + return undefined + } + + let chunkHasClientDirective = false + + for (const moduleId of Object.keys(chunk.modules)) { + const hasClientDirective = this.getModuleInfo(moduleId)?.meta?.hasClientDirective + if (hasClientDirective) { + chunkHasClientDirective = true + break + } + } + + if (chunkHasClientDirective) { + const transformed = new MagicString(code) + transformed.prepend(`"use client";\n`) + const sourcemap = transformed.generateMap({ + includeContent: true, + }) + return { + code: transformed.toString(), + map: sourcemap, + } + } + + return null + }, + }, + }, ], + onwarn(warning, defaultHandler) { + // Dependencies or modules may use "use client" as an indicator for React + // Server Components that this module should only be loaded on the client. + if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('use client')) { + return + } + + if (warning.code === 'CIRCULAR_DEPENDENCY') { + throw warning + } + + defaultHandler(warning) + }, output: { dir: 'dist', format: 'esm', + preserveModules: true, }, }) diff --git a/packages/styled-react/src/components/BaseStyles.tsx b/packages/styled-react/src/components/BaseStyles.tsx new file mode 100644 index 00000000000..e58f33100e8 --- /dev/null +++ b/packages/styled-react/src/components/BaseStyles.tsx @@ -0,0 +1,152 @@ +import type React from 'react' +import {type CSSProperties, type PropsWithChildren} from 'react' +import {clsx} from 'clsx' +// eslint-disable-next-line import/no-namespace +import type * as styledSystem from 'styled-system' +import {useTheme} from './ThemeProvider' + +import 'focus-visible' +import {createGlobalStyle} from 'styled-components' + +export interface SystemCommonProps + extends styledSystem.ColorProps, + styledSystem.SpaceProps, + styledSystem.DisplayProps {} + +export interface SystemTypographyProps extends styledSystem.TypographyProps { + whiteSpace?: 'normal' | 'nowrap' | 'pre' | 'pre-wrap' | 'pre-line' +} + +const GlobalStyle = createGlobalStyle<{colorScheme?: 'light' | 'dark'}>` + * { + box-sizing: border-box; + } + + body { + margin: 0; + } + + table { + /* stylelint-disable-next-line primer/borders */ + border-collapse: collapse; + } + + [data-color-mode='light'] input { + color-scheme: light; + } + + [data-color-mode='dark'] input { + color-scheme: dark; + } + + @media (prefers-color-scheme: light) { + [data-color-mode='auto'][data-light-theme*='light'] { + color-scheme: light; + } + } + + @media (prefers-color-scheme: dark) { + [data-color-mode='auto'][data-dark-theme*='dark'] { + color-scheme: dark; + } + } + + [role='button']:focus:not(:focus-visible):not(:global(.focus-visible)), + [role='tabpanel'][tabindex='0']:focus:not(:focus-visible):not(:global(.focus-visible)), + button:focus:not(:focus-visible):not(:global(.focus-visible)), + summary:focus:not(:focus-visible):not(:global(.focus-visible)), + a:focus:not(:focus-visible):not(:global(.focus-visible)) { + outline: none; + box-shadow: none; + } + + [tabindex='0']:focus:not(:focus-visible):not(:global(.focus-visible)), + details-dialog:focus:not(:focus-visible):not(:global(.focus-visible)) { + outline: none; + } + + /* -------------------------------------------------------------------------- */ + + .BaseStyles { + font-family: var(--BaseStyles-fontFamily, var(--fontStack-system)); + /* stylelint-disable-next-line primer/typography */ + line-height: var(--BaseStyles-lineHeight, 1.5); + /* stylelint-disable-next-line primer/colors */ + color: var(--BaseStyles-fgColor, var(--fgColor-default)); + + /* Global styles for light mode */ + &:has([data-color-mode='light']) { + input & { + color-scheme: light; + } + } + + /* Global styles for dark mode */ + &:has([data-color-mode='dark']) { + input & { + color-scheme: dark; + } + } + + /* Low-specificity default link styling */ + :where(a:not([class*='prc-']):not([class*='PRC-']):not([class*='Primer_Brand__'])) { + color: var(--fgColor-accent, var(--color-accent-fg)); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +` + +export type BaseStylesProps = PropsWithChildren & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + as?: React.ComponentType | keyof JSX.IntrinsicElements + className?: string + style?: CSSProperties + color?: string // Fixes `color` ts-error +} & SystemTypographyProps & + SystemCommonProps + +export function BaseStyles({ + children, + color, + fontFamily, + lineHeight, + className, + as: Component = 'div', + style, + ...rest +}: BaseStylesProps) { + const {colorMode, colorScheme, dayScheme, nightScheme} = useTheme() + + const baseStyles = { + ['--BaseStyles-fgColor']: color, + ['--BaseStyles-fontFamily']: fontFamily, + ['--BaseStyles-lineHeight']: lineHeight, + } + + return ( + + + {children} + + ) +} diff --git a/packages/styled-react/src/components/FeatureFlaggedTheming.tsx b/packages/styled-react/src/components/FeatureFlaggedTheming.tsx new file mode 100644 index 00000000000..16751b47d03 --- /dev/null +++ b/packages/styled-react/src/components/FeatureFlaggedTheming.tsx @@ -0,0 +1,49 @@ +import { + ThemeProvider as PrimerReactThemeProvider, + type ThemeProviderProps, + BaseStyles as PrimerReactBaseStyles, + type BaseStylesProps, + useTheme as primerReactUseTheme, + useColorSchemeVar as primerReactUseColorSchemeVar, +} from '@primer/react' +import { + ThemeProvider as StyledReactThemeProvider, + useTheme as styledReactUseTheme, + useColorSchemeVar as styledReactUseColorSchemeVar, +} from './ThemeProvider' +import {BaseStyles as StyledReactBaseStyles} from './BaseStyles' +import {useFeatureFlag} from '@primer/react/experimental' + +export const ThemeProvider: React.FC> = ({children, ...props}) => { + const enabled = useFeatureFlag('primer_react_use_styled_react_theming') + if (enabled) { + return {children} + } else { + return {children} + } +} + +export const BaseStyles: React.FC> = ({children, ...props}) => { + const enabled = useFeatureFlag('primer_react_use_styled_react_theming') + if (enabled) { + return {children} + } else { + return {children} + } +} + +export const useTheme: typeof primerReactUseTheme = () => { + const enabled = useFeatureFlag('primer_react_use_styled_react_theming') + const styledReactResults = styledReactUseTheme() + const primerReactResults = primerReactUseTheme() + + return enabled ? styledReactResults : primerReactResults +} + +export const useColorSchemeVar: typeof primerReactUseColorSchemeVar = (values, fallback) => { + const enabled = useFeatureFlag('primer_react_use_styled_react_theming') + const styledReactResults = styledReactUseColorSchemeVar(values, fallback) + const primerReactResults = primerReactUseColorSchemeVar(values, fallback) + + return enabled ? styledReactResults : primerReactResults +} diff --git a/packages/styled-react/src/components/ThemeProvider.tsx b/packages/styled-react/src/components/ThemeProvider.tsx new file mode 100644 index 00000000000..adea1055ebf --- /dev/null +++ b/packages/styled-react/src/components/ThemeProvider.tsx @@ -0,0 +1,243 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import {ThemeProvider as SCThemeProvider} from 'styled-components' +import {theme as defaultTheme, useId, useSyncedState} from '@primer/react' +import deepmerge from 'deepmerge' + +export const defaultColorMode = 'day' +const defaultDayScheme = 'light' +const defaultNightScheme = 'dark' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Theme = {[key: string]: any} +type ColorMode = 'day' | 'night' | 'light' | 'dark' +export type ColorModeWithAuto = ColorMode | 'auto' + +export type ThemeProviderProps = { + theme?: Theme + colorMode?: ColorModeWithAuto + dayScheme?: string + nightScheme?: string + preventSSRMismatch?: boolean +} + +const ThemeContext = React.createContext<{ + theme?: Theme + colorScheme?: string + colorMode?: ColorModeWithAuto + resolvedColorMode?: ColorMode + resolvedColorScheme?: string + dayScheme?: string + nightScheme?: string + setColorMode: React.Dispatch> + setDayScheme: React.Dispatch> + setNightScheme: React.Dispatch> +}>({ + setColorMode: () => null, + setDayScheme: () => null, + setNightScheme: () => null, +}) + +// inspired from __NEXT_DATA__, we use application/json to avoid CSRF policy with inline scripts +const getServerHandoff = (id: string) => { + try { + const serverData = document.getElementById(`__PRIMER_DATA_${id}__`)?.textContent + if (serverData) return JSON.parse(serverData) + } catch (_error) { + // if document/element does not exist or JSON is invalid, supress error + } + return {} +} + +export const ThemeProvider: React.FC> = ({children, ...props}) => { + // Get fallback values from parent ThemeProvider (if exists) + const { + theme: fallbackTheme, + colorMode: fallbackColorMode, + dayScheme: fallbackDayScheme, + nightScheme: fallbackNightScheme, + } = useTheme() + + // Initialize state + const theme = props.theme ?? fallbackTheme ?? defaultTheme + + const uniqueDataId = useId() + const {resolvedServerColorMode} = getServerHandoff(uniqueDataId) + const resolvedColorModePassthrough = React.useRef(resolvedServerColorMode) + + const [colorMode, setColorMode] = useSyncedState(props.colorMode ?? fallbackColorMode ?? defaultColorMode) + const [dayScheme, setDayScheme] = useSyncedState(props.dayScheme ?? fallbackDayScheme ?? defaultDayScheme) + const [nightScheme, setNightScheme] = useSyncedState(props.nightScheme ?? fallbackNightScheme ?? defaultNightScheme) + const systemColorMode = useSystemColorMode() + const resolvedColorMode = resolvedColorModePassthrough.current || resolveColorMode(colorMode, systemColorMode) + const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme) + const {resolvedTheme, resolvedColorScheme} = React.useMemo( + () => applyColorScheme(theme, colorScheme), + [theme, colorScheme], + ) + + // this effect will only run on client + React.useEffect( + function updateColorModeAfterServerPassthrough() { + const resolvedColorModeOnClient = resolveColorMode(colorMode, systemColorMode) + + if (resolvedColorModePassthrough.current) { + // if the resolved color mode passed on from the server is not the resolved color mode on client, change it! + if (resolvedColorModePassthrough.current !== resolvedColorModeOnClient) { + window.setTimeout(() => { + // use ReactDOM.flushSync to prevent automatic batching of state updates since React 18 + // ref: https://github.com/reactwg/react-18/discussions/21 + ReactDOM.flushSync(() => { + // override colorMode to whatever is resolved on the client to get a re-render + setColorMode(resolvedColorModeOnClient) + }) + + // immediately after that, set the colorMode to what the user passed to respond to system color mode changes + setColorMode(colorMode) + }) + } + + resolvedColorModePassthrough.current = null + } + }, + [colorMode, systemColorMode, setColorMode], + ) + + return ( + + + {children} + {props.preventSSRMismatch ? ( +