diff --git a/components/src/next/README.md b/components/src/next/README.md index 1186f733..61aaf8d6 100644 --- a/components/src/next/README.md +++ b/components/src/next/README.md @@ -9,6 +9,38 @@ This directory contains the next-generation reusable components. This guide docu - Represent sizes and visual states with the component's `data-*` attributes or native pseudo-classes. Preserve semantic HTML and ARIA attributes; styling attributes do not replace accessibility semantics. - Treat exported props, class names, custom properties intended for consumer overrides, and observable CSS behavior as compatibility-sensitive. +### Adding and registering a component + +Consumers swap primitives through `ComponentsProvider`, so a component is only usable once it is registered. For a new `ComponentName`: + +- Create `primitives/ComponentName/` with the implementation, stylesheet, tests, stories, and an `index.ts` barrel. +- Export the barrel from [`primitives/index.ts`](./primitives/index.ts) and add the component and its prop types to [`exports.test.tsx`](./primitives/exports.test.tsx). +- Add the component to the `PersesComponents` interface in [`contexts/ComponentsContext.ts`](./contexts/ComponentsContext.ts) and to `defaultComponents` in [`primitives/defaults.ts`](./primitives/defaults.ts). Every entry is required, so a missing default is a type error. + +Registering the component in `PersesComponents` enables the compile-time check in [`types.test.tsx`](./types.test.tsx), which verifies that its `style` prop accepts custom properties. Stylesheets under `primitives/` are discovered automatically by [`css/layers.test.ts`](./css/layers.test.ts). + +### Inline `style` and custom properties + +React's `CSSProperties` does not accept CSS custom properties, so consumers would otherwise have to cast every `--perses-*` or application variable they pass through `style`. Type the `style` prop with `PersesCSSProperties` from `../types` (also exported publicly) instead of relying on the `HTMLAttributes` default: + +```ts +export interface BoxProps extends HTMLAttributes { + style?: PersesCSSProperties; +} +``` + +When a component documents its own override variables, declare them in a `ComponentNameStyle` interface that extends `CSSProperties` and wrap it so consumers keep those typed keys while still being able to add other custom properties: + +```ts +export interface CheckboxStyle extends CSSProperties { + '--perses-checkbox-size-md'?: string; +} + +style?: PersesCSSProperties; +``` + +React appends `px` to numeric values of standard properties such as `marginTop: 8`, but not to custom properties: `'--app-size': 8` is emitted as a bare `8`. Include the unit yourself, for example `'--app-size': '8px'`. + ## CSS conventions ### Cascade layers and tokens diff --git a/components/src/next/contexts/ComponentsContext.ts b/components/src/next/contexts/ComponentsContext.ts index d0a85baa..c8c0f676 100644 --- a/components/src/next/contexts/ComponentsContext.ts +++ b/components/src/next/contexts/ComponentsContext.ts @@ -11,14 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { - ComponentType, - ForwardRefExoticComponent, - PropsWithoutRef, - ReactNode, - RefAttributes, - SVGProps, -} from 'react'; +import type { ComponentType, ForwardRefExoticComponent, PropsWithoutRef, ReactNode, RefAttributes } from 'react'; import { createContext } from 'react'; import type { AlertProps } from '../primitives/Alert'; @@ -47,6 +40,7 @@ import type { TextProps } from '../primitives/Text'; import type { TextFieldProps } from '../primitives/TextField'; import type { TextHelperProps } from '../primitives/TextHelper'; import type { TooltipProps } from '../primitives/Tooltip'; +import type { StyledSVGProps } from '../types'; type RefComponent = ForwardRefExoticComponent & RefAttributes>; @@ -82,10 +76,10 @@ export interface PersesComponents { } export interface PersesIcons { - Error: ComponentType>; - Info: ComponentType>; - Success: ComponentType>; - Warning: ComponentType>; + Error: ComponentType; + Info: ComponentType; + Success: ComponentType; + Warning: ComponentType; } export interface ComponentsContextValue { diff --git a/components/src/next/contexts/TokenCustomization.stories.tsx b/components/src/next/contexts/TokenCustomization.stories.tsx index f1090a38..497aa20b 100644 --- a/components/src/next/contexts/TokenCustomization.stories.tsx +++ b/components/src/next/contexts/TokenCustomization.stories.tsx @@ -12,11 +12,20 @@ // limitations under the License. import type { Story } from '@ladle/react'; -import type { CSSProperties, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import { defaultComponents, defaultIcons } from '../primitives/defaults'; +import type { PersesCSSProperties } from '../types'; import { ComponentsProvider, useComponents } from './ComponentsProvider'; +const TOKEN_OVERRIDES: PersesCSSProperties = { + '--perses-status-solid-primary': '#7c3aed', + '--perses-status-bg-primary-hover': 'rgba(124, 58, 237, 0.15)', + '--perses-status-border-primary': '#7c3aed', + '--perses-status-bg-primary': 'rgba(124, 58, 237, 0.08)', + '--perses-radius-md': '4px', +}; + function TokenDemo(): ReactElement { const { components: { Button }, @@ -45,17 +54,7 @@ function TokenDemo(): ReactElement { export const TokenCustomization: Story = () => ( -
+
diff --git a/components/src/next/index.ts b/components/src/next/index.ts index 3633fdad..368166ee 100644 --- a/components/src/next/index.ts +++ b/components/src/next/index.ts @@ -19,5 +19,7 @@ export type { ComponentsContextValue, } from './contexts/ComponentsProvider'; +export type { PersesCSSProperties } from './types'; + export { ThemeModeProvider } from './contexts/ThemeModeProvider'; export type { ThemeMode, ThemeModeProviderProps } from './contexts/ThemeModeProvider'; diff --git a/components/src/next/primitives/Alert/Alert.tsx b/components/src/next/primitives/Alert/Alert.tsx index 0b9a1a55..ea522bf5 100644 --- a/components/src/next/primitives/Alert/Alert.tsx +++ b/components/src/next/primitives/Alert/Alert.tsx @@ -17,6 +17,7 @@ import { forwardRef } from 'react'; import type { PersesIcons } from '../../contexts/ComponentsContext'; import { useComponents } from '../../contexts/ComponentsProvider'; +import type { PersesCSSProperties } from '../../types'; import { Icon } from '../Icon/Icon'; import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from '../Icon/icons'; import type { DisplayProps } from '../system/display'; @@ -48,6 +49,7 @@ function isAlertSeverity(icon: unknown): icon is AlertSeverity { const ALERT_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, displayProps]); export interface AlertProps extends HTMLAttributes, SpacingProps, SizingProps, DisplayProps { + style?: PersesCSSProperties; severity?: AlertSeverity; icon?: AlertSeverity | ReactElement | number | boolean | null; } diff --git a/components/src/next/primitives/Box/Box.tsx b/components/src/next/primitives/Box/Box.tsx index 6cfe3d7e..0a090468 100644 --- a/components/src/next/primitives/Box/Box.tsx +++ b/components/src/next/primitives/Box/Box.tsx @@ -15,6 +15,7 @@ import clsx from 'clsx'; import { forwardRef } from 'react'; import type { HTMLAttributes } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { applyStyleProps, compileStyleProps, displayProps, flexboxProps, sizingProps, spacingProps } from '../system'; import type { DisplayProps, FlexboxProps, SizingProps, SpacingProps } from '../system'; @@ -25,7 +26,9 @@ import '../system/spacing.css'; export type { Breakpoint, Responsive, ResponsiveObject, SpacingValue } from '../system'; export interface BoxProps - extends HTMLAttributes, SpacingProps, SizingProps, FlexboxProps, DisplayProps {} + extends HTMLAttributes, SpacingProps, SizingProps, FlexboxProps, DisplayProps { + style?: PersesCSSProperties; +} const BOX_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, flexboxProps, displayProps]); diff --git a/components/src/next/primitives/Button/Button.tsx b/components/src/next/primitives/Button/Button.tsx index 3be916cd..093a7163 100644 --- a/components/src/next/primitives/Button/Button.tsx +++ b/components/src/next/primitives/Button/Button.tsx @@ -17,6 +17,7 @@ import { forwardRef } from 'react'; import type { ButtonHTMLAttributes } from 'react'; import { useComponents } from '../../contexts/ComponentsProvider'; +import type { PersesCSSProperties } from '../../types'; import { Icon } from '../Icon/Icon'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; @@ -33,6 +34,7 @@ const BUTTON_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, display export interface ButtonProps extends Omit, 'color'>, SpacingProps, SizingProps, DisplayProps { + style?: PersesCSSProperties; variant?: ButtonVariant; color?: ColorVariant | Status; size?: Size; diff --git a/components/src/next/primitives/Checkbox/Checkbox.test.tsx b/components/src/next/primitives/Checkbox/Checkbox.test.tsx index 62493a0a..2e4b8f60 100644 --- a/components/src/next/primitives/Checkbox/Checkbox.test.tsx +++ b/components/src/next/primitives/Checkbox/Checkbox.test.tsx @@ -16,13 +16,14 @@ import userEvent from '@testing-library/user-event'; import { createRef } from 'react'; import type { CSSProperties } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { ColorVariant, Size, Status } from '../types'; import { Checkbox } from './Checkbox'; import type { CheckboxStyle, CheckboxVariant } from './Checkbox'; const POSITION_STYLE: CSSProperties = { position: 'absolute' }; const RESPONSIVE_WIDTH = { default: 100, md: 200 }; -const WIDTH_OVERRIDE = { '--ps-width': '50px' } as CSSProperties; +const WIDTH_OVERRIDE = { '--ps-width': '50px' } satisfies PersesCSSProperties; const MARGIN_STYLE = { marginTop: '8px' }; const SIZES: Size[] = ['xs', 'sm', 'md', 'lg']; const VARIANTS: CheckboxVariant[] = ['solid', 'subtle', 'outline']; diff --git a/components/src/next/primitives/Checkbox/Checkbox.tsx b/components/src/next/primitives/Checkbox/Checkbox.tsx index 1d0b968c..caadd55c 100644 --- a/components/src/next/primitives/Checkbox/Checkbox.tsx +++ b/components/src/next/primitives/Checkbox/Checkbox.tsx @@ -16,6 +16,7 @@ import clsx from 'clsx'; import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from 'react'; import { forwardRef, useCallback } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; import type { ColorVariant, Size, Status } from '../types'; @@ -74,7 +75,7 @@ interface CheckboxBaseProps size?: Size; color?: ColorVariant | Status; variant?: CheckboxVariant; - style?: CheckboxStyle; + style?: PersesCSSProperties; } /** diff --git a/components/src/next/primitives/Chip/Chip.tsx b/components/src/next/primitives/Chip/Chip.tsx index 161a74d2..bc38f05c 100644 --- a/components/src/next/primitives/Chip/Chip.tsx +++ b/components/src/next/primitives/Chip/Chip.tsx @@ -15,6 +15,7 @@ import clsx from 'clsx'; import { forwardRef } from 'react'; import type { CSSProperties, HTMLAttributes, MouseEventHandler, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import { applyStyleProps, @@ -60,7 +61,7 @@ interface ChipBaseProps onClose?: MouseEventHandler; /** Maximum width of the label; longer text is truncated with an ellipsis. */ maxWidth?: CSSProperties['maxWidth']; - style?: ChipStyle; + style?: PersesCSSProperties; } /** @@ -84,7 +85,10 @@ export interface ChipStyle extends CSSProperties { '--perses-chip-size-sm'?: string; } -function withLabelMaxWidth(style: ChipStyle | undefined, maxWidth: CSSProperties['maxWidth']): ChipStyle | undefined { +function withLabelMaxWidth( + style: PersesCSSProperties | undefined, + maxWidth: CSSProperties['maxWidth'], +): PersesCSSProperties | undefined { if (maxWidth === undefined) return style; return { ...style, '--perses-chip-label-max-width': lengthTransform(maxWidth) }; } diff --git a/components/src/next/primitives/Icon/Icon.tsx b/components/src/next/primitives/Icon/Icon.tsx index ea4d7dd5..a915b45a 100644 --- a/components/src/next/primitives/Icon/Icon.tsx +++ b/components/src/next/primitives/Icon/Icon.tsx @@ -15,9 +15,12 @@ import clsx from 'clsx'; import { forwardRef } from 'react'; import type { HTMLAttributes, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; + import './icon.css'; export interface IconProps extends HTMLAttributes { + style?: PersesCSSProperties; children?: ReactNode; } diff --git a/components/src/next/primitives/Icon/icons/createIcon.tsx b/components/src/next/primitives/Icon/icons/createIcon.tsx index bcb9fbba..f2797b4c 100644 --- a/components/src/next/primitives/Icon/icons/createIcon.tsx +++ b/components/src/next/primitives/Icon/icons/createIcon.tsx @@ -12,14 +12,16 @@ // limitations under the License. import { forwardRef } from 'react'; -import type { ForwardRefExoticComponent, RefAttributes, SVGProps } from 'react'; +import type { ForwardRefExoticComponent, RefAttributes } from 'react'; -export type IconComponent = ForwardRefExoticComponent & RefAttributes>; +import type { StyledSVGProps } from '../../../types'; + +export type IconComponent = ForwardRefExoticComponent>; export function createIcon(name: string, width: number, height: number, paths: string[]): IconComponent { const iconPaths = paths.map((path, index) => ({ key: `${name}-${index}`, path })); - const Component = forwardRef>(function FontAwesomeIcon(props, ref) { + const Component = forwardRef(function FontAwesomeIcon(props, ref) { return ( { it('renders children', () => { diff --git a/components/src/next/primitives/IconButton/IconButton.tsx b/components/src/next/primitives/IconButton/IconButton.tsx index 4882e5cf..df3e1f26 100644 --- a/components/src/next/primitives/IconButton/IconButton.tsx +++ b/components/src/next/primitives/IconButton/IconButton.tsx @@ -16,6 +16,7 @@ import clsx from 'clsx'; import { forwardRef } from 'react'; import type { ButtonHTMLAttributes } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { ButtonVariant } from '../Button/Button'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; @@ -34,6 +35,7 @@ export interface IconButtonProps SpacingProps, SizingProps, DisplayProps { + style?: PersesCSSProperties; 'aria-label': string; variant?: ButtonVariant; color?: ColorVariant | Status; diff --git a/components/src/next/primitives/Spinner/Spinner.tsx b/components/src/next/primitives/Spinner/Spinner.tsx index 9b1c2865..72d3511d 100644 --- a/components/src/next/primitives/Spinner/Spinner.tsx +++ b/components/src/next/primitives/Spinner/Spinner.tsx @@ -12,11 +12,13 @@ // limitations under the License. import clsx from 'clsx'; -import type { ReactElement, SVGProps } from 'react'; +import type { ReactElement } from 'react'; + +import type { StyledSVGProps } from '../../types'; import './spinner.css'; -export type SpinnerProps = SVGProps; +export type SpinnerProps = StyledSVGProps; export function Spinner({ className, ...rest }: SpinnerProps): ReactElement { return ( diff --git a/components/src/next/primitives/Stack/Stack.tsx b/components/src/next/primitives/Stack/Stack.tsx index 58f3359f..e3499a43 100644 --- a/components/src/next/primitives/Stack/Stack.tsx +++ b/components/src/next/primitives/Stack/Stack.tsx @@ -15,6 +15,7 @@ import clsx from 'clsx'; import { forwardRef } from 'react'; import type { HTMLAttributes } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { applyStyleProps, compileStyleProps, displayProps, flexboxProps, sizingProps, spacingProps } from '../system'; import type { DisplayProps, FlexboxProps, Responsive, SizingProps, SpacingProps, SpacingValue } from '../system'; @@ -29,6 +30,7 @@ export interface StackProps SizingProps, Omit, Pick { + style?: PersesCSSProperties; /** Main axis of the stack. Defaults to `column`. */ direction?: Responsive<'row' | 'column'>; /** Space between children: a spacing token, numeric pixels, or a CSS spacing value. */ diff --git a/components/src/next/primitives/Switch/Switch.test.tsx b/components/src/next/primitives/Switch/Switch.test.tsx index 3f47102d..4edd707a 100644 --- a/components/src/next/primitives/Switch/Switch.test.tsx +++ b/components/src/next/primitives/Switch/Switch.test.tsx @@ -14,15 +14,15 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef } from 'react'; -import type { CSSProperties } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { ColorVariant, Size, Status } from '../types'; import { Switch } from './Switch'; import type { SwitchStyle } from './Switch'; const noop = (): void => {}; const RESPONSIVE_WIDTH = { default: 100, md: 200 }; -const WIDTH_OVERRIDE = { '--ps-width': '50px' } as CSSProperties; +const WIDTH_OVERRIDE = { '--ps-width': '50px' } satisfies PersesCSSProperties; if (typeof window.PointerEvent === 'undefined') { window.PointerEvent = window.MouseEvent as unknown as typeof PointerEvent; diff --git a/components/src/next/primitives/Switch/Switch.tsx b/components/src/next/primitives/Switch/Switch.tsx index b5cdc844..ac7fac06 100644 --- a/components/src/next/primitives/Switch/Switch.tsx +++ b/components/src/next/primitives/Switch/Switch.tsx @@ -16,6 +16,7 @@ import clsx from 'clsx'; import { forwardRef, useId, useMemo } from 'react'; import type { CSSProperties, HTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import type { ColorVariant, Size, Status } from '../types'; @@ -67,7 +68,7 @@ export interface SwitchProps /** Renders a clickable label next to the switch, wrapping both in a single `...
* */ -export type TableContainerProps = HTMLAttributes; +export interface TableContainerProps extends HTMLAttributes { + style?: PersesCSSProperties; +} export const TableContainer = forwardRef(function TableContainer( { className, ...props }, @@ -87,6 +90,7 @@ export const TableContainer = forwardRef(fu * */ export interface TableProps extends HTMLAttributes { + style?: PersesCSSProperties; size?: Size; stickyHeader?: boolean; /** @@ -115,7 +119,9 @@ export const Table = forwardRef(function Table( ); }); -export type TableHeadProps = HTMLAttributes; +export interface TableHeadProps extends HTMLAttributes { + style?: PersesCSSProperties; +} export const TableHead = forwardRef(function TableHead( { className, ...props }, @@ -124,7 +130,9 @@ export const TableHead = forwardRef(fun return ; }); -export type TableBodyProps = HTMLAttributes; +export interface TableBodyProps extends HTMLAttributes { + style?: PersesCSSProperties; +} export const TableBody = forwardRef(function TableBody( { className, ...props }, @@ -133,7 +141,9 @@ export const TableBody = forwardRef(fun return ; }); -export type TableFooterProps = HTMLAttributes; +export interface TableFooterProps extends HTMLAttributes { + style?: PersesCSSProperties; +} export const TableFooter = forwardRef(function TableFooter( { className, ...props }, @@ -151,6 +161,7 @@ export const TableFooter = forwardRef * */ export interface TableRowProps extends HTMLAttributes { + style?: PersesCSSProperties; selected?: boolean; hover?: boolean; } @@ -180,6 +191,7 @@ export const TableRow = forwardRef(function * Value */ interface TableCellBaseProps extends Omit, 'onResize'>, DisplayProps { + style?: PersesCSSProperties; header?: boolean; align?: 'left' | 'center' | 'right'; size?: Size; diff --git a/components/src/next/primitives/Table/TablePagination.tsx b/components/src/next/primitives/Table/TablePagination.tsx index b1277f2d..0e489c50 100644 --- a/components/src/next/primitives/Table/TablePagination.tsx +++ b/components/src/next/primitives/Table/TablePagination.tsx @@ -16,6 +16,7 @@ import { forwardRef, useCallback } from 'react'; import type { ChangeEvent, HTMLAttributes, ReactElement, ReactNode } from 'react'; import { useComponents } from '../../contexts/ComponentsProvider'; +import type { PersesCSSProperties } from '../../types'; import { ChevronLeftIcon, ChevronRightIcon, Icon } from '../Icon'; // oxlint-disable-next-line import/no-unassigned-import -- This import intentionally registers the table styles. @@ -38,6 +39,7 @@ import './table.css'; * } nextIcon={} ... /> */ export interface TablePaginationProps extends HTMLAttributes { + style?: PersesCSSProperties; /** Total number of rows across all pages. */ count: number; /** Current zero-based page index. */ diff --git a/components/src/next/primitives/Table/TableRowExpandButton.tsx b/components/src/next/primitives/Table/TableRowExpandButton.tsx index f6d32c36..8f5ef8e9 100644 --- a/components/src/next/primitives/Table/TableRowExpandButton.tsx +++ b/components/src/next/primitives/Table/TableRowExpandButton.tsx @@ -15,6 +15,7 @@ import { clsx } from 'clsx'; import { forwardRef } from 'react'; import type { ButtonHTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { ChevronRightIcon, Icon } from '../Icon'; // oxlint-disable-next-line import/no-unassigned-import -- This import intentionally registers shared button styles. @@ -26,6 +27,7 @@ interface TableRowExpandButtonBaseProps extends Omit< ButtonHTMLAttributes, 'children' | 'aria-label' > { + style?: PersesCSSProperties; expanded?: boolean; icon?: ReactElement; } diff --git a/components/src/next/primitives/Table/TableSortLabel.tsx b/components/src/next/primitives/Table/TableSortLabel.tsx index 15ce5673..2c571b29 100644 --- a/components/src/next/primitives/Table/TableSortLabel.tsx +++ b/components/src/next/primitives/Table/TableSortLabel.tsx @@ -15,6 +15,7 @@ import { clsx } from 'clsx'; import { forwardRef } from 'react'; import type { ButtonHTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { ArrowDownIcon, Icon } from '../Icon'; import type { SortDirection } from './Table'; @@ -45,6 +46,7 @@ interface TableSortLabelBaseProps extends Omit< ButtonHTMLAttributes, 'children' | 'aria-label' | 'aria-labelledby' > { + style?: PersesCSSProperties; active?: boolean; direction?: SortDirection; /** diff --git a/components/src/next/primitives/Text/Text.tsx b/components/src/next/primitives/Text/Text.tsx index 293aa36d..781a7159 100644 --- a/components/src/next/primitives/Text/Text.tsx +++ b/components/src/next/primitives/Text/Text.tsx @@ -16,6 +16,7 @@ import { clsx } from 'clsx'; import { createElement, forwardRef } from 'react'; import type { CSSProperties, HTMLAttributes } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import type { Status } from '../types'; @@ -25,6 +26,7 @@ import '../system/spacing.css'; import './text.css'; export interface TextProps extends Omit, 'color'>, SpacingProps, SizingProps, DisplayProps { + style?: PersesCSSProperties; /** Semantic element to render. Typography is independent of the chosen element. Defaults to `span`. */ as?: 'span' | 'p' | 'div' | 'strong' | 'em' | 'small'; /** Font-size token. Defaults to `md`. */ diff --git a/components/src/next/primitives/TextField/TextField.tsx b/components/src/next/primitives/TextField/TextField.tsx index 0742b8c7..c6d7c100 100644 --- a/components/src/next/primitives/TextField/TextField.tsx +++ b/components/src/next/primitives/TextField/TextField.tsx @@ -16,6 +16,7 @@ import clsx from 'clsx'; import { forwardRef, useId, useMemo } from 'react'; import type { CSSProperties, InputHTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; import type { DisplayProps, SizingProps, SpacingProps } from '../system'; import { applyStyleProps, compileStyleProps, displayProps, sizingProps, spacingProps } from '../system'; import { TextHelper } from '../TextHelper'; @@ -50,7 +51,7 @@ interface TextFieldBaseProps SizingProps, Pick { /** Inline styles for the root; accepts the `--perses-textfield-size-` override custom properties. */ - style?: TextFieldStyle; + style?: PersesCSSProperties; /** Hint or validation message, linked to the control through `aria-describedby`. */ helperText?: ReactNode; /** Visual status. `error` also sets `aria-invalid` on the control. */ diff --git a/components/src/next/primitives/TextHelper/TextHelper.tsx b/components/src/next/primitives/TextHelper/TextHelper.tsx index 02475c10..4604df1a 100644 --- a/components/src/next/primitives/TextHelper/TextHelper.tsx +++ b/components/src/next/primitives/TextHelper/TextHelper.tsx @@ -14,6 +14,7 @@ import { clsx } from 'clsx'; import { forwardRef } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { Text } from '../Text'; import type { TextProps } from '../Text'; import type { Status } from '../types'; @@ -22,6 +23,7 @@ import type { Status } from '../types'; import './textHelper.css'; export interface TextHelperProps extends Omit { + style?: PersesCSSProperties; /** Visual status; does not announce messages or mark the associated control invalid. */ status?: Status; } diff --git a/components/src/next/primitives/Tooltip/Tooltip.tsx b/components/src/next/primitives/Tooltip/Tooltip.tsx index 5adc00b7..ce3fccff 100644 --- a/components/src/next/primitives/Tooltip/Tooltip.tsx +++ b/components/src/next/primitives/Tooltip/Tooltip.tsx @@ -17,6 +17,8 @@ import { clsx } from 'clsx'; import { cloneElement, forwardRef, useId, useState } from 'react'; import type { CSSProperties, HTMLAttributes, ReactElement, ReactNode } from 'react'; +import type { PersesCSSProperties } from '../../types'; + import './tooltip.css'; /** Mirrors --perses-spacing-sm (8px). */ @@ -33,6 +35,7 @@ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'; * The popup portals to document.body; ancestor-scoped themes and token overrides do not propagate to it. */ export interface TooltipProps extends Omit, 'title' | 'children'> { + style?: PersesCSSProperties; /** * Popup content. It supplements, and should not replace, the trigger's accessible name. * `''`, `false`, `null`, or `undefined` disables the tooltip and renders only the trigger, diff --git a/components/src/next/primitives/index.ts b/components/src/next/primitives/index.ts index 0ffa3f5e..8ce66a2c 100644 --- a/components/src/next/primitives/index.ts +++ b/components/src/next/primitives/index.ts @@ -28,3 +28,4 @@ export * from './TextField'; export * from './Text'; export * from './TextHelper'; export type { ColorVariant, Size, Status } from './types'; +export type { PersesCSSProperties } from '../types'; diff --git a/components/src/next/primitives/system/styleProps.test.ts b/components/src/next/primitives/system/styleProps.test.ts index 269392e9..b05293b2 100644 --- a/components/src/next/primitives/system/styleProps.test.ts +++ b/components/src/next/primitives/system/styleProps.test.ts @@ -112,6 +112,29 @@ describe('applyStyleProps', () => { expect(style).toEqual({ '--ps-p': 'var(--perses-spacing-md)', padding: '99px' }); }); + it('preserves mixed custom properties and lets caller variables override computed ones', () => { + const { style } = applyStyleProps({ p: 'md', width: { md: 400 } }, spacingAndSizing, { + color: 'red', + '--perses-bg-surface': 'white', + '--perses-selection-control-size-md': '1.5rem', + '--perses-checkbox-size-md': '24px', + '--app-accent': 'blue', + '--app-scale': 2, + '--ps-p': '2rem', + '--ps-width-md': '500px', + }); + expect(style).toEqual({ + color: 'red', + '--perses-bg-surface': 'white', + '--perses-selection-control-size-md': '1.5rem', + '--perses-checkbox-size-md': '24px', + '--app-accent': 'blue', + '--app-scale': 2, + '--ps-p': '2rem', + '--ps-width-md': '500px', + }); + }); + it('ignores undefined prop values', () => { const { className, style } = applyStyleProps({ p: undefined, width: 10 }, spacingAndSizing); expect(className).toBe('ps-width'); diff --git a/components/src/next/primitives/system/styleProps.ts b/components/src/next/primitives/system/styleProps.ts index af6cff0e..7424d68e 100644 --- a/components/src/next/primitives/system/styleProps.ts +++ b/components/src/next/primitives/system/styleProps.ts @@ -12,8 +12,8 @@ // limitations under the License. import clsx from 'clsx'; -import type { CSSProperties } from 'react'; +import type { PersesCSSProperties } from '../../types'; import { isResponsiveValue } from './responsive'; import type { Responsive } from './responsive'; @@ -38,7 +38,7 @@ function cssName(prop: string): string { function writeStyleProp( entry: CompiledStyleProp, value: Responsive, - target: Record, + target: Record<`--${string}`, string | number | undefined>, classNames: string[], ): void { const { className, varPrefix, transform } = entry; @@ -60,7 +60,7 @@ function writeStyleProp( interface CompiledStyleProp { prop: string; className: string; - varPrefix: string; + varPrefix: `--ps-${string}`; transform: (value: unknown) => string; } @@ -110,10 +110,10 @@ export function compileStyleProps, K extends string>( props: Props, compiled: CompiledStyleProps, - style?: CSSProperties, -): { className: string; style: CSSProperties; rest: Omit } { + style?: PersesCSSProperties, +): { className: string; style: PersesCSSProperties; rest: Omit } { const classNames: string[] = []; - const computed: Record = {}; + const computed: Record<`--${string}`, string | number | undefined> = {}; for (const entry of compiled.props) { const value = props[entry.prop] as Responsive | undefined; @@ -129,5 +129,5 @@ export function applyStyleProps, K extends if (!keys.has(prop)) rest[prop] = props[prop]; } - return { className: clsx(classNames), style: computed as CSSProperties, rest: rest as Omit }; + return { className: clsx(classNames), style: computed, rest: rest as Omit }; } diff --git a/components/src/next/types.test.tsx b/components/src/next/types.test.tsx new file mode 100644 index 00000000..d23e6ea7 --- /dev/null +++ b/components/src/next/types.test.tsx @@ -0,0 +1,167 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import type { ComponentProps, CSSProperties } from 'react'; +import { expectTypeOf } from 'vitest'; + +import type { PersesComponents, PersesCSSProperties, PersesIcons } from './index'; +import { AddIcon, Box, Checkbox } from './primitives'; +import type { + CheckboxProps, + CheckboxStyle, + ChipProps, + ChipStyle, + IconProps, + PersesCSSProperties as PrimitiveCSSProperties, + SwitchProps, + SwitchStyle, + TextFieldProps, + TextFieldStyle, +} from './primitives'; + +// These type-level contracts are checked by `npm run type-check`. +type AssertAssignable = T; +type AssertNever = T; +type AssertFalse = T; +type StyleKeys = T extends unknown ? keyof T : never; +type StyleValue = T extends unknown ? (Key extends keyof T ? T[Key] : never) : never; + +interface ExtendedCheckboxStyle extends CheckboxStyle { + '--app-accent': string; +} +interface ExtendedChipStyle extends ChipStyle { + '--app-accent': string; +} +interface ExtendedSwitchStyle extends SwitchStyle { + '--app-accent': string; +} +interface ExtendedTextFieldStyle extends TextFieldStyle { + '--app-accent': string; +} + +// Derived from the component registry so every registered primitive and icon is checked without listing it here. +type StyledProps = { + [Name in keyof PersesComponents]: ComponentProps; +} & { + [Name in keyof PersesIcons as `${Name}Icon`]: ComponentProps; +} & { + AddIcon: ComponentProps; + Icon: IconProps; +}; +type StyleOf = Props extends { style?: infer Style } ? NonNullable