From 5fa996c317512faa965df5fc46debed0a2d78ec6 Mon Sep 17 00:00:00 2001 From: Gabriel Bernal Date: Tue, 27 Oct 2026 09:36:23 +0100 Subject: [PATCH 1/5] [IGNORE] add assertions to select control sizes Signed-off-by: Gabriel Bernal --- design-tokens/README.md | 15 ----- design-tokens/src/test/type-assertions.ts | 71 ++++++----------------- design-tokens/src/types.ts | 3 + 3 files changed, 22 insertions(+), 67 deletions(-) diff --git a/design-tokens/README.md b/design-tokens/README.md index f690b7a3..83655045 100644 --- a/design-tokens/README.md +++ b/design-tokens/README.md @@ -26,18 +26,3 @@ initializes the same complete layer order before its own declarations: `@perses-dev/design-tokens/css/layers` is the public, order-only prelude. It contains no reset, selectors, or token values. Components use this export internally; consumers do not need a bootstrap import to initialize library order. - -## Disabled opacity - -`tokens.opacity.disabled` references `var(--perses-opacity-disabled)`, which defaults to `0.5` in both light and dark modes. -Use it for disabled-state opacity without replacing status or custom colors. Override the semantic token globally or in a -scoped container: - -```css -:root { - --perses-opacity-disabled: 0.6; -} -``` - -The variable is included in the public `SemanticOpacityVar` and `PersesTokenVar` types. The existing -`--perses-text-disabled` and `tokens.text.disabled` color tokens remain available for consumers that need them. diff --git a/design-tokens/src/test/type-assertions.ts b/design-tokens/src/test/type-assertions.ts index 3210855a..22e5bab5 100644 --- a/design-tokens/src/test/type-assertions.ts +++ b/design-tokens/src/test/type-assertions.ts @@ -15,64 +15,31 @@ // PersesTokenVar, `npm run type-check` will fail here. // This file is never executed — it only needs to pass tsc. -import type { tokens, PersesTokenVar, SemanticOpacityVar } from '../index'; +import type { tokens, PersesTokenVar, SelectionControlSizeVar, SemanticOpacityVar } from '../index'; -type ExtractVar = T extends `var(${infer V})` ? V : never; +type ExtractVars = T extends `var(${infer V})` + ? V + : T extends object + ? { [K in keyof T]: ExtractVars }[keyof T] + : never; type AssertAssignable = T; -// Verify that the var() references in the tokens object produce variable names -// that are assignable to PersesTokenVar. If a token references a CSS variable -// not covered by PersesTokenVar, tsc will emit an error on this line. -type _BgVars = AssertAssignable, PersesTokenVar>; -type _BgSurface = AssertAssignable, PersesTokenVar>; -type _BgSunken = AssertAssignable, PersesTokenVar>; -type _BgOverlay = AssertAssignable, PersesTokenVar>; -type _BgBackdrop = AssertAssignable, PersesTokenVar>; -type _BgNav = AssertAssignable, PersesTokenVar>; +// Check every var() leaf, including nested families and numeric color stops. +type _AllTokenVars = AssertAssignable, PersesTokenVar>; -type _BorderDefault = AssertAssignable, PersesTokenVar>; -type _BorderControl = AssertAssignable, PersesTokenVar>; -type _FocusRingWidth = AssertAssignable, PersesTokenVar>; -type _FocusRingOffset = AssertAssignable, PersesTokenVar>; -type _FocusRingColor = AssertAssignable, PersesTokenVar>; +type _SelectionControlSizeXs = AssertAssignable<'--perses-selection-control-size-xs', SelectionControlSizeVar>; +type _SelectionControlSizeSm = AssertAssignable<'--perses-selection-control-size-sm', SelectionControlSizeVar>; +type _SelectionControlSizeMd = AssertAssignable<'--perses-selection-control-size-md', SelectionControlSizeVar>; +type _SelectionControlSizeLg = AssertAssignable<'--perses-selection-control-size-lg', SelectionControlSizeVar>; +type _SelectionControlSizeVars = AssertAssignable; -type _TextPrimary = AssertAssignable, PersesTokenVar>; -type _TextSecondary = AssertAssignable, PersesTokenVar>; -type _TextDisabled = AssertAssignable, PersesTokenVar>; -type _TextLink = AssertAssignable, PersesTokenVar>; -type _TextLinkHover = AssertAssignable, PersesTokenVar>; -type _TextNav = AssertAssignable, PersesTokenVar>; -type _TextAccent = AssertAssignable, PersesTokenVar>; - -type _OpacityDisabled = AssertAssignable, PersesTokenVar>; +type _OpacityDisabled = AssertAssignable, PersesTokenVar>; type _OpacityDisabledVar = AssertAssignable<'--perses-opacity-disabled', PersesTokenVar>; type _SemanticOpacityVar = AssertAssignable<'--perses-opacity-disabled', SemanticOpacityVar>; -type _TooltipBg = AssertAssignable, PersesTokenVar>; -type _TooltipText = AssertAssignable, PersesTokenVar>; - -type _SpacingXs = AssertAssignable, PersesTokenVar>; -type _SpacingLg = AssertAssignable, PersesTokenVar>; - -type _ControlSizeXs = AssertAssignable, PersesTokenVar>; -type _ControlSizeLg = AssertAssignable, PersesTokenVar>; - -type _RadiusMd = AssertAssignable, PersesTokenVar>; -type _RadiusFull = AssertAssignable, PersesTokenVar>; - -type _ShadowOverlay = AssertAssignable, PersesTokenVar>; -type _ZIndexOverlay = AssertAssignable, PersesTokenVar>; - -type _FontFamily = AssertAssignable, PersesTokenVar>; -type _FontWeightBold = AssertAssignable, PersesTokenVar>; -type _FontSizeSm = AssertAssignable, PersesTokenVar>; -type _LineHeightTight = AssertAssignable, PersesTokenVar>; - -type _StatusErrorBg = AssertAssignable, PersesTokenVar>; -type _StatusSuccessText = AssertAssignable, PersesTokenVar>; -type _StatusWarningBorder = AssertAssignable, PersesTokenVar>; -type _StatusInfoIcon = AssertAssignable, PersesTokenVar>; - -type _ColorBlue500 = AssertAssignable, PersesTokenVar>; -type _ColorWhite = AssertAssignable, PersesTokenVar>; +type _UnknownVarRejected = AssertAssignable<'--perses-unknown' extends PersesTokenVar ? true : false, false>; +type _UnknownSelectionSizeRejected = AssertAssignable< + '--perses-selection-control-size-xl' extends PersesTokenVar ? true : false, + false +>; diff --git a/design-tokens/src/types.ts b/design-tokens/src/types.ts index e1000a04..c21f69b6 100644 --- a/design-tokens/src/types.ts +++ b/design-tokens/src/types.ts @@ -65,6 +65,8 @@ export type ControlSizeScale = 'xs' | 'sm' | 'md' | 'lg'; export type ControlSizeVar = `--perses-control-size-${ControlSizeScale}`; +export type SelectionControlSizeVar = `--perses-selection-control-size-${ControlSizeScale}`; + export type BorderWidthScale = 'sm' | 'md'; export type BorderWidthVar = `--perses-border-width-${BorderWidthScale}`; @@ -106,6 +108,7 @@ export type PersesTokenVar = | StatusSolidVar | SpacingVar | ControlSizeVar + | SelectionControlSizeVar | BorderWidthVar | FocusRingVar | RadiusVar From e067744a04daa138586585928f0f43d196dc8524 Mon Sep 17 00:00:00 2001 From: Gabriel Bernal Date: Tue, 27 Oct 2026 09:58:54 +0100 Subject: [PATCH 2/5] [IGNORE] wrap style prop to avoud casting to component styles Signed-off-by: Gabriel Bernal --- .../src/next/contexts/ComponentsContext.ts | 18 +- .../contexts/TokenCustomization.stories.tsx | 23 +- components/src/next/index.ts | 2 + .../src/next/primitives/Alert/Alert.tsx | 2 + components/src/next/primitives/Box/Box.tsx | 5 +- .../src/next/primitives/Button/Button.tsx | 2 + .../primitives/Checkbox/Checkbox.test.tsx | 3 +- .../src/next/primitives/Checkbox/Checkbox.tsx | 3 +- components/src/next/primitives/Chip/Chip.tsx | 8 +- components/src/next/primitives/Icon/Icon.tsx | 3 + .../next/primitives/Icon/icons/createIcon.tsx | 8 +- .../primitives/IconButton/IconButton.test.tsx | 4 +- .../next/primitives/IconButton/IconButton.tsx | 2 + .../src/next/primitives/Spinner/Spinner.tsx | 6 +- .../src/next/primitives/Stack/Stack.tsx | 2 + .../next/primitives/Switch/Switch.test.tsx | 4 +- .../src/next/primitives/Switch/Switch.tsx | 3 +- .../src/next/primitives/Table/Table.tsx | 20 +- .../next/primitives/Table/TablePagination.tsx | 2 + .../primitives/Table/TableRowExpandButton.tsx | 2 + .../next/primitives/Table/TableSortLabel.tsx | 2 + components/src/next/primitives/Text/Text.tsx | 2 + .../next/primitives/TextField/TextField.tsx | 3 +- .../next/primitives/TextHelper/TextHelper.tsx | 2 + .../src/next/primitives/Tooltip/Tooltip.tsx | 3 + components/src/next/primitives/index.ts | 1 + .../next/primitives/system/styleProps.test.ts | 23 ++ .../src/next/primitives/system/styleProps.ts | 14 +- components/src/next/types.test.tsx | 198 ++++++++++++++++++ components/src/next/types.ts | 29 +++ 30 files changed, 348 insertions(+), 51 deletions(-) create mode 100644 components/src/next/types.test.tsx create mode 100644 components/src/next/types.ts 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..15653820 --- /dev/null +++ b/components/src/next/types.test.tsx @@ -0,0 +1,198 @@ +// 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 { PersesCSSProperties, PersesIcons } from './index'; +import { AddIcon, Box, Checkbox } from './primitives'; +import type { + AlertProps, + BoxProps, + ButtonProps, + CheckboxProps, + CheckboxStyle, + ChipProps, + ChipStyle, + IconButtonProps, + IconProps, + PersesCSSProperties as PrimitiveCSSProperties, + SpinnerProps, + StackProps, + SwitchProps, + SwitchStyle, + TableBodyProps, + TableCellProps, + TableContainerProps, + TableFooterProps, + TableHeadProps, + TablePaginationProps, + TableProps, + TableRowExpandButtonProps, + TableRowProps, + TableSortLabelProps, + TextFieldProps, + TextFieldStyle, + TextHelperProps, + TextProps, + TooltipProps, +} 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; +} + +type StyledProps = { + AddIcon: ComponentProps; + RegistryIcon: ComponentProps; + Alert: AlertProps; + Box: BoxProps; + Button: ButtonProps; + Checkbox: CheckboxProps; + Chip: ChipProps; + Icon: IconProps; + IconButton: IconButtonProps; + Spinner: SpinnerProps; + Stack: StackProps; + Switch: SwitchProps; + Table: TableProps; + TableBody: TableBodyProps; + TableCell: TableCellProps; + TableContainer: TableContainerProps; + TableFooter: TableFooterProps; + TableHead: TableHeadProps; + TablePagination: TablePaginationProps; + TableRow: TableRowProps; + TableRowExpandButton: TableRowExpandButtonProps; + TableSortLabel: TableSortLabelProps; + Text: TextProps; + TextField: TextFieldProps; + TextHelper: TextHelperProps; + Tooltip: TooltipProps; +}; + +// Structural assignability alone would allow extra keys on non-literal objects, +// masking the excess-property errors callers encounter with inline styles. +type MissingCustomProperties = { + [Name in keyof StyledProps]: `--${string}` extends StyleKeys> ? never : Name; +}[keyof StyledProps]; +type _EveryStyleSupportsCustomProperties = AssertNever; + +type _ReactCompatible = AssertAssignable; +type _ExistingReactStylesAccepted = AssertAssignable; +type _PrimitiveExport = AssertAssignable; +type _MixedStyle = AssertAssignable< + { + display: 'flex'; + marginTop: 8; + '--perses-bg-surface': 'white'; + '--perses-selection-control-size-md': '1.5rem'; + '--perses-checkbox-size-md': '24px'; + '--app-accent': 'blue'; + '--app-scale': 2; + '--app-optional': undefined; + }, + PersesCSSProperties +>; + +type _UnknownCssPropertyRejected = AssertFalse<'colour' extends StyleKeys ? true : false>; +type _DisplayRemainsTyped = AssertAssignable; +type _BooleanCustomPropertyRejected = AssertFalse< + boolean extends StyleValue ? true : false +>; +type _ObjectCustomPropertyRejected = AssertFalse< + object extends StyleValue ? true : false +>; +type _ChipBorderStyleRemainsTyped = AssertAssignable< + ChipStyle['--perses-chip-border-style'], + CSSProperties['borderStyle'] +>; +type _CheckboxSizeRemainsTyped = AssertAssignable< + StyleValue, '--perses-checkbox-size-md'>, + string | undefined +>; +type _SwitchSizeRemainsTyped = AssertAssignable< + StyleValue, '--perses-switch-size-md'>, + string | undefined +>; +type _TextFieldSizeRemainsTyped = AssertAssignable< + StyleValue, '--perses-textfield-size-md'>, + string | undefined +>; +type _ExtendedCheckboxStyleAccepted = AssertAssignable>; +type _ExtendedChipStyleAccepted = AssertAssignable>; +type _ExtendedSwitchStyleAccepted = AssertAssignable>; +type _ExtendedTextFieldStyleAccepted = AssertAssignable>; + +// @ts-expect-error Misspelled ordinary CSS properties must remain errors. +expectTypeOf({ colour: 'red' } satisfies PersesCSSProperties); +// @ts-expect-error Custom properties do not accept booleans, even alongside ordinary CSS. +expectTypeOf({ display: 'block', '--app-enabled': true } satisfies PersesCSSProperties); +// @ts-expect-error Custom properties do not accept objects. +expectTypeOf({ color: 'red', '--app-value': {} } satisfies PersesCSSProperties); +// @ts-expect-error Component-specific size overrides require units as a string. +expectTypeOf({ '--perses-checkbox-size-md': 24 } satisfies PersesCSSProperties); +// @ts-expect-error Component-specific border styles retain their CSS value type, not arbitrary numbers. +expectTypeOf({ '--perses-chip-border-style': 1 } satisfies PersesCSSProperties); + +const MIXED_STYLE = { + marginTop: 8, + '--perses-bg-surface': 'white', + '--perses-selection-control-size-md': '1.5rem', + '--perses-checkbox-size-md': '24px', + '--app-accent': 'blue', + '--app-scale': 2, +} satisfies PersesCSSProperties; +const REACT_STYLE: CSSProperties = { marginTop: 8 }; + +describe('Perses inline styles', () => { + it('passes ordinary CSS and semantic, component, and application custom properties to the control', () => { + render(); + const control = screen.getByRole('checkbox', { name: 'Select item' }); + expect(control.style.marginTop).toBe('8px'); + expect(control.style.getPropertyValue('--perses-bg-surface')).toBe('white'); + expect(control.style.getPropertyValue('--perses-selection-control-size-md')).toBe('1.5rem'); + expect(control.style.getPropertyValue('--perses-checkbox-size-md')).toBe('24px'); + expect(control.style.getPropertyValue('--app-accent')).toBe('blue'); + expect(control.style.getPropertyValue('--app-scale')).toBe('2'); + }); + + it('passes application custom properties to SVG icons', () => { + render(); + const icon = screen.getByLabelText('Add item'); + expect(icon.style.getPropertyValue('--app-accent')).toBe('blue'); + expect(icon.style.getPropertyValue('--app-scale')).toBe('2'); + }); + + it('continues to accept existing React CSSProperties objects', () => { + render(Existing style); + expect(screen.getByText('Existing style')).toHaveStyle({ marginTop: '8px' }); + }); +}); diff --git a/components/src/next/types.ts b/components/src/next/types.ts new file mode 100644 index 00000000..b1256ce6 --- /dev/null +++ b/components/src/next/types.ts @@ -0,0 +1,29 @@ +// 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 type { CSSProperties, SVGProps } from 'react'; + +/** + * React inline styles with support for CSS custom properties. + * Custom-property numbers are written verbatim, without implicit units; use strings for lengths such as `'24px'`. + * The React-only branch preserves compatibility with existing `CSSProperties` interfaces without an index signature. + */ +export type PersesCSSProperties