Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/styled-react-themeprovider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@primer/react": patch
"@primer/styled-react": minor
---

@primer/react: Export `useId` and `useSyncedState`
@primer/styled-react: Add `ThemeProvider` and `BaseStyles`
6 changes: 3 additions & 3 deletions e2e/components/IconButton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
{
Expand Down
1 change: 1 addition & 0 deletions examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
38 changes: 36 additions & 2 deletions examples/nextjs/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
padding: theme?.space[5],
backgroundColor: theme?.colors.btn.primary.bg,
}}
>
Hello world
</div>
)
}

export default function IndexPage() {
return <Button>Hello world</Button>
return (
<Stack direction="horizontal">
<Button variant="primary" sx={{padding: 5}}>
Hello world
</Button>
<Box sx={{padding: 5, backgroundColor: 'btn.primary.bg'}}>Hello world</Box>
<StyledDiv>Hello world</StyledDiv>
<ThemeUser />
</Stack>
)
}
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/react/src/FeatureFlags/DefaultFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useFocusTrap",
"useFocusZone",
"useFormControlForwardedProps",
"useId",
"useIsomorphicLayoutEffect",
"useOnEscapePress",
"useOnOutsideClick",
Expand All @@ -224,6 +225,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] =
"useResizeObserver",
"useResponsiveValue",
"useSafeTimeout",
"useSyncedState",
"useTheme",
"VisuallyHidden",
"type VisuallyHiddenProps",
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
103 changes: 103 additions & 0 deletions packages/styled-react/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}),
Expand All @@ -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,
},
})
152 changes: 152 additions & 0 deletions packages/styled-react/src/components/BaseStyles.tsx
Original file line number Diff line number Diff line change
@@ -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 & {
Comment on lines +79 to +86
Copy link

Copilot AI Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS selector input & appears to be invalid. The & placeholder should come before input to properly target input elements within the BaseStyles component. Consider changing to & input instead.

Suggested change
input & {
color-scheme: light;
}
}
/* Global styles for dark mode */
&:has([data-color-mode='dark']) {
input & {
& input {
color-scheme: light;
}
}
/* Global styles for dark mode */
&:has([data-color-mode='dark']) {
& input {

Copilot uses AI. Check for mistakes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<any> | 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 (
<Component
className={clsx('BaseStyles', className)}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self: might want to call this prc-BaseStyles for consistency

data-portal-root
/**
* We need to map valid primer/react color modes onto valid color modes for primer/primitives
* valid color modes for primer/primitives: auto | light | dark
* valid color modes for primer/primer: auto | day | night | light | dark
*/
data-color-mode={colorMode === 'auto' ? 'auto' : colorScheme?.includes('dark') ? 'dark' : 'light'}
data-light-theme={dayScheme}
data-dark-theme={nightScheme}
style={{
...baseStyles,
...style,
}}
{...rest}
>
<GlobalStyle colorScheme={colorScheme?.includes('dark') ? 'dark' : 'light'} />
{children}
</Component>
)
}
Loading
Loading