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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions components/src/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> {
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<CheckboxStyle>;
```

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
Expand Down
18 changes: 6 additions & 12 deletions components/src/next/contexts/ComponentsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Props, Element> = ForwardRefExoticComponent<PropsWithoutRef<Props> & RefAttributes<Element>>;

Expand Down Expand Up @@ -82,10 +76,10 @@ export interface PersesComponents {
}

export interface PersesIcons {
Error: ComponentType<SVGProps<SVGSVGElement>>;
Info: ComponentType<SVGProps<SVGSVGElement>>;
Success: ComponentType<SVGProps<SVGSVGElement>>;
Warning: ComponentType<SVGProps<SVGSVGElement>>;
Error: ComponentType<StyledSVGProps>;
Info: ComponentType<StyledSVGProps>;
Success: ComponentType<StyledSVGProps>;
Warning: ComponentType<StyledSVGProps>;
}

export interface ComponentsContextValue {
Expand Down
23 changes: 11 additions & 12 deletions components/src/next/contexts/TokenCustomization.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -45,17 +54,7 @@ function TokenDemo(): ReactElement {

export const TokenCustomization: Story = () => (
<ComponentsProvider components={defaultComponents} icons={defaultIcons}>
<div
style={
{
'--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',
} as CSSProperties
}
>
<div style={TOKEN_OVERRIDES}>
<TokenDemo />
</div>
</ComponentsProvider>
Expand Down
2 changes: 2 additions & 0 deletions components/src/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions components/src/next/primitives/Alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,6 +49,7 @@ function isAlertSeverity(icon: unknown): icon is AlertSeverity {
const ALERT_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, displayProps]);

export interface AlertProps extends HTMLAttributes<HTMLDivElement>, SpacingProps, SizingProps, DisplayProps {
style?: PersesCSSProperties;
severity?: AlertSeverity;
icon?: AlertSeverity | ReactElement | number | boolean | null;
}
Expand Down
5 changes: 4 additions & 1 deletion components/src/next/primitives/Box/Box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,7 +26,9 @@ import '../system/spacing.css';
export type { Breakpoint, Responsive, ResponsiveObject, SpacingValue } from '../system';

export interface BoxProps
extends HTMLAttributes<HTMLDivElement>, SpacingProps, SizingProps, FlexboxProps, DisplayProps {}
extends HTMLAttributes<HTMLDivElement>, SpacingProps, SizingProps, FlexboxProps, DisplayProps {
style?: PersesCSSProperties;
}

const BOX_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, flexboxProps, displayProps]);

Expand Down
2 changes: 2 additions & 0 deletions components/src/next/primitives/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +34,7 @@ const BUTTON_STYLE_PROPS = compileStyleProps([spacingProps, sizingProps, display

export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'color'>, SpacingProps, SizingProps, DisplayProps {
style?: PersesCSSProperties;
variant?: ButtonVariant;
color?: ColorVariant | Status;
size?: Size;
Expand Down
3 changes: 2 additions & 1 deletion components/src/next/primitives/Checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckboxStyle>;
const MARGIN_STYLE = { marginTop: '8px' };
const SIZES: Size[] = ['xs', 'sm', 'md', 'lg'];
const VARIANTS: CheckboxVariant[] = ['solid', 'subtle', 'outline'];
Expand Down
3 changes: 2 additions & 1 deletion components/src/next/primitives/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -74,7 +75,7 @@ interface CheckboxBaseProps
size?: Size;
color?: ColorVariant | Status;
variant?: CheckboxVariant;
style?: CheckboxStyle;
style?: PersesCSSProperties<CheckboxStyle>;
}

/**
Expand Down
8 changes: 6 additions & 2 deletions components/src/next/primitives/Chip/Chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -60,7 +61,7 @@ interface ChipBaseProps
onClose?: MouseEventHandler<HTMLButtonElement>;
/** Maximum width of the label; longer text is truncated with an ellipsis. */
maxWidth?: CSSProperties['maxWidth'];
style?: ChipStyle;
style?: PersesCSSProperties<ChipStyle>;
}

/**
Expand All @@ -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<ChipStyle> | undefined,
maxWidth: CSSProperties['maxWidth'],
): PersesCSSProperties<ChipStyle> | undefined {
if (maxWidth === undefined) return style;
return { ...style, '--perses-chip-label-max-width': lengthTransform(maxWidth) };
}
Expand Down
3 changes: 3 additions & 0 deletions components/src/next/primitives/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSpanElement> {
style?: PersesCSSProperties;
children?: ReactNode;
}

Expand Down
8 changes: 5 additions & 3 deletions components/src/next/primitives/Icon/icons/createIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGProps<SVGSVGElement> & RefAttributes<SVGSVGElement>>;
import type { StyledSVGProps } from '../../../types';

export type IconComponent = ForwardRefExoticComponent<StyledSVGProps & RefAttributes<SVGSVGElement>>;

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<SVGSVGElement, SVGProps<SVGSVGElement>>(function FontAwesomeIcon(props, ref) {
const Component = forwardRef<SVGSVGElement, StyledSVGProps>(function FontAwesomeIcon(props, ref) {
return (
<svg
ref={ref}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CSSProperties } from 'react';

import type { PersesCSSProperties } from '../../types';
import { IconButton } from './IconButton';

const RESPONSIVE_WIDTH = { default: 100, md: 200 };
const WIDTH_OVERRIDE = { '--ps-width': '50px' } as CSSProperties;
const WIDTH_OVERRIDE = { '--ps-width': '50px' } satisfies PersesCSSProperties;

describe('IconButton', () => {
it('renders children', () => {
Expand Down
2 changes: 2 additions & 0 deletions components/src/next/primitives/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -34,6 +35,7 @@ export interface IconButtonProps
SpacingProps,
SizingProps,
DisplayProps {
style?: PersesCSSProperties;
'aria-label': string;
variant?: ButtonVariant;
color?: ColorVariant | Status;
Expand Down
6 changes: 4 additions & 2 deletions components/src/next/primitives/Spinner/Spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGSVGElement>;
export type SpinnerProps = StyledSVGProps;

export function Spinner({ className, ...rest }: SpinnerProps): ReactElement {
return (
Expand Down
2 changes: 2 additions & 0 deletions components/src/next/primitives/Stack/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -29,6 +30,7 @@ export interface StackProps
SizingProps,
Omit<FlexboxProps, 'flexDirection'>,
Pick<DisplayProps, 'overflow'> {
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. */
Expand Down
4 changes: 2 additions & 2 deletions components/src/next/primitives/Switch/Switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SwitchStyle>;

if (typeof window.PointerEvent === 'undefined') {
window.PointerEvent = window.MouseEvent as unknown as typeof PointerEvent;
Expand Down
3 changes: 2 additions & 1 deletion components/src/next/primitives/Switch/Switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,7 +68,7 @@ export interface SwitchProps
/** Renders a clickable label next to the switch, wrapping both in a single `<label>`. */
label?: ReactNode;
/** Inline style, extended with typed selection-control size override custom properties. */
style?: SwitchStyle;
style?: PersesCSSProperties<SwitchStyle>;
}

/**
Expand Down
Loading
Loading