diff --git a/.changeset/chubby-colts-nail.md b/.changeset/chubby-colts-nail.md new file mode 100644 index 00000000000..6c78ba5ba51 --- /dev/null +++ b/.changeset/chubby-colts-nail.md @@ -0,0 +1,5 @@ +--- +'@primer/styled-react': patch +--- + +Refactor ToggleSwitch export type to match original type from @primer/react diff --git a/.changeset/great-hats-serve.md b/.changeset/great-hats-serve.md new file mode 100644 index 00000000000..8c5dc98f351 --- /dev/null +++ b/.changeset/great-hats-serve.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add ProgressBarItemProps and ProgressBarItemProps type exports to @primer/react diff --git a/.changeset/metal-cups-peel.md b/.changeset/metal-cups-peel.md new file mode 100644 index 00000000000..41673ddedb4 --- /dev/null +++ b/.changeset/metal-cups-peel.md @@ -0,0 +1,5 @@ +--- +'@primer/styled-react': patch +--- + +Remove several components that have no sx usage diff --git a/.changeset/nine-cobras-talk.md b/.changeset/nine-cobras-talk.md new file mode 100644 index 00000000000..f9e14b41019 --- /dev/null +++ b/.changeset/nine-cobras-talk.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Add ToggleSwitchProps type to package exports diff --git a/eslint.config.mjs b/eslint.config.mjs index ad93e3b1df3..0e0e5e0ca4b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -42,6 +42,7 @@ const config = defineConfig([ 'contributor-docs/adrs/*', 'examples/codesandbox/**/*', 'packages/react/src/utils/polymorphic.ts', + 'packages/styled-react/src/polymorphic.d.ts', '**/storybook-static', '**/CHANGELOG.md', '**/node_modules/**/*', @@ -376,6 +377,22 @@ const config = defineConfig([ '@typescript-eslint/triple-slash-reference': 'off', }, }, + + // packages/styled-react overrides + { + files: ['packages/styled-react/**/*.{ts,tsx}'], + rules: { + 'primer-react/no-unnecessary-components': 'off', + }, + }, + { + files: ['packages/styled-react/**/*.test.{ts,tsx}'], + rules: { + 'github/a11y-aria-label-is-well-formatted': 'off', + 'github/a11y-svg-has-accessible-name': 'off', + 'primer-react/direct-slot-children': 'off', + }, + }, ]) export default tseslint.config(config) diff --git a/package-lock.json b/package-lock.json index be8b794cef9..fb22b1a88e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1875,6 +1875,8 @@ }, "node_modules/@babel/preset-react": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.27.1.tgz", + "integrity": "sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==", "dev": true, "license": "MIT", "dependencies": { @@ -26320,6 +26322,7 @@ "name": "@primer/styled-react", "version": "1.0.0-rc.1", "devDependencies": { + "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@primer/react": "^38.0.0-rc.1", "@rollup/plugin-babel": "^6.0.4", diff --git a/packages/react/src/ProgressBar/ProgressBar.tsx b/packages/react/src/ProgressBar/ProgressBar.tsx index b654d89367d..c0b0975f8a7 100644 --- a/packages/react/src/ProgressBar/ProgressBar.tsx +++ b/packages/react/src/ProgressBar/ProgressBar.tsx @@ -16,13 +16,13 @@ type StyledProgressContainerProps = { animated?: boolean } & SxProp -export type ProgressBarItems = React.HTMLAttributes & { +export type ProgressBarItemProps = React.HTMLAttributes & { 'aria-label'?: string className?: string } & ProgressProp & SxProp -export const Item = forwardRef( +export const Item = forwardRef( ( { progress, diff --git a/packages/react/src/ProgressBar/index.ts b/packages/react/src/ProgressBar/index.ts index 6c0452114df..2a6a4bee81e 100644 --- a/packages/react/src/ProgressBar/index.ts +++ b/packages/react/src/ProgressBar/index.ts @@ -1,6 +1,6 @@ import {ProgressBar as Bar, Item} from './ProgressBar' -export type {ProgressBarProps} from './ProgressBar' +export type {ProgressBarProps, ProgressBarItemProps} from './ProgressBar' /** * Collection of ProgressBar related components. diff --git a/packages/react/src/ToggleSwitch/ToggleSwitch.tsx b/packages/react/src/ToggleSwitch/ToggleSwitch.tsx index 488bd625371..107b11d8c06 100644 --- a/packages/react/src/ToggleSwitch/ToggleSwitch.tsx +++ b/packages/react/src/ToggleSwitch/ToggleSwitch.tsx @@ -72,135 +72,133 @@ const LineIcon: React.FC> = ({size}) => ) -const ToggleSwitch = React.forwardRef>( - function ToggleSwitch(props, ref) { - const { - 'aria-labelledby': ariaLabelledby, - 'aria-describedby': ariaDescribedby, - defaultChecked, - disabled, - loading, - checked, - onChange, - onClick, - buttonType = 'button', - size = 'medium', - statusLabelPosition = 'start', - loadingLabelDelay = 2000, - loadingLabel = 'Loading', - className, - ...rest - } = props - const isControlled = typeof checked !== 'undefined' - const [isOn, setIsOn] = useProvidedStateOrCreate(checked, onChange, Boolean(defaultChecked)) - const acceptsInteraction = !disabled && !loading - - const [isLoadingLabelVisible, setIsLoadingLabelVisible] = React.useState(false) - const loadingLabelId = useId('loadingLabel') - - const {safeSetTimeout} = useSafeTimeout() - - const handleToggleClick: MouseEventHandler = useCallback( - e => { - if (disabled || loading) return - - if (!isControlled) { - setIsOn(!isOn) - } - onClick && onClick(e) - }, - [disabled, isControlled, loading, onClick, setIsOn, isOn], - ) - - useEffect(() => { - if (onChange && isControlled && !disabled) { - onChange(Boolean(checked)) +const ToggleSwitch = React.forwardRef(function ToggleSwitch(props, ref) { + const { + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + defaultChecked, + disabled, + loading, + checked, + onChange, + onClick, + buttonType = 'button', + size = 'medium', + statusLabelPosition = 'start', + loadingLabelDelay = 2000, + loadingLabel = 'Loading', + className, + ...rest + } = props + const isControlled = typeof checked !== 'undefined' + const [isOn, setIsOn] = useProvidedStateOrCreate(checked, onChange, Boolean(defaultChecked)) + const acceptsInteraction = !disabled && !loading + + const [isLoadingLabelVisible, setIsLoadingLabelVisible] = React.useState(false) + const loadingLabelId = useId('loadingLabel') + + const {safeSetTimeout} = useSafeTimeout() + + const handleToggleClick: MouseEventHandler = useCallback( + e => { + if (disabled || loading) return + + if (!isControlled) { + setIsOn(!isOn) } - }, [onChange, checked, isControlled, disabled]) - - useEffect(() => { - if (!loading && isLoadingLabelVisible) { - setIsLoadingLabelVisible(false) - } else if (loading && !isLoadingLabelVisible) { - safeSetTimeout(() => { - setIsLoadingLabelVisible(true) - }, loadingLabelDelay) - } - }, [loading, isLoadingLabelVisible, loadingLabelDelay, safeSetTimeout]) - - let switchButtonDescribedBy = loadingLabelId - if (ariaDescribedby) switchButtonDescribedBy = `${switchButtonDescribedBy} ${ariaDescribedby}` - - return ( -
- - - {isLoadingLabelVisible && loadingLabel} - - - - {loading ? ( -
- -
- ) : null} - -
+ + + ) +}) if (__DEV__) { ToggleSwitch.displayName = 'ToggleSwitch' diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index c7db44a1990..3250e135574 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -118,6 +118,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "Portal", "type PortalProps", "ProgressBar", + "type ProgressBarItemProps", "type ProgressBarProps", "Radio", "RadioGroup", @@ -177,6 +178,7 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type TimelineItemsProps", "type TimelineProps", "ToggleSwitch", + "type ToggleSwitchProps", "Token", "type TokenProps", "Tooltip", diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a1e066b265d..a811e24534f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -127,7 +127,7 @@ export type {PopoverProps, PopoverContentProps} from './Popover' export {default as Portal, registerPortalRoot} from './Portal' export type {PortalProps} from './Portal' export {ProgressBar} from './ProgressBar' -export type {ProgressBarProps} from './ProgressBar' +export type {ProgressBarProps, ProgressBarItemProps} from './ProgressBar' export {default as RadioGroup} from './RadioGroup' export type {RelativeTimeProps} from './RelativeTime' export {default as RelativeTime} from './RelativeTime' @@ -151,6 +151,7 @@ export type {StateLabelProps} from './StateLabel' export {default as SubNav} from './SubNav' export type {SubNavProps, SubNavLinkProps, SubNavLinksProps} from './SubNav' export {default as ToggleSwitch} from './ToggleSwitch' +export type {ToggleSwitchProps} from './ToggleSwitch' export {default as TextInput} from './TextInput' export type {TextInputProps} from './TextInput' export {default as TextInputWithTokens} from './TextInputWithTokens' diff --git a/packages/styled-react/ARCHITECTURE.md b/packages/styled-react/ARCHITECTURE.md new file mode 100644 index 00000000000..a5ea328aaf8 --- /dev/null +++ b/packages/styled-react/ARCHITECTURE.md @@ -0,0 +1,100 @@ +# Architecture + +This package mirrors components from `@primer/react` but optionally provides +support for `sx` and `styled-system` props to a component. Only components +that have downstream `sx` usage across GitHub are included in this package. + +## Overview + +There are several ways a component is added to this package: + +- A functional component +- A functional component with `forwardRef` +- A polymorphic functional component with `forwardRef` + +The way we author the wrappers for these components will differ depending on +what type the component is originally. + +### A functional component + +```tsx +import { + ExampleComponent as PrimerExampleComponent, + type ExampleComponentProps as PrimerExampleComponentProps, +} from '@primer/react' + +type ExampleComponentProps = PrimerExampleComponentProps & SxProp + +function ExampleComponent(props: ExampleComponentProps) { + return +} + +export {ExampleComponent} +``` + +### A functional component with `forwardRef` + +```tsx +import { + ExampleComponent as PrimerExampleComponent, + type ExampleComponentProps as PrimerExampleComponentProps, +} from '@primer/react' +import {forwardRef} from 'react' + +type ExampleComponentProps = PrimerExampleComponentProps & SxProp + +const ExampleComponent = forwardRef +}); + +export {ExampleComponent} +``` + +It's important that this signature matches the original component exactly, +including both the type of the `ref` and props. + +### A polymorphic functional component with `forwardRef` + +```tsx +import { + ExampleComponent as PrimerExampleComponent, + type ExampleComponentProps as PrimerExampleComponentProps, +} from '@primer/react' +import {forwardRef} from 'react' +import {PolymorphicForwardRef as ForwardRefComponent} from '../polymorphic' + +type ExampleComponentProps = PrimerExampleComponentProps & SxProp + +const ExampleComponent = forwardRef(function ExampleComponent(props, ref) { + // @ts-expect-error the polymorphic component type is not inferred + // correctly + return +}) as ForwardRefComponent<'div', ExampleComponentProps> + +export {ExampleComponent} +``` + +## Sub-components + +Some components will include sub-components as a part of their properties. For +example, `SubNav` also includes `SubNav.Link`. When wrapping these components, +it's important to also wrap the sub-components so that these accessors +continue to work as expected. + +```tsx +type SubNavProps = PrimerSubNavProps & SxProp + +const SubNavImpl = forwardRef(function SubNav(props, ref) { + return +}) + +type SubNavLinkProps = PrimerSubNavLinkProps & SxProp + +const SubNavLink = forwardRef(function SubNavLink(props, ref) { + return +}) + +const SubNav = Object.assign(SubNavImpl, { + Link: SubNavLink, +}) +``` diff --git a/packages/styled-react/package.json b/packages/styled-react/package.json index ff125cf3f08..d989038632a 100644 --- a/packages/styled-react/package.json +++ b/packages/styled-react/package.json @@ -27,6 +27,7 @@ "type-check": "tsc --noEmit" }, "devDependencies": { + "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@primer/react": "^38.0.0-rc.1", "@rollup/plugin-babel": "^6.0.4", diff --git a/packages/styled-react/rollup.config.js b/packages/styled-react/rollup.config.js index 0c5deb298b0..3d8bd571154 100644 --- a/packages/styled-react/rollup.config.js +++ b/packages/styled-react/rollup.config.js @@ -14,14 +14,14 @@ function createPackageRegex(name) { } export default defineConfig({ - input: ['src/index.ts', 'src/experimental.ts', 'src/deprecated.ts'], + input: ['src/index.tsx', 'src/experimental.tsx', 'src/deprecated.tsx'], external: dependencies.map(createPackageRegex), plugins: [ typescript({ tsconfig: 'tsconfig.build.json', }), babel({ - presets: ['@babel/preset-typescript'], + presets: ['@babel/preset-typescript', '@babel/preset-react'], extensions: ['.ts', '.tsx'], babelHelpers: 'bundled', }), diff --git a/packages/styled-react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/styled-react/src/__tests__/__snapshots__/exports.test.ts.snap index 625ff55c68f..e1bf1906e1e 100644 --- a/packages/styled-react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/styled-react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -2,12 +2,11 @@ exports[`@primer/styled-react exports 1`] = ` [ - "ToggleSwitch", "ActionList", "ActionMenu", "Autocomplete", "Avatar", - "BranchName", + "Box", "Breadcrumbs", "Button", "Checkbox", @@ -22,9 +21,9 @@ exports[`@primer/styled-react exports 1`] = ` "Heading", "IconButton", "Label", - "LabelGroup", "Link", "LinkButton", + "merge", "NavList", "Overlay", "PageHeader", @@ -35,27 +34,22 @@ exports[`@primer/styled-react exports 1`] = ` "RelativeTime", "SegmentedControl", "Select", - "SelectPanel", - "SideNav", "Spinner", - "Stack", "StateLabel", "SubNav", + "sx", "Text", "Textarea", "TextInput", - "TextInputWithTokens", + "theme", + "themeGet", + "ThemeProvider", "Timeline", + "ToggleSwitch", "Token", "Tooltip", "Truncate", "UnderlineNav", - "Box", - "sx", - "ThemeProvider", - "merge", - "theme", - "themeGet", "useColorSchemeVar", "useTheme", ] diff --git a/packages/styled-react/src/__tests__/exports.test.ts b/packages/styled-react/src/__tests__/exports.test.ts index 1ef4ed8b05d..a6c58b205e0 100644 --- a/packages/styled-react/src/__tests__/exports.test.ts +++ b/packages/styled-react/src/__tests__/exports.test.ts @@ -7,13 +7,25 @@ import * as StyledReactDeprecated from '../deprecated' import * as StyledReactExperimental from '../experimental' test('@primer/styled-react exports', () => { - expect(Object.keys(StyledReact)).toMatchSnapshot() + expect( + Object.keys(StyledReact).sort((a, b) => { + return a.localeCompare(b) + }), + ).toMatchSnapshot() }) test('@primer/styled-react/deprecated exports', () => { - expect(Object.keys(StyledReactDeprecated)).toMatchSnapshot() + expect( + Object.keys(StyledReactDeprecated).sort((a, b) => { + return a.localeCompare(b) + }), + ).toMatchSnapshot() }) test('@primer/styled-react/experimental exports', () => { - expect(Object.keys(StyledReactExperimental)).toMatchSnapshot() + expect( + Object.keys(StyledReactExperimental).sort((a, b) => { + return a.localeCompare(b) + }), + ).toMatchSnapshot() }) diff --git a/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx b/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx new file mode 100644 index 00000000000..c8fbd7fbc54 --- /dev/null +++ b/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx @@ -0,0 +1,30 @@ +import {render, screen} from '@testing-library/react' +import {describe, expect, test} from 'vitest' +import {Dialog, Octicon, TabNav, Tooltip} from '../deprecated' + +describe('@primer/react/deprecated', () => { + test('Dialog supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Octicon supports `sx` prop', () => { + render( } sx={{background: 'red'}} />) + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('TabNav supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('TabNav.Link supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Tooltip supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) +}) diff --git a/packages/styled-react/src/__tests__/primer-react-experimental.browser.test.tsx b/packages/styled-react/src/__tests__/primer-react-experimental.browser.test.tsx new file mode 100644 index 00000000000..4e2a8fa64fe --- /dev/null +++ b/packages/styled-react/src/__tests__/primer-react-experimental.browser.test.tsx @@ -0,0 +1,51 @@ +import {render, screen} from '@testing-library/react' +import {describe, expect, test} from 'vitest' +import {Dialog, PageHeader, Table, Tooltip, UnderlinePanels} from '../experimental' + +describe('@primer/react/experimental', () => { + test('Dialog supports `sx` prop', () => { + render( {}} sx={{background: 'red'}} />) + expect(window.getComputedStyle(screen.getByRole('dialog')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('PageHeader supports `sx` prop', () => { + const {container} = render() + expect(window.getComputedStyle(container.firstElementChild!).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Table.Container', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test.todo('Tooltip supports `sx` prop', () => { + render( + + + , + ) + expect(window.getComputedStyle(screen.getByRole('tooltip', {hidden: true})).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('UnderlinePanels supports `sx` prop', () => { + render( + + tab + panel + , + ) + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('UnderlinePanels.Panel supports `sx` prop', () => { + render( + + tab + + panel + + , + ) + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) +}) diff --git a/packages/styled-react/src/__tests__/primer-react.browser.test.tsx b/packages/styled-react/src/__tests__/primer-react.browser.test.tsx new file mode 100644 index 00000000000..7909455e5ae --- /dev/null +++ b/packages/styled-react/src/__tests__/primer-react.browser.test.tsx @@ -0,0 +1,436 @@ +import {userEvent} from '@testing-library/user-event' +import {render, screen} from '@testing-library/react' +import {createRef} from 'react' +import {describe, expect, test} from 'vitest' +import { + ActionList, + ActionMenu, + Autocomplete, + Avatar, + Box, + Breadcrumbs, + Button, + Checkbox, + CheckboxGroup, + CircleBadge, + CounterLabel, + Dialog, + Flash, + FormControl, + Header, + Heading, + IconButton, + Label, + Link, + LinkButton, + NavList, + Overlay, + PageHeader, + PageLayout, + Popover, + ProgressBar, + RadioGroup, + RelativeTime, + SegmentedControl, + Select, + Spinner, + StateLabel, + SubNav, + Text, + TextInput, + Textarea, + ThemeProvider, + Timeline, + Token, + Tooltip, + Truncate, + UnderlineNav, +} from '../' + +describe('@primer/react', () => { + test('ActionList supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('ActionMenu.Button supports `sx` prop', () => { + const {container} = render(test) + expect(window.getComputedStyle(container.firstElementChild!).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('ActionMenu.Overlay supports `sx` prop', async () => { + const user = userEvent.setup() + render( + + + test + + test + + + , + ) + + await user.click(screen.getByText('test')) + + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Autocomplete.Input supports `sx` prop', () => { + const {container} = render( + + + , + ) + expect(window.getComputedStyle(container.firstElementChild!).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Autocomplete.Overlay supports `sx` prop', async () => { + const user = userEvent.setup() + + render( + + + + + test + + + , + ) + + await user.click(screen.getByRole('combobox')) + await user.keyboard('a') + + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Avatar supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Box supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Breadcrumbs supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByLabelText('Breadcrumbs')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Breadcrumbs.Item supports `sx` prop', () => { + render() + expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)') + }) + + test('Button supports `sx` prop', () => { + render(