diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index ba98b172d..4a2c8c123 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -54,6 +54,7 @@ import { } from '../dataview-demo'; import LinearMenuDemo from '../linear-menu-demo'; import PopoverColorPicker from '../popover-color-picker'; +import ThemePanelDemo from '../theme-panel-demo'; import TourDemo from '../tour-demo'; import DemoPlayground from './demo-playground'; import DemoPreview from './demo-preview'; @@ -88,6 +89,7 @@ export default function Demo(props: DemoProps) { ChipInputDemo, LinearMenuDemo, PopoverColorPicker, + ThemePanelDemo, TourDemo, NextLink, AlignCenter, diff --git a/apps/www/src/components/theme-panel-demo.tsx b/apps/www/src/components/theme-panel-demo.tsx new file mode 100644 index 000000000..d7b1e05de --- /dev/null +++ b/apps/www/src/components/theme-panel-demo.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + ACCENT_COLORS, + Avatar, + Badge, + Button, + Callout, + Checkbox, + Chip, + Flex, + GRAY_COLOR_VALUES, + Input, + PANEL_BACKGROUNDS, + Popover, + Progress, + RADII, + SCALINGS, + Select, + Separator, + Switch, + Text, + ThemePreview, + type ThemeSettings, + Tooltip, + useThemePreview +} from '@raystack/apsara'; +import { useState } from 'react'; + +const APPEARANCES = ['light', 'dark', 'system'] as const; + +/** A live control for every setting, next to a sampler of components. */ +function Controls() { + const { value, resolved, setValue } = useThemePreview(); + + const field = ( + label: string, + key: K, + options: readonly string[] + ) => ( + + + {label} + + + + ); + + return ( + + {field('Appearance', 'appearance', APPEARANCES)} + {field('Accent', 'accentColor', ACCENT_COLORS)} + {field('Gray', 'grayColor', GRAY_COLOR_VALUES)} + {field('Radius', 'radius', RADII)} + {field('Scaling', 'scaling', SCALINGS)} + {field('Panel', 'panelBackground', PANEL_BACKGROUNDS)} + {field('Reduced motion', 'reducedMotion', ['system', 'true', 'false'])} + + + + Resolved: {resolved.appearance} · {resolved.grayColor} + + + ); +} + +function Sampler() { + const [checked, setChecked] = useState(true); + + return ( + + + + + + + + + + Badge + Chip + + + + + + + + Callouts follow the accent and the radius factor. + + + + Tooltip} + /> + Portalled, and still themed + + + + Popover} + /> + + + Theme values cross the portal through context, so this popup + matches the scope it was opened from. + + + + + + ); +} + +/** `isRoot={false}`: one example on a page, not the page itself. */ +export default function ThemePanelDemo() { + return ( + + + + + + + + ); +} diff --git a/apps/www/src/content/docs/theme/meta.json b/apps/www/src/content/docs/theme/meta.json index 47be1c910..067f5f840 100644 --- a/apps/www/src/content/docs/theme/meta.json +++ b/apps/www/src/content/docs/theme/meta.json @@ -2,6 +2,7 @@ "title": "Theme", "pages": [ "overview", + "preview", "colors", "typography", "spacing", diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 1bf92cce7..ed79bae52 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -7,6 +7,14 @@ import { switcherDemo, switcherSizeDemo } from "./demo.ts"; Apsara provides a theming system built on CSS custom properties (tokens). Tokens are semantic variables that automatically resolve to appropriate values based on the active theme—so your UI adapts seamlessly when users switch between light and dark modes or when you change accent colors, without any code changes. + + This page documents the original `Theme` component, which continues to ship + unchanged. New applications should use + [`ThemePreview`](/docs/theme/preview) — it server-renders, allows more than + one provider per page, works inside portals, and adds radius, scaling, panel + background and reduced-motion settings. It includes a migration guide. + + ## Installation Wrap your application with the `Theme` component: diff --git a/apps/www/src/content/docs/theme/preview/demo.ts b/apps/www/src/content/docs/theme/preview/demo.ts new file mode 100644 index 000000000..c9440480f --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/demo.ts @@ -0,0 +1,107 @@ +'use client'; + +export const scopeDemo = { + type: 'code', + code: ` + + + + Light scope + + + + + + + Dark scope + + + + ` +}; + +export const accentDemo = { + type: 'code', + code: ` + + {["indigo", "orange", "mint"].map(accent => ( + + + {accent} + + Badge + + + ))} + ` +}; + +export const radiusDemo = { + type: 'code', + code: ` + + {["none", "small", "medium", "large", "full"].map(radius => ( + + + {radius} + + + + ))} + ` +}; + +export const scalingDemo = { + type: 'code', + code: ` + + {["0.9", "1", "1.1"].map(scaling => ( + + + {scaling}x + + + + ))} + ` +}; + +export const componentRadiusDemo = { + type: 'code', + code: ` + + + {/* Follows the theme */} + + {/* Overrides it, without compounding */} + + + + + ` +}; + +export const switcherDemo = { + type: 'code', + code: `` +}; + +export const panelDemo = { + type: 'code', + code: `` +}; diff --git a/apps/www/src/content/docs/theme/preview/index.mdx b/apps/www/src/content/docs/theme/preview/index.mdx new file mode 100644 index 000000000..f44c7485d --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/index.mdx @@ -0,0 +1,430 @@ +--- +title: ThemePreview +description: The rewritten theme — tokens on a real element, seven settings, and per-component radius. +--- + +import { + accentDemo, + componentRadiusDemo, + panelDemo, + radiusDemo, + scalingDemo, + scopeDemo, + switcherDemo +} from "./demo.ts"; + +`ThemePreview` is the next `Theme`, shipping alongside the existing one so applications can migrate at their own pace. It mounts every token-bearing attribute on a **real element** instead of ``, which is what makes the root theme, a nested scope and a portal all behave the same way. + +Three long-standing problems close with that change: + +- **The root can be server-rendered.** Attributes on `` came only from an effect or a blocking script, so the server emitted nothing. Every setting is now an ordinary prop that renders on the first byte. +- **More than one provider can exist per page.** Nothing competes for ``, so an embedded widget or a second independent root just works. +- **Scoped themes reach into portals.** Theme values cross a portal through React context and are re-emitted onto the portalled element, so a popover opened inside a dark scope is dark. + + + `ThemePreview` is additive. The existing `Theme`, `useTheme` and `ThemeSwitcher` are unchanged and keep working. Do not nest one inside the other — pick one per application. + + +## Installation + +```tsx +import { ThemePreview } from "@raystack/apsara"; + +export default function App() { + return ( + + + + ); +} +``` + +Tokens live on the element `ThemePreview` renders, so everything that needs them must be inside it. Consumer CSS and hand-rolled portals mounted outside the provider will not resolve `--rs-*`; the `container` prop on every portalling component is the supported way to place portalled content back inside the theme. + +## Theme panel + +Every setting, live, next to a sampler of components. The panel is a scope with +`isRoot={false}`, so it re-themes itself without touching the page. + + + +## Settings + +One settings object describes the theme. Every key is independently seedable, controllable and persistable, and every key becomes a data attribute on the theme element. + +| Setting | Values | Default | Attribute | +|---|---|---|---| +| `appearance` | `light`, `dark`, `system` | `system` | `data-theme` (resolved) | +| `accentColor` | `indigo`, `orange`, `mint` | `indigo` | `data-accent-color` | +| `grayColor` | `gray`, `mauve`, `slate`, `sage`, `auto` | `auto` | `data-gray-color` (resolved) | +| `radius` | `none`, `small`, `medium`, `large`, `full` | `medium` | `data-radius` | +| `scaling` | `0.9`, `0.95`, `1`, `1.05`, `1.1` | `1` | `data-scaling` | +| `panelBackground` | `solid`, `translucent` | `solid` | `data-panel-background` | +| `reducedMotion` | `true`, `false`, `system` | `system` | `data-reduced-motion` | + +`appearance: "system"` resolves against `prefers-color-scheme` and `grayColor: "auto"` pairs a complementary gray to the accent, both before the attribute is written — `data-theme` only ever holds `light` or `dark`. + +Font families are deliberately not a setting. They are customised through CSS variables instead; see [Fonts](#fonts). + +### Appearance + + + +### Accent + + + +### Radius + +Radius is a factor applied to a fixed base scale, so `radius="small"` means the same thing in every configuration. + + + +### Scaling + +Scaling is a **zoom**, not a density control: it multiplies spacing, radius, type and line height together. Border and divider widths, and font weights, do not scale. + + + +## Controlled and uncontrolled + +`defaultValue` seeds uncontrolled keys; a stored user choice may override it. `value` is authoritative: a controlled key always wins, is never persisted and is never written by the inline script. + +Control is **per key**, so a page can drive appearance from a cookie while leaving accent and radius adjustable: + +```tsx + + + +``` + +## Persistence + +Persistence is **off** unless `persistKey` is set. A theme without one holds its settings in memory and emits no inline script, so a nested scope, an embedded widget and a second independent root all keep their own state by default and cannot collide. + +```tsx +// Persist everything under one namespace + + +// Persist only the appearance; the rest stays in memory + +``` + +A namespace is one `localStorage` entry holding one JSON object alongside a schema version. A write **merges**: it applies only the settings its `persist` covers and leaves every other field intact, including fields owned by a theme with a different `persist` on the same namespace. A missing or unparseable entry falls back to the seeded defaults and is overwritten on the next write; a field outside its union is discarded individually. + +Sharing a `persistKey` is a supported feature, not a hazard — it is exactly what a documentation page wants when several live examples should switch appearance together. Themes on one namespace stay in step within a document, and across tabs through the `storage` event. + +## Server rendering + +Accent, gray, radius, scaling and panel background are ordinary props, so React server-renders them correctly on the first byte. Only appearance can differ between the server and the browser, and only when it is uncontrolled and persisted — which is the one case the inline script covers. + +```tsx +// Next.js App Router: app/layout.tsx +import { ThemePreview } from "@raystack/apsara"; + +export default function RootLayout({ children }) { + return ( + + + {children} + + + ); +} +``` + +No `suppressHydrationWarning` on `` is needed: nothing is written there. The theme element carries it itself, which suppresses attribute diffs exactly one level deep. + +The script renders as the theme element's first child and patches its own parent. It is emitted only for a theme with a `persistKey`, and then only for that namespace's uncontrolled settings — a consumer reading appearance from a cookie ships no script at all. Pass `nonce` if your CSP requires one. + +## Scoping + +A nested `ThemePreview` inherits every key it does not set, so "inherit" is expressed by omission rather than by a value. + +```tsx + + {/* Only the accent changes; appearance, radius and scaling inherit */} + + + + +``` + +### hasBackground + +The component cannot infer whether it should paint, because re-tinting the accent and flipping a panel to dark use the same component but want opposite behaviour. `hasBackground` decides, and its default follows a heuristic: true at the root, true for a nested theme that sets an explicit `light` or `dark` appearance, false for one that only changes accent, gray, radius or scaling. + +Pass `hasBackground={false}` if your application paints its own page background. Foreground colour applies regardless. + +### isRoot + +Exactly one theme per document may own the page's colour scheme, which is what the browser paints in the overscroll area, the document scrollbar, the region below short content, and native widget defaults. That theme carries a `data-rs-root` marker, and `` derives `color-scheme` from it with `:has()` — no JavaScript, nothing written to ``. + +A theme claims the marker when it finds no ancestor theme. A theme that has no ancestor but does **not** own the page — an embedded widget, a micro-frontend — must pass `isRoot={false}`. Everything else about it is unchanged: it still carries `data-theme`, so its own subtree still gets a `color-scheme`; only the three document-level surfaces defer to the host. + +If two elements carry the marker with conflicting appearances, neither wins by position — both rules match `:root` at equal specificity, so `dark` wins because it is declared later. Set `isRoot={false}` on the one that does not own the page. + +### render + +`render` is the `asChild`-style escape hatch: it merges the theme's attributes onto an element you supply instead of adding a wrapper node. + +```tsx +}> + + +``` + +## The useThemePreview hook + +```tsx +import { useThemePreview } from "@raystack/apsara"; + +function AppearanceToggle() { + const { resolved, setValue } = useThemePreview(); + const isDark = resolved.appearance === "dark"; + + return ( + + ); +} +``` + + + +`value` is the settings as set, `system` and `auto` included; `resolved` is the settings as applied. Branch on `resolved`, not `value` — that is what the viewer is actually looking at. + +`root` is the same shape bound to the root provider, so a control inside a scope can flip the page theme: + +```tsx +function PageToggle() { + const { root } = useThemePreview(); + return ( + + ); +} +``` + +The hook **throws** outside a provider rather than returning a silent no-op: every colour token is declared under `[data-theme]`, so a tree with no provider has no colours at all. + +### ThemePreviewSwitcher + +A ready-made icon button that flips between light and dark. It reads `resolved.appearance`, so `system` shows the icon for what is actually on screen. + + + + + +## Per-component radius + +Components take a `radius` prop with the same five values as the theme setting. Two rules distinguish it from a subtree scope: + +1. It affects **only** the component it is set on, never anything inside it. Tree-level changes belong to `ThemePreview`. +2. It does **not compound** with the theme radius — a `large` theme with a `small` component yields small, not large multiplied by small. + + + +`radius` is available on `Button`, `IconButton`, `Badge`, `Callout`, `Chip`, `Input`, `TextArea`, `Image`, `Avatar`, and on the portalled sub-components: `Dialog.Content`, `AlertDialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu.Content`, `ContextMenu.Content`, `Select.Content`, `Combobox.Content`, `Tooltip.Content`, `PreviewCard.Content`, `Command.DialogContent` and `Tour.Content`. + +It lives on the portalled sub-component rather than the root because a portal carries nothing forward: ``, not ``. + +## Portals + +Every portalling component exposes a `container` prop, so portalled content can be placed inside a subtree you control: + +```tsx + +``` + +You rarely need it for theming — the inherited theme is re-emitted onto the portalled element automatically — but it is the supported way to keep portalled content inside a specific scroll container, dialog, or shadow root. + +## Panel background + +`panelBackground` selects between opaque and translucent overlay surfaces: dialogs, drawers, menus, popovers, selects, comboboxes, tooltips, toasts, preview cards, command palettes and tour cards. The default is `solid`, so translucency is opt-in. + +```tsx + +``` + +## Reduced motion + +`reducedMotion: "system"` is the default and honours `prefers-reduced-motion`, which fifty component stylesheets already respect. A forced `"true"` collapses the duration tokens to a near-zero value, which neutralises transitions and any animation whose duration comes from a token. + +It does not reach animations gated behind `@media (prefers-reduced-motion: no-preference)` blocks. Converting those is tracked separately. + +## Overriding tokens + +Every `--rs-*` declaration in the package is wrapped in `:where()`, so it contributes zero specificity. Every theme element — root, scope or portal re-injection — carries a stable, unhashed `rs-theme` class. A single-class rule of yours therefore beats every built-in token declaration, without `!important` and regardless of stylesheet order: + +```css +.rs-theme { + --rs-color-background-accent-emphasis: #6d28d9; + --rs-radius-3: 10px; +} +``` + +Scope it like any CSS: + +```css +.marketing-page .rs-theme { + --rs-font-title: "Playfair Display", serif; +} +``` + +Inline `style` works too, since tokens now live on a real element: + +```tsx + +``` + +This cuts both ways: an unintended selector can overwrite tokens as easily as an intended one. Given the alternative is overrides that cannot be made to work at all, it is the better failure. + +## Fonts + +Three CSS variables and no prop: + +| Token | Role | +|---|---| +| `--rs-font-body` | Body text | +| `--rs-font-title` | Headings | +| `--rs-font-mono` | Monospace | + +```css +.rs-theme { + --rs-font-body: "Geist", system-ui, sans-serif; + --rs-font-title: "Geist", system-ui, sans-serif; +} +``` + +There is no `fontFamily` prop. A font is a one-time branding choice with no runtime picker, and being free-form it could never be a data attribute like the seven settings. A prop would have to write inline custom properties, which beat every `:where()`-wrapped token rule — making fonts the one part of the token system you could not override from a stylesheet. + +Two stylesheets are published and you import exactly one: + +| Export | Contents | +|---|---| +| `@raystack/apsara/style.css` | Tokens, components, and the font imports | +| `@raystack/apsara/style-no-fonts.css` | Tokens and components, no font imports | + + + Custom fonts carry a caveat, not a guarantee. The typography scale pairs pixel font sizes with pixel line heights, and its letter spacing is tuned for Inter. A font with different metrics leaves line heights uncentred and tracking wrong, and because controls are sized by padding plus line-height, their dimensions shift with it. + + +## Migrating from Theme + +`ThemePreview` is a clean break rather than a superset. Migrate a whole application at once; do not nest the two. + +| Removed | Replacement | +|---|---| +| `theme` | `value.appearance` | +| `defaultTheme` | `defaultValue.appearance` | +| `forcedTheme` | `value.appearance` | +| `accentColor`, `grayColor` as flat props | `defaultValue.accentColor`, `defaultValue.grayColor` | +| `style` | `radius` plus the `--rs-font-*` tokens | +| `onThemeChange` | `onValueChange` | +| `enableSystem` | `appearance: "system"` | +| `enableColorScheme` | Handled by the stylesheet | +| `themes`, `attribute`, `value` as a name-to-attribute map | None. Arbitrary named themes are not supported | +| `ThemeProvider` alias | `ThemePreview` | +| `useTheme().theme` / `.setTheme` / `.resolvedTheme` / `.systemTheme` | `value` / `setValue` / `resolved` / `systemAppearance` | +| `useTheme().themes` / `.forcedTheme` / `.style` / `.scopes` | None | +| `useTheme({ storageKey })` | `useThemePreview().root` | +| `storageKey` | `persistKey`, which now also gates persistence rather than only naming it | +| Persistence at the root by default | `persistKey` is required to persist, at the root as well as in a scope | +| `ThemeSwitcher` | `ThemePreviewSwitcher` | + +### Before and after + +```tsx +// Before + track(resolved)} +> + + + +// After + { + if (changed.appearance) track(value.appearance); + }} +> + + +``` + +```tsx +// Before — force dark for a subtree + + + + +// After + + + +``` + +```tsx +// Before — flip the page theme from inside a scope +const { setTheme } = useTheme({ storageKey: "theme" }); + +// After +const { root } = useThemePreview(); +root.setValue({ appearance: "dark" }); +``` + +### style is retired + +`style="modern" | "traditional"` decomposed exactly into a radius level plus a font pair, both of which are now first-class. Traditional was not a constant multiple of modern — the two scales ran 2/4/6/8/12/16 and 8/16/20/24/32/40 — so it could not survive as a factor without changing its output. It becomes a recipe instead: + +```tsx + +``` + +```css +.rs-theme { + --rs-font-title: "Lora", serif; + --rs-font-body: "Josefin Sans", sans-serif; +} +``` + +### Component radius values + +`Image` and `Avatar` had bespoke radius scales disconnected from the theme; both now use the shared five values. + +- **`Image`** — `none`, `medium` and `full` are unchanged; `small` is now 0.75× the base step rather than a separate token, and `large` is new. +- **`Avatar`** — the default moves from `small` to `medium`, which renders exactly as the old default did. An explicit `radius="small"` is now slightly tighter; `full` is unchanged. + +### Other things to know + +- Tokens are no longer on ``, so consumer CSS and hand-rolled portals living outside the provider stop resolving `--rs-*`. Move them inside, or use a `container` prop. +- `useThemePreview` throws outside a provider instead of returning a no-op. +- The mono font stack now puts JetBrains Mono ahead of Menlo, so the imported face actually renders on macOS. + +## API Reference + +### ThemePreview + + + +### ThemeSettings + + diff --git a/apps/www/src/content/docs/theme/preview/props.ts b/apps/www/src/content/docs/theme/preview/props.ts new file mode 100644 index 000000000..39df2a905 --- /dev/null +++ b/apps/www/src/content/docs/theme/preview/props.ts @@ -0,0 +1,154 @@ +export type Appearance = 'light' | 'dark'; +export type AppearanceSetting = 'light' | 'dark' | 'system'; +export type AccentColor = 'indigo' | 'orange' | 'mint'; +export type GrayColorSetting = 'gray' | 'mauve' | 'slate' | 'sage' | 'auto'; +export type Radius = 'none' | 'small' | 'medium' | 'large' | 'full'; +export type Scaling = '0.9' | '0.95' | '1' | '1.05' | '1.1'; +export type PanelBackground = 'solid' | 'translucent'; +export type ReducedMotion = 'true' | 'false' | 'system'; + +/** One settings object describes the theme. Every key is independent. */ +export type ThemeSettings = { + /** + * Colour scheme. `system` resolves against `prefers-color-scheme`. + * @defaultValue "system" + */ + appearance: AppearanceSetting; + + /** + * Accent ramp. + * @defaultValue "indigo" + */ + accentColor: AccentColor; + + /** + * Gray ramp. `auto` pairs a complementary gray to the accent. + * @defaultValue "auto" + */ + grayColor: GrayColorSetting; + + /** + * Corner radius, applied as a factor over a fixed base scale. + * @defaultValue "medium" + */ + radius: Radius; + + /** + * Zoom. Multiplies spacing, radius, type and line height together. + * @defaultValue "1" + */ + scaling: Scaling; + + /** + * Whether overlay surfaces are opaque or translucent. + * @defaultValue "solid" + */ + panelBackground: PanelBackground; + + /** + * Motion preference. A forced value collapses the duration tokens. + * @defaultValue "system" + */ + reducedMotion: ReducedMotion; +}; + +export type ThemeSettingKey = keyof ThemeSettings; + +export type ThemePreviewProps = { + /** + * Partial settings that seed uncontrolled keys. A stored user choice + * overrides them, so this is a seed rather than a value. + */ + defaultValue?: Partial; + + /** + * Partial settings that are controlled. A controlled key always wins, is + * never persisted, and is never written by the inline script. Control is per + * key: drive `appearance` from a cookie while accent and radius stay + * adjustable. + */ + value?: Partial; + + /** Fires with the full next settings object and the changed subset. */ + onValueChange?: ( + value: ThemeSettings, + changed: Partial + ) => void; + + /** + * Which settings this namespace covers. + * @defaultValue all seven keys + */ + persist?: ThemeSettingKey[]; + + /** + * Storage namespace. Persistence is off unless this is set; a theme without + * one holds its settings in memory and emits no inline script. + */ + persistKey?: string; + + /** + * Whether this theme owns the document's colour scheme. An embedded widget + * or micro-frontend that has no ancestor theme but does not own the page + * must pass `false`. + * @defaultValue true when there is no ancestor theme + */ + isRoot?: boolean; + + /** + * Overrides the painting heuristic: true at the root, true for a nested + * theme that sets an explicit `light` or `dark` appearance, false for one + * that only changes accent, gray, radius or scaling. + */ + hasBackground?: boolean; + + /** + * Suppresses the 0.4s colour transition during an appearance switch. + * @defaultValue false + */ + disableTransitionOnChange?: boolean; + + /** CSP nonce for the inline script. */ + nonce?: string; + + /** `asChild`-style escape hatch: merges the theme onto your own element. */ + render?: React.ReactElement | ((props: object) => React.ReactElement); + + /** Extra classes. `rs-theme` is always present alongside them. */ + className?: string; + + children?: React.ReactNode; +}; + +/** The theme, as read and driven from anywhere inside a provider. */ +export type ThemeHandle = { + /** Settings as set, `system` and `auto` included. */ + value: ThemeSettings; + /** Settings as applied, with `system` and `auto` resolved. */ + resolved: ThemeSettings & { appearance: Appearance }; + /** Takes a partial settings object. Controlled keys are ignored. */ + setValue: (next: Partial) => void; + /** What the OS reports, whatever the current setting is. */ + systemAppearance: Appearance; +}; + +export type UseThemePreviewReturn = ThemeHandle & { + /** + * The same shape bound to the root provider, for flipping the page theme + * from inside a scope. + */ + root: ThemeHandle; +}; + +export type ThemePreviewSwitcherProps = { + /** + * Square size of the button box, in pixels. + * @defaultValue 30 + */ + size?: number; + /** + * Whether to flip the root theme rather than the nearest scope. + * @defaultValue "nearest" + */ + target?: 'nearest' | 'root'; +}; diff --git a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx index 47a17baf1..fef5ac0ce 100644 --- a/packages/raystack/components/alert-dialog/alert-dialog-content.tsx +++ b/packages/raystack/components/alert-dialog/alert-dialog-content.tsx @@ -3,6 +3,12 @@ import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import styles from '../dialog/dialog.module.css'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; export interface AlertDialogContentProps extends AlertDialogPrimitive.Popup.Props { @@ -12,6 +18,10 @@ export interface AlertDialogContentProps * `@default` true */ showNestedAnimation?: boolean; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this dialog only. Overrides the theme's `radius`. */ + radius?: Radius; } export const AlertDialogContent = ({ @@ -19,10 +29,13 @@ export const AlertDialogContent = ({ children, overlay, showNestedAnimation = true, + container, + radius, ...props }: AlertDialogContentProps) => { + const theme = useThemeInjection(); return ( - + { }); describe('Radius', () => { - const radii = ['small', 'full'] as const; + const radii = ['none', 'small', 'medium', 'large', 'full'] as const; it.each(radii)('renders %s radius', radius => { const { container } = render(); const avatar = container.querySelector('[class*="avatar"]'); - expect(avatar).toHaveClass(styles[`avatar-${radius}`]); + expect(avatar).toHaveClass(radiusClasses[radius]); }); - it('defaults to small radius', () => { + it('defaults to medium radius, which reproduces the old default', () => { const { container } = render(); const avatar = container.querySelector('[class*="avatar"]'); - expect(avatar).toHaveClass(styles['avatar-small']); + expect(avatar).toHaveClass(radiusClasses.medium); + }); + + it('takes its base step from the size class', () => { + const { container } = render(); + const avatar = container.querySelector('[class*="avatar"]'); + expect(avatar).toHaveClass(styles['avatar-size-10']); + expect(avatar).toHaveClass(radiusClasses.medium); }); }); @@ -229,7 +237,7 @@ describe('Avatar', () => { const overflowAvatar = screen .getByText('+1') .closest('[class*="avatar"]'); - expect(overflowAvatar).toHaveClass(styles['avatar-full']); + expect(overflowAvatar).toHaveClass(radiusClasses.full); }); it('matches first avatar variant', () => { diff --git a/packages/raystack/components/avatar/avatar.module.css b/packages/raystack/components/avatar/avatar.module.css index bff9f4f92..af9b36a40 100644 --- a/packages/raystack/components/avatar/avatar.module.css +++ b/packages/raystack/components/avatar/avatar.module.css @@ -16,9 +16,8 @@ --fallback-letter-spacing: 0.03em; } -.avatar.avatar-full { - border-radius: var(--rs-radius-full); -} +/* `radius` comes from the shared override; each `.avatar-size-N` block names + its own `--rs-radius-step`. */ .avatar-disabled { opacity: 0.5; @@ -260,7 +259,8 @@ height: var(--rs-space-5, 16px); --fallback-font-size: calc(var(--rs-space-5, 16px) * 0.4); --fallback-letter-spacing: 0.05em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-2 { @@ -268,7 +268,8 @@ height: var(--rs-space-6, 20px); --fallback-font-size: calc(var(--rs-space-6, 20px) * 0.4); --fallback-letter-spacing: 0.05em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-3 { @@ -276,7 +277,8 @@ height: var(--rs-space-7, 24px); --fallback-font-size: calc(var(--rs-space-7, 24px) * 0.4); --fallback-letter-spacing: 0.04em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-4 { @@ -284,7 +286,8 @@ height: var(--rs-space-8, 28px); --fallback-font-size: calc(var(--rs-space-8, 28px) * 0.35); --fallback-letter-spacing: 0.04em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-5 { @@ -292,7 +295,8 @@ height: var(--rs-space-9, 32px); --fallback-font-size: calc(var(--rs-space-9, 32px) * 0.4); --fallback-letter-spacing: 0.03em; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); + --rs-radius-step: 4px; } .avatar-size-6 { @@ -300,7 +304,8 @@ height: var(--rs-space-10, 40px); --fallback-font-size: calc(var(--rs-space-10, 40px) * 0.35); --fallback-letter-spacing: 0.02em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-7 { @@ -308,7 +313,8 @@ height: var(--rs-space-11, 48px); --fallback-font-size: calc(var(--rs-space-11, 48px) * 0.35); --fallback-letter-spacing: 0.01em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-8 { @@ -316,7 +322,8 @@ height: var(--rs-space-12, 56px); --fallback-font-size: calc(var(--rs-space-12, 56px) * 0.3); --fallback-letter-spacing: 0.01em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-9 { @@ -324,7 +331,8 @@ height: var(--rs-space-13, 64px); --fallback-font-size: calc(var(--rs-space-13, 64px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-4); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-4)); + --rs-radius-step: 8px; } .avatar-size-10 { @@ -332,7 +340,8 @@ height: var(--rs-space-14, 72px); --fallback-font-size: calc(var(--rs-space-14, 72px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-11 { @@ -340,7 +349,8 @@ height: var(--rs-space-15, 80px); --fallback-font-size: calc(var(--rs-space-15, 80px) * 0.3); --fallback-letter-spacing: 0em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-12 { @@ -348,7 +358,8 @@ height: var(--rs-space-16, 96px); --fallback-font-size: calc(var(--rs-space-16, 96px) * 0.3); --fallback-letter-spacing: -0.005em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } .avatar-size-13 { @@ -356,7 +367,8 @@ height: var(--rs-space-17, 120px); --fallback-font-size: calc(var(--rs-space-17, 120px) * 0.3); --fallback-letter-spacing: -0.01em; - border-radius: var(--rs-radius-5); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-5)); + --rs-radius-step: 12px; } /* Avatar Group Styles */ diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index 11a1d982b..a1b3a4b1c 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -10,6 +10,7 @@ import { useRef, useState } from 'react'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './avatar.module.css'; import { AVATAR_COLORS } from './utils'; @@ -18,10 +19,9 @@ type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; const avatar = cva(styles.avatar, { variants: { - radius: { - small: styles['avatar-small'], - full: styles['avatar-full'] - }, + // Each size class names its own base step, so the corner still grows with + // the avatar. `medium` reproduces the previous `small` default exactly. + ...radiusVariants, size: { 1: styles['avatar-size-1'], 2: styles['avatar-size-2'], @@ -125,7 +125,7 @@ const avatar = cva(styles.avatar, { ], defaultVariants: { size: 3, - radius: 'small', + radius: 'medium', variant: 'soft', color: 'indigo' } diff --git a/packages/raystack/components/badge/badge.module.css b/packages/raystack/components/badge/badge.module.css index aff0be440..eae7c6415 100644 --- a/packages/raystack/components/badge/badge.module.css +++ b/packages/raystack/components/badge/badge.module.css @@ -6,7 +6,8 @@ justify-content: center; align-items: center; gap: var(--rs-space-2); - border-radius: var(--rs-radius-1); + --rs-radius-step: 2px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); white-space: nowrap; } diff --git a/packages/raystack/components/badge/badge.tsx b/packages/raystack/components/badge/badge.tsx index f0d0ffef7..31377b197 100644 --- a/packages/raystack/components/badge/badge.tsx +++ b/packages/raystack/components/badge/badge.tsx @@ -1,10 +1,12 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { ComponentProps, ReactNode } from 'react'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './badge.module.css'; const badge = cva(styles['badge'], { variants: { + ...radiusVariants, variant: { accent: styles['badge-accent'], warning: styles['badge-warning'], @@ -36,6 +38,7 @@ type BadgeProps = VariantProps & export const Badge = ({ variant = 'accent', size = 'small', + radius, icon, children, className, @@ -44,7 +47,7 @@ export const Badge = ({ }: BadgeProps) => { return ( diff --git a/packages/raystack/components/breadcrumb/breadcrumb.module.css b/packages/raystack/components/breadcrumb/breadcrumb.module.css index 0d9c2742f..e92d25c02 100644 --- a/packages/raystack/components/breadcrumb/breadcrumb.module.css +++ b/packages/raystack/components/breadcrumb/breadcrumb.module.css @@ -51,7 +51,7 @@ .breadcrumb-link:focus-visible { outline: var(--rs-focus-ring); - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); } .breadcrumb-link-active { @@ -94,7 +94,7 @@ .breadcrumb-dropdown-trigger:focus-visible { outline: var(--rs-focus-ring); - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); } .breadcrumb-dropdown-icon { @@ -120,5 +120,5 @@ .breadcrumb-dropdown-item:hover { background-color: var(--rs-color-background-base-primary-hover); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); } diff --git a/packages/raystack/components/button/button.module.css b/packages/raystack/components/button/button.module.css index ce2993711..e0a8e595e 100644 --- a/packages/raystack/components/button/button.module.css +++ b/packages/raystack/components/button/button.module.css @@ -11,7 +11,8 @@ cursor: pointer; width: fit-content; padding: var(--rs-space-3) var(--rs-space-4); - border-radius: var(--rs-radius-2); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); text-wrap: nowrap; } diff --git a/packages/raystack/components/button/button.tsx b/packages/raystack/components/button/button.tsx index f5ed4afcc..a4c3b1d7d 100644 --- a/packages/raystack/components/button/button.tsx +++ b/packages/raystack/components/button/button.tsx @@ -3,10 +3,12 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ReactNode } from 'react'; import { Spinner } from '../spinner'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './button.module.css'; const button = cva(styles['button'], { variants: { + ...radiusVariants, variant: { solid: styles['button-solid'], outline: styles['button-outline'], @@ -138,6 +140,7 @@ export const Button = ({ variant = 'solid', color = 'accent', size = 'normal', + radius, disabled, loading, loaderText, @@ -152,7 +155,7 @@ export const Button = ({ return (
& export const Chip = ({ variant, + radius, size, color, trailingIcon, @@ -131,6 +134,7 @@ export const Chip = ({ role={role} className={chip({ variant, + radius, size, color, className: cx(styles['chip-interactive'], className) @@ -149,7 +153,7 @@ export const Chip = ({ data-slot='chip' {...props} {...sharedProps} - className={chip({ variant, size, color, className })} + className={chip({ variant, size, color, radius, className })} role={role ?? 'status'} onClick={disabled ? undefined : onClick} > diff --git a/packages/raystack/components/code-block/code-block.module.css b/packages/raystack/components/code-block/code-block.module.css index 9f2a32aab..fb4d6de78 100644 --- a/packages/raystack/components/code-block/code-block.module.css +++ b/packages/raystack/components/code-block/code-block.module.css @@ -1,6 +1,6 @@ /* Code Block Container */ .container { - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); overflow: hidden; width: 100%; box-sizing: border-box; @@ -92,7 +92,7 @@ padding: var(--rs-space-2) var(--rs-space-3); width: fit-content; height: auto; - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); border: 0.5px solid var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); box-shadow: var(--rs-shadow-feather); diff --git a/packages/raystack/components/color-picker/color-picker.module.css b/packages/raystack/components/color-picker/color-picker.module.css index 8d94d8ad0..08573412c 100644 --- a/packages/raystack/components/color-picker/color-picker.module.css +++ b/packages/raystack/components/color-picker/color-picker.module.css @@ -25,7 +25,7 @@ height: var(--rs-space-4); width: 100%; flex-grow: 1; - border-radius: var(--rs-radius-3); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-3)); } .hueTrack { @@ -115,7 +115,7 @@ branch has no such element. aspect-ratio gives both branches a square footprint derived from width. */ aspect-ratio: 1 / 1; - border-radius: var(--rs-radius-1); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-1)); overflow: hidden; /* thumb transform below resolves 100cqw/100cqh against this box */ container-type: size; diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx index f6cfeb98e..6b1b06099 100644 --- a/packages/raystack/components/combobox/combobox-content.tsx +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -2,6 +2,12 @@ import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; @@ -10,7 +16,12 @@ export interface ComboboxContentProps ComboboxPrimitive.Positioner.Props, 'render' | 'className' | 'style' >, - ComboboxPrimitive.Popup.Props {} + ComboboxPrimitive.Popup.Props { + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; +} export const ComboboxContent = ({ ref, @@ -21,11 +32,14 @@ export const ComboboxContent = ({ initialFocus, finalFocus, sideOffset = 4, + container, + radius, ...positionerProps }: ComboboxContentProps) => { const { inputContainerRef } = useComboboxContext(); + const theme = useThemeInjection(); return ( - + ( @@ -24,6 +30,10 @@ CommandDialogTrigger.displayName = 'Command.DialogTrigger'; export interface CommandDialogContentProps extends DialogPrimitive.Popup.Props { width?: string | number; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this palette only. Overrides the theme's `radius`. */ + radius?: Radius; } export function CommandDialogContent({ @@ -31,6 +41,8 @@ export function CommandDialogContent({ children, width, style, + container, + radius, ...props }: CommandDialogContentProps) { const popupRef = useRef(null); @@ -41,17 +53,24 @@ export function CommandDialogContent({ usually behave: focus goes back to the trigger only when you opened it by clicking the trigger. */ const originRef = useRef(null); + const theme = useThemeInjection(); return ( - + { /* Runs before focus moves into the popup, so activeElement is diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css index 4cc5f9ca5..2b26cf28e 100644 --- a/packages/raystack/components/command/command.module.css +++ b/packages/raystack/components/command/command.module.css @@ -4,7 +4,8 @@ overflow: hidden; isolation: isolate; width: 100%; - background-color: var(--rs-color-background-base-primary); + background-color: var(--rs-color-panel); + backdrop-filter: var(--rs-panel-backdrop-filter); } .inputWrapper { @@ -51,7 +52,7 @@ letter-spacing: var(--rs-letter-spacing-small); color: var(--rs-color-foreground-base-primary); background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); cursor: pointer; outline: none; user-select: none; @@ -165,8 +166,10 @@ centered on short ones so the popup stays inside the viewport. The input stays put while the list below changes height. */ transform: translate(-50%, min(160px, calc(50vh - 50%))); - background-color: var(--rs-color-background-base-primary); - border-radius: var(--rs-radius-2); + background-color: var(--rs-color-panel); + backdrop-filter: var(--rs-panel-backdrop-filter); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); box-shadow: var(--rs-shadow-floating); overflow: hidden; outline: none; diff --git a/packages/raystack/components/context-menu/context-menu-content.tsx b/packages/raystack/components/context-menu/context-menu-content.tsx index fe497ab05..8750fc4f1 100644 --- a/packages/raystack/components/context-menu/context-menu-content.tsx +++ b/packages/raystack/components/context-menu/context-menu-content.tsx @@ -14,6 +14,12 @@ import { isElementSubMenuTrigger, KEYCODES } from '../menu/utils'; +import { + type PortalContainer, + useThemeInjection +} from '../theme-preview/portal'; +import { radiusClass } from '../theme-preview/radius'; +import type { Radius } from '../theme-preview/settings'; export interface ContextMenuContentProps extends Omit< @@ -22,6 +28,10 @@ export interface ContextMenuContentProps >, ContextMenuPrimitive.Popup.Props { searchPlaceholder?: string; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this menu only. Overrides the theme's `radius`. */ + radius?: Radius; } export const ContextMenuContent = ({ @@ -35,6 +45,8 @@ export const ContextMenuContent = ({ sideOffset = 4, align = 'start', onFocus, + container, + radius, ...positionerProps }: ContextMenuContentProps) => { const { @@ -97,8 +109,10 @@ export const ContextMenuContent = ({ item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); }, []); + const theme = useThemeInjection(); + return ( - + + + { }); describe('Radius Variants', () => { - const radiuses = ['none', 'small', 'medium', 'full'] as const; + const radiuses = ['none', 'small', 'medium', 'large', 'full'] as const; it.each(radiuses)('renders %s radius correctly', radius => { render(Test); const img = screen.getByRole('img'); - expect(img).toHaveClass(styles[`image-radius-${radius}`]); + expect(img).toHaveClass(radiusClasses[radius]); }); it('defaults to none radius', () => { render(Test); const img = screen.getByRole('img'); - expect(img).toHaveClass(styles['image-radius-none']); + expect(img).toHaveClass(radiusClasses.none); }); }); diff --git a/packages/raystack/components/image/image.module.css b/packages/raystack/components/image/image.module.css index cb9774b5c..2c58fd079 100644 --- a/packages/raystack/components/image/image.module.css +++ b/packages/raystack/components/image/image.module.css @@ -1,4 +1,7 @@ .image { + /* Base step for the shared `radius` override; `medium` reproduces the + previous `--rs-radius-3`. */ + --rs-radius-step: 6px; display: block; max-width: 100%; height: auto; @@ -20,22 +23,6 @@ object-fit: fill; } -.image-radius-none { - border-radius: 0; -} - -.image-radius-small { - border-radius: var(--rs-radius-2); -} - -.image-radius-medium { - border-radius: var(--rs-radius-3); -} - -.image-radius-full { - border-radius: var(--rs-radius-full); -} - /* Load fade: hidden only after JS confirms an in-flight load, so SSR/no-JS images are never invisible. Cached images skip this entirely. */ .image-loading { diff --git a/packages/raystack/components/image/image.tsx b/packages/raystack/components/image/image.tsx index deb7ffb00..9a5f02f87 100644 --- a/packages/raystack/components/image/image.tsx +++ b/packages/raystack/components/image/image.tsx @@ -4,6 +4,7 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ComponentProps, SyntheticEvent, useRef, useState } from 'react'; import { useIsomorphicLayoutEffect } from '~/hooks'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './image.module.css'; const image = cva(styles.image, { @@ -13,12 +14,8 @@ const image = cva(styles.image, { cover: styles['image-cover'], fill: styles['image-fill'] }, - radius: { - none: styles['image-radius-none'], - small: styles['image-radius-small'], - medium: styles['image-radius-medium'], - full: styles['image-radius-full'] - } + // The base step lives in `image.module.css` as `--rs-radius-step`. + ...radiusVariants }, defaultVariants: { fit: 'cover', diff --git a/packages/raystack/components/input/input.module.css b/packages/raystack/components/input/input.module.css index f20e89dcd..964079028 100644 --- a/packages/raystack/components/input/input.module.css +++ b/packages/raystack/components/input/input.module.css @@ -5,7 +5,8 @@ align-items: center; width: 100%; position: relative; - border-radius: var(--rs-radius-2); + --rs-radius-step: 4px; + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); border: 0.5px solid var(--rs-color-border-base-tertiary); background: var(--rs-color-background-base-primary); transition: var(--rs-transition-interactive); @@ -168,7 +169,7 @@ .chip-overflow { color: var(--rs-color-foreground-base-primary); font-size: var(--rs-font-size-small); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); } .prefix, @@ -184,7 +185,7 @@ padding: 0 var(--rs-space-2); pointer-events: none; background: var(--rs-color-background-neutral-secondary); - border-radius: var(--rs-radius-2); + border-radius: max(var(--rs-radius-pill, 0px), var(--rs-radius-2)); white-space: nowrap; height: auto; } diff --git a/packages/raystack/components/input/input.tsx b/packages/raystack/components/input/input.tsx index 506d2d658..d9d6ead93 100644 --- a/packages/raystack/components/input/input.tsx +++ b/packages/raystack/components/input/input.tsx @@ -3,10 +3,12 @@ import { cva, cx, type VariantProps } from 'class-variance-authority'; import { ReactNode, RefObject } from 'react'; import { Chip } from '../chip'; import { useFieldContext } from '../field'; +import { radiusVariants } from '../theme-preview/radius'; import styles from './input.module.css'; const inputWrapper = cva(styles['input-wrapper'], { variants: { + ...radiusVariants, size: { small: styles['size-small'], large: styles['size-large'] @@ -49,6 +51,7 @@ export function Input({ chips, maxChipsVisible = 2, size, + radius, variant = 'default', containerRef, classNames, @@ -61,7 +64,7 @@ export function Input({ return (
, MenuPrimitive.Popup.Props { searchPlaceholder?: string; + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this menu only. Overrides the theme's `radius`. */ + radius?: Radius; } export function MenuContent({ @@ -35,6 +45,8 @@ export function MenuContent({ sideOffset = 4, align = 'start', onFocus, + container, + radius, ...positionerProps }: MenuContentProps) { const { @@ -97,8 +109,10 @@ export function MenuContent({ item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); }, []); + const theme = useThemeInjection(); + return ( - + , - PopoverPrimitive.Popup.Props {} + PopoverPrimitive.Popup.Props { + /** Portals into this element instead of `document.body`. */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; +} function PopoverContent({ ref, @@ -19,10 +30,13 @@ function PopoverContent({ style, render, children, + container, + radius, ...positionerProps }: PopoverContentProps) { + const theme = useThemeInjection(); return ( - + + , SelectPrimitive.Popup.Props { searchPlaceholder?: string; + /** + * Portals into this element instead of `document.body`. Only the + * autocomplete variant portals; the plain variant keeps its items in the + * DOM so the trigger can display the selected value. + */ + container?: PortalContainer; + /** Corner radius for this popup only. Overrides the theme's `radius`. */ + radius?: Radius; } export function SelectContent({ @@ -24,13 +38,16 @@ export function SelectContent({ sideOffset = 4, side = 'bottom', align = 'start', + container, + radius, ...props }: SelectContentProps) { const { autocomplete, multiple } = useSelectContext(); + const theme = useThemeInjection(); if (autocomplete) { return ( - + { + installLocalStorage(); + installMatchMedia(false); + clearThemeStorageCache(); +}); + +describe('ThemePreview data-slot contract', () => { + it('exposes the theme element slot', () => { + const { container } = render( + +
child
+
+ ); + expectSlots(container, ['theme-preview']); + }); + + it('exposes the script slot only for a persisted namespace', () => { + const { container: plain } = render(child); + expect(getSlot(plain, 'theme-preview-script')).toBeNull(); + + const { container: persisted } = render( + child + ); + expect(getSlot(persisted, 'theme-preview-script')?.tagName).toBe('SCRIPT'); + }); + + it('exposes the slot on a nested scope too', () => { + const { container } = render( + + +
nested
+
+
+ ); + expect( + container.querySelectorAll('[data-slot="theme-preview"]') + ).toHaveLength(2); + }); + + it('exposes the switcher slot', () => { + const { container } = render( + + + + ); + expectSlots(container, ['theme-preview-switcher']); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/mocks.ts b/packages/raystack/components/theme-preview/__tests__/mocks.ts new file mode 100644 index 000000000..e6edf9df5 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/mocks.ts @@ -0,0 +1,87 @@ +import { act } from '@testing-library/react'; +import { vi } from 'vitest'; + +/** + * A real in-memory `localStorage`. The theme round-trips JSON through it, so a + * mock that only records calls cannot exercise the merge or the cache. + */ +export function installLocalStorage(): Map { + const entries = new Map(); + const storage: Storage = { + getItem: key => (entries.has(key) ? (entries.get(key) as string) : null), + setItem: (key, value) => { + entries.set(key, String(value)); + }, + removeItem: key => { + entries.delete(key); + }, + clear: () => entries.clear(), + key: index => Array.from(entries.keys())[index] ?? null, + get length() { + return entries.size; + } + }; + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: storage + }); + return entries; +} + +type MediaListener = (event: MediaQueryListEvent) => void; + +export interface MediaController { + /** Flips what the OS reports and notifies every listener. */ + setPrefersDark: (next: boolean) => void; + matchMedia: ReturnType; +} + +/** jsdom ships no `matchMedia`; the theme needs one that can change. */ +export function installMatchMedia(initialDark = false): MediaController { + let prefersDark = initialDark; + const listeners = new Set(); + + const matchMedia = vi.fn((query: string) => ({ + get matches() { + return query.includes('dark') ? prefersDark : false; + }, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: (_type: string, listener: MediaListener) => { + listeners.add(listener); + }, + removeEventListener: (_type: string, listener: MediaListener) => { + listeners.delete(listener); + }, + dispatchEvent: vi.fn() + })); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: matchMedia + }); + + return { + matchMedia, + setPrefersDark: next => { + prefersDark = next; + act(() => { + for (const listener of listeners) { + listener({ matches: next } as MediaQueryListEvent); + } + }); + } + }; +} + +/** Serialises a settings object the way the theme stores it. */ +export function storedEntry( + settings: Record, + version = 1 +): string { + return JSON.stringify({ v: version, settings }); +} diff --git a/packages/raystack/components/theme-preview/__tests__/script.test.ts b/packages/raystack/components/theme-preview/__tests__/script.test.ts new file mode 100644 index 000000000..d08e9dfa2 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/script.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { createThemeScript } from '../script'; +import { installLocalStorage, installMatchMedia, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + installMatchMedia(false); + document.body.innerHTML = ''; +}); + +/** Runs a generated script the way the browser would: as its own child. */ +function run(source: string, parent: HTMLElement): void { + const script = document.createElement('script'); + parent.appendChild(script); + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => script + }); + try { + new Function(source)(); + } finally { + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + } +} + +function themeElement(attributes: Record = {}): HTMLDivElement { + const element = document.createElement('div'); + element.className = 'rs-theme'; + element.setAttribute('data-theme', 'light'); + element.setAttribute('data-accent-color', 'indigo'); + element.setAttribute('data-gray-color', 'slate'); + element.setAttribute('data-rs-theme-id', 'rs-theme-abc'); + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + document.body.appendChild(element); + return element; +} + +describe('createThemeScript', () => { + it('emits nothing when there are no uncontrolled persistable keys', () => { + expect( + createThemeScript({ persistKey: 'app', keys: [], elementId: 'x' }) + ).toBeNull(); + }); + + it('escapes characters that could close the script tag', () => { + const source = createThemeScript({ + persistKey: '', + keys: ['appearance'], + elementId: 'x' + }); + expect(source).not.toContain(''); + expect(source).toContain('\\u003c'); + }); +}); + +describe('the generated script', () => { + it('patches its own parent from the stored value', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a stored `system` appearance against the OS', () => { + installMatchMedia(true); + entries.set('app', storedEntry({ appearance: 'system' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('resolves a stored `auto` gray against the accent it just wrote', () => { + entries.set( + 'app', + storedEntry({ accentColor: 'orange', grayColor: 'auto' }) + ); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['accentColor', 'grayColor'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-accent-color')).toBe('orange'); + expect(element.getAttribute('data-gray-color')).toBe('mauve'); + }); + + it('leaves the server-rendered attribute when the entry is absent', () => { + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('leaves the server-rendered attribute when the entry is malformed', () => { + entries.set('app', '{ broken'); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('leaves the server-rendered attribute for an out-of-union value', () => { + entries.set('app', storedEntry({ appearance: 'ultraviolet' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('light'); + }); + + it('falls back to a selector when currentScript is unavailable', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const element = themeElement(); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + new Function(source)(); + + expect(element.getAttribute('data-theme')).toBe('dark'); + }); + + it('never writes a key it was not given, even when one is stored', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const element = themeElement({ 'data-radius': 'medium' }); + const source = createThemeScript({ + persistKey: 'app', + keys: ['appearance'], + elementId: 'rs-theme-abc' + }) as string; + + run(source, element); + + expect(element.getAttribute('data-theme')).toBe('dark'); + expect(element.getAttribute('data-radius')).toBe('medium'); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx new file mode 100644 index 000000000..11bb61069 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/ssr.test.tsx @@ -0,0 +1,171 @@ +import { act } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useThemePreview } from '../context'; +import { clearThemeStorageCache } from '../store'; +import { ThemePreview } from '../theme-preview'; +import { installLocalStorage, installMatchMedia, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + installMatchMedia(false); + clearThemeStorageCache(); + document.body.innerHTML = ''; +}); + +/** Runs the inline script the way the browser would, before hydration. */ +function runInlineScript(container: HTMLElement): void { + const script = container.querySelector('script'); + if (!script) return; + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => script + }); + try { + new Function(script.textContent as string)(); + } finally { + Object.defineProperty(document, 'currentScript', { + configurable: true, + get: () => null + }); + } +} + +describe('server rendering', () => { + it('renders every setting as an attribute on the first byte', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const html = renderToString( + + content + + ); + + // The server snapshot returns the seed, so the hydration render matches. + expect(html).toContain('data-theme="light"'); + expect(html).toContain('data-accent-color="mint"'); + expect(html).toContain('data-radius="medium"'); + expect(html).toContain('data-scaling="1"'); + }); + + it('renders the script inside the theme element, as its first child', () => { + const html = renderToString( + content + ); + const container = document.createElement('div'); + container.innerHTML = html; + const theme = container.querySelector('.rs-theme') as HTMLElement; + + expect(theme.firstElementChild?.tagName).toBe('SCRIPT'); + }); + + it('emits no script and reads no storage when persistence is off', () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + const html = renderToString(content); + expect(html).not.toContain(' { + const html = renderToString( + + content + + ); + expect(html).toContain('nonce="abc123"'); + }); +}); + +describe('hydration', () => { + it('keeps the value the script patched in, with no mismatch', async () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + + const tree = ( + + content + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + + // The server wrote `light`; the script corrects the DOM before paint. + const theme = container.querySelector('.rs-theme') as HTMLElement; + expect(theme.getAttribute('data-theme')).toBe('light'); + runInlineScript(theme); + expect(theme.getAttribute('data-theme')).toBe('dark'); + + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + await act(async () => { + hydrateRoot(container, tree); + }); + + // The post-hydration snapshot returns the same value, so nothing moves. + expect(theme.getAttribute('data-theme')).toBe('dark'); + const hydrationWarnings = error.mock.calls.filter(call => + String(call[0]).includes('did not match') + ); + expect(hydrationWarnings).toHaveLength(0); + error.mockRestore(); + }); + + it('reconciles the element when no script ran to correct it', async () => { + // `system` with no persistence: the server guesses light, nothing patches + // the DOM, and React does not fix attribute mismatches during hydration. + installMatchMedia(true); + + const tree = ( + + content + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + const theme = container.querySelector('.rs-theme') as HTMLElement; + expect(theme.getAttribute('data-theme')).toBe('light'); + + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + await act(async () => { + hydrateRoot(container, tree); + }); + error.mockRestore(); + + expect(theme.getAttribute('data-theme')).toBe('dark'); + }); + + it('gives the hook the stored value after hydration', async () => { + entries.set('app', storedEntry({ radius: 'full' })); + let seen: string | undefined; + function Probe() { + seen = useThemePreview().resolved.radius; + return null; + } + + const tree = ( + + + + ); + + const container = document.createElement('div'); + container.innerHTML = renderToString(tree); + document.body.appendChild(container); + expect(seen).toBe('medium'); + + await act(async () => { + hydrateRoot(container, tree); + }); + + expect(seen).toBe('full'); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/store.test.ts b/packages/raystack/components/theme-preview/__tests__/store.test.ts new file mode 100644 index 000000000..dc01a4103 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/store.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearThemeStorageCache, + readServerSettings, + readStoredSettings, + subscribeToThemeStorage, + THEME_STORAGE_EVENT, + writeStoredSettings +} from '../store'; +import { installLocalStorage, storedEntry } from './mocks'; + +let entries: Map; + +beforeEach(() => { + entries = installLocalStorage(); + clearThemeStorageCache(); +}); + +describe('readStoredSettings', () => { + it('reads the settings a namespace holds', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'large' })); + expect(readStoredSettings('app')).toEqual({ + appearance: 'dark', + radius: 'large' + }); + }); + + it('returns nothing without a persistKey, and never touches storage', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + expect(readStoredSettings(undefined)).toEqual({}); + }); + + it('falls back to the seed when the entry is missing', () => { + expect(readStoredSettings('app')).toEqual({}); + }); + + it('falls back to the seed when the entry is unparseable', () => { + entries.set('app', '{not json'); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('falls back to the seed for a bare legacy theme name', () => { + // The previous provider stored `"dark"`, which is valid JSON but not an + // object, and is therefore detectable rather than silently accepted. + entries.set('app', JSON.stringify('dark')); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('ignores an entry written by a newer schema version', () => { + entries.set('app', storedEntry({ appearance: 'dark' }, 99)); + expect(readStoredSettings('app')).toEqual({}); + }); + + it('discards an out-of-union field individually', () => { + entries.set( + 'app', + storedEntry({ appearance: 'ultraviolet', radius: 'large' }) + ); + expect(readStoredSettings('app')).toEqual({ radius: 'large' }); + }); + + it('holds snapshot identity while the stored string is unchanged', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const first = readStoredSettings('app'); + const second = readStoredSettings('app'); + // `useSyncExternalStore` compares with `Object.is` and accepts no equality + // function, so a freshly parsed object each call would re-render forever. + expect(second).toBe(first); + }); + + it('returns a new snapshot once the stored string changes', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const first = readStoredSettings('app'); + entries.set('app', storedEntry({ appearance: 'light' })); + const second = readStoredSettings('app'); + expect(second).not.toBe(first); + expect(second).toEqual({ appearance: 'light' }); + }); + + it('holds identity across empty results too', () => { + expect(readStoredSettings('app')).toBe(readStoredSettings('other')); + }); + + it('returns the seed as the server snapshot', () => { + expect(readServerSettings()).toEqual({}); + }); +}); + +describe('writeStoredSettings', () => { + it('writes a versioned object', () => { + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(JSON.parse(entries.get('app') as string)).toEqual({ + v: 1, + settings: { appearance: 'dark' } + }); + }); + + it('merges rather than replaces', () => { + entries.set('app', storedEntry({ radius: 'large', accentColor: 'mint' })); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(readStoredSettings('app')).toEqual({ + radius: 'large', + accentColor: 'mint', + appearance: 'dark' + }); + }); + + it('applies only the settings its persist list covers', () => { + writeStoredSettings('app', ['appearance'], { + appearance: 'dark', + radius: 'full' + }); + expect(readStoredSettings('app')).toEqual({ appearance: 'dark' }); + }); + + it('leaves fields owned by a theme with a different persist intact', () => { + writeStoredSettings('app', ['radius'], { radius: 'full' }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + expect(readStoredSettings('app')).toEqual({ + radius: 'full', + appearance: 'dark' + }); + }); + + it('notifies in-document readers, which the storage event does not', () => { + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + unsubscribe(); + expect(notified).toBe(1); + }); + + it('does not notify when nothing actually changed', () => { + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + writeStoredSettings('app', ['appearance'], { appearance: 'dark' }); + unsubscribe(); + expect(notified).toBe(0); + }); +}); + +describe('subscribeToThemeStorage', () => { + it('listens to the storage event for other tabs', () => { + let notified = 0; + const unsubscribe = subscribeToThemeStorage(() => { + notified += 1; + }); + window.dispatchEvent(new Event('storage')); + window.dispatchEvent(new Event(THEME_STORAGE_EVENT)); + unsubscribe(); + window.dispatchEvent(new Event('storage')); + expect(notified).toBe(2); + }); +}); diff --git a/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx new file mode 100644 index 000000000..df6d967e8 --- /dev/null +++ b/packages/raystack/components/theme-preview/__tests__/theme-preview.test.tsx @@ -0,0 +1,721 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { type ReactNode, useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useThemePreview } from '../context'; +import { useThemeInjection } from '../portal'; +import { radiusClass } from '../radius'; +import type { ThemeSettings } from '../settings'; +import { clearThemeStorageCache } from '../store'; +import { ThemePreview } from '../theme-preview'; +import { + installLocalStorage, + installMatchMedia, + type MediaController, + storedEntry +} from './mocks'; + +let entries: Map; +let media: MediaController; + +beforeEach(() => { + entries = installLocalStorage(); + media = installMatchMedia(false); + clearThemeStorageCache(); +}); + +/** The element every theme carries the documented override class on. */ +function themeElement(container: HTMLElement, index = 0): HTMLElement { + const elements = container.querySelectorAll('.rs-theme'); + const element = elements[index]; + if (!element) throw new Error(`No theme element at index ${index}`); + return element; +} + +function Probe({ label = 'probe' }: { label?: string }) { + const theme = useThemePreview(); + return ( + + {JSON.stringify({ value: theme.value, resolved: theme.resolved })} + + ); +} + +function readProbe(label = 'probe'): { + value: ThemeSettings; + resolved: ThemeSettings; +} { + return JSON.parse(screen.getByTestId(label).textContent as string); +} + +// ─── Attributes ───────────────────────────────────────────────────────────── + +describe('ThemePreview attributes', () => { + it('writes every setting as a data attribute on its own element', () => { + const { container } = render(content); + const element = themeElement(container); + + expect(element).toHaveAttribute('data-theme', 'light'); + expect(element).toHaveAttribute('data-accent-color', 'indigo'); + expect(element).toHaveAttribute('data-gray-color', 'slate'); + expect(element).toHaveAttribute('data-radius', 'medium'); + expect(element).toHaveAttribute('data-scaling', '1'); + expect(element).toHaveAttribute('data-panel-background', 'solid'); + expect(element).toHaveAttribute('data-reduced-motion', 'system'); + }); + + it('writes nothing to the document element', () => { + render(content); + expect(document.documentElement.hasAttribute('data-theme')).toBe(false); + expect(document.documentElement.hasAttribute('data-accent-color')).toBe( + false + ); + }); + + it('carries the stable rs-theme override class', () => { + const { container } = render( + content + ); + const element = themeElement(container); + expect(element).toHaveClass('rs-theme'); + expect(element).toHaveClass('mine'); + }); + + it('lets a nested scope layer settings over its parent', () => { + const { container } = render( + + + scoped + + + ); + + const scope = themeElement(container, 1); + expect(scope).toHaveAttribute('data-accent-color', 'mint'); + // Inherited by omission: a nested theme keeps every key it does not set. + expect(scope).toHaveAttribute('data-radius', 'large'); + }); +}); + +// ─── Root marker and background ───────────────────────────────────────────── + +describe('the root marker', () => { + it('marks a theme with no ancestor', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-rs-root'); + }); + + it('does not mark a nested theme', () => { + const { container } = render( + + scoped + + ); + expect(themeElement(container, 1)).not.toHaveAttribute('data-rs-root'); + }); + + it('isRoot={false} suppresses the marker but leaves the theme intact', () => { + const { container } = render( + + widget + + ); + const element = themeElement(container); + expect(element).not.toHaveAttribute('data-rs-root'); + expect(element).toHaveAttribute('data-theme', 'dark'); + expect(element).toHaveAttribute('data-accent-color', 'indigo'); + }); +}); + +describe('hasBackground', () => { + it('paints at the root by default', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-rs-background'); + }); + + it('paints a nested theme that sets an explicit appearance', () => { + const { container } = render( + + panel + + ); + expect(themeElement(container, 1)).toHaveAttribute('data-rs-background'); + }); + + it('does not paint a nested theme that only re-tints', () => { + const { container } = render( + + tint + + ); + expect(themeElement(container, 1)).not.toHaveAttribute( + 'data-rs-background' + ); + }); + + it('honours an explicit override', () => { + const { container } = render( + content + ); + expect(themeElement(container)).not.toHaveAttribute('data-rs-background'); + }); +}); + +// ─── Controlled and uncontrolled ──────────────────────────────────────────── + +describe('controlled versus uncontrolled precedence', () => { + it('a controlled key ignores a stored value', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + }); + + it('a stored value overrides the seed for an uncontrolled key', () => { + entries.set('app', storedEntry({ appearance: 'dark' })); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('control is per key', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const { container } = render( + + content + + ); + const element = themeElement(container); + expect(element).toHaveAttribute('data-theme', 'light'); + expect(element).toHaveAttribute('data-radius', 'full'); + }); + + it('setValue never writes a controlled key', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + expect(themeElement(container)).toHaveAttribute('data-radius', 'large'); + expect(JSON.parse(entries.get('app') as string).settings).toEqual({ + radius: 'large' + }); + }); +}); + +// ─── Persistence ──────────────────────────────────────────────────────────── + +describe('persistence', () => { + it('does not touch storage without a persistKey', async () => { + const getItem = vi.spyOn(window.localStorage, 'getItem'); + const setItem = vi.spyOn(window.localStorage, 'setItem'); + const user = userEvent.setup(); + + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + // The setting still applies, in memory. + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + }); + + it('emits no inline script without a persistKey', () => { + const { container } = render(content); + expect(container.querySelector('script')).toBeNull(); + }); + + it('emits an inline script for a persisted namespace', () => { + const { container } = render( + content + ); + const script = container.querySelector('script'); + expect(script).not.toBeNull(); + // First child, so it patches the opening tag already parsed above it. + expect(themeElement(container).firstChild).toBe(script); + }); + + it('omits the script when every persistable setting is controlled', () => { + const { container } = render( + + content + + ); + expect(container.querySelector('script')).toBeNull(); + }); + + it('omits the script when persist excludes everything', () => { + const { container } = render( + + content + + ); + expect(container.querySelector('script')).toBeNull(); + }); + + it('narrows a namespace with persist, keeping other settings in memory', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + ); + await user.click(screen.getByRole('button')); + + const element = themeElement(container); + expect(element).toHaveAttribute('data-theme', 'dark'); + expect(element).toHaveAttribute('data-radius', 'large'); + expect(JSON.parse(entries.get('app') as string).settings).toEqual({ + appearance: 'dark' + }); + }); + + it('keeps two themes sharing a namespace in step within one document', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + <> + + + + + second + + + ); + + await user.click(screen.getByRole('button')); + + // The `storage` event does not fire here, so the in-document notification + // is what keeps the second theme in step. + expect(themeElement(container, 0)).toHaveAttribute('data-theme', 'dark'); + expect(themeElement(container, 1)).toHaveAttribute('data-theme', 'dark'); + }); + + it('synchronises across tabs through the storage event', () => { + const { container } = render( + content + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + + act(() => { + entries.set('app', storedEntry({ appearance: 'dark' })); + window.dispatchEvent(new Event('storage')); + }); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('reads storage on the first render under CSR', () => { + entries.set('app', storedEntry({ appearance: 'dark', radius: 'full' })); + const renders: string[] = []; + function Recorder() { + const { resolved } = useThemePreview(); + renders.push(`${resolved.appearance}/${resolved.radius}`); + return null; + } + + render( + + + + ); + + // No hydration under CSR, so the first render is already correct. + expect(renders[0]).toBe('dark/full'); + }); + + it('falls back to the seed for an unparseable entry', () => { + entries.set('app', 'not json at all'); + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); +}); + +// ─── Resolution ───────────────────────────────────────────────────────────── + +describe('resolution', () => { + it('resolves `system` against the OS', () => { + installMatchMedia(true); + const { container } = render( + + + + ); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + const probe = readProbe(); + expect(probe.value.appearance).toBe('system'); + expect(probe.resolved.appearance).toBe('dark'); + }); + + it('follows the OS when it changes', () => { + const { container } = render(content); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + + media.setPrefersDark(true); + + expect(themeElement(container)).toHaveAttribute('data-theme', 'dark'); + }); + + it('pairs `auto` gray to the accent', () => { + const { container } = render( + + + + ); + + expect(themeElement(container)).toHaveAttribute('data-gray-color', 'mauve'); + expect(readProbe().value.grayColor).toBe('auto'); + expect(readProbe().resolved.grayColor).toBe('mauve'); + }); + + it('honours an explicit gray over the pairing', () => { + const { container } = render( + + content + + ); + expect(themeElement(container)).toHaveAttribute('data-gray-color', 'sage'); + }); + + it('reports the OS appearance whatever the setting is', () => { + installMatchMedia(true); + function SystemProbe() { + const { systemAppearance, resolved } = useThemePreview(); + return ( + {`${systemAppearance}/${resolved.appearance}`} + ); + } + render( + + + + ); + expect(screen.getByTestId('sys')).toHaveTextContent('dark/light'); + }); +}); + +// ─── The hook ─────────────────────────────────────────────────────────────── + +describe('useThemePreview', () => { + it('throws outside a provider', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => { + /* swallow React's expected error logging */ + }); + expect(() => render()).toThrow(/must be called inside/); + error.mockRestore(); + }); + + it('reaches the root provider from inside a scope', async () => { + const user = userEvent.setup(); + function RootSwitcher() { + const { root } = useThemePreview(); + return ( + + ); + } + + const { container } = render( + + + + + + ); + + await user.click(screen.getByRole('button')); + + expect(themeElement(container, 0)).toHaveAttribute('data-theme', 'dark'); + // The scope inherits the flipped appearance because it never set its own. + expect(themeElement(container, 1)).toHaveAttribute('data-theme', 'dark'); + }); + + it('reports the nearest theme as the root when there is only one', () => { + function RootProbe() { + const theme = useThemePreview(); + return ( + {theme.root.resolved.accentColor} + ); + } + render( + + + + ); + expect(screen.getByTestId('root')).toHaveTextContent('mint'); + }); +}); + +// ─── onValueChange ────────────────────────────────────────────────────────── + +describe('onValueChange', () => { + it('fires with the full next settings and the changed subset', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + render( + + + + ); + + expect(onValueChange).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const [next, changed] = onValueChange.mock.calls[0]; + expect(next.appearance).toBe('dark'); + expect(next.accentColor).toBe('indigo'); + expect(changed).toEqual({ appearance: 'dark' }); + }); +}); + +// ─── The render prop ──────────────────────────────────────────────────────── + +describe('render', () => { + it('merges the theme onto a caller-supplied element', () => { + const { container } = render( + }>content + ); + const element = themeElement(container); + expect(element.tagName).toBe('SECTION'); + expect(element).toHaveClass('page'); + expect(element).toHaveAttribute('data-theme', 'light'); + expect(container.querySelectorAll('.rs-theme')).toHaveLength(1); + }); + + it('accepts a function form', () => { + const { container } = render( +
}>content + ); + expect(themeElement(container).tagName).toBe('MAIN'); + }); + + it('keeps the theme children, including the inline script', () => { + const { container } = render( + supplied children are replaced} + > + mine + + ); + const element = themeElement(container); + expect(element.firstElementChild?.tagName).toBe('SCRIPT'); + expect(screen.getByTestId('mine')).toBeInTheDocument(); + expect(element).not.toHaveTextContent('supplied children are replaced'); + }); + + it('fires both refs', () => { + let ours: HTMLElement | null = null; + let theirs: unknown = null; + render( + { + ours = node; + }} + render={ +
{ + theirs = node; + }} + /> + } + > + content + + ); + expect(ours).not.toBeNull(); + expect(theirs).toBe(ours); + }); +}); + +// ─── Portals ──────────────────────────────────────────────────────────────── + +describe('the portal re-injector', () => { + function Portalled({ children }: { children?: ReactNode }) { + const theme = useThemeInjection(); + return ( +
+ {children} +
+ ); + } + + it('re-emits the inherited settings onto the portalled element', () => { + render( + + + + + + ); + + const portalled = screen.getByTestId('portalled'); + expect(portalled).toHaveClass('rs-theme'); + expect(portalled).toHaveAttribute('data-theme', 'dark'); + // The nearest scope wins: a portal used to render in the root's theme. + expect(portalled).toHaveAttribute('data-accent-color', 'orange'); + }); + + it('emits nothing outside a provider', () => { + render(); + const portalled = screen.getByTestId('portalled'); + expect(portalled).not.toHaveClass('rs-theme'); + expect(portalled).not.toHaveAttribute('data-theme'); + }); +}); + +// ─── Per-component radius ─────────────────────────────────────────────────── + +describe('the shared radius override', () => { + it('maps each level to its own class', () => { + expect(radiusClass('none')).toBeTruthy(); + expect(radiusClass('full')).toBeTruthy(); + expect(radiusClass('small')).not.toBe(radiusClass('large')); + }); + + it('returns nothing when the prop is unset', () => { + expect(radiusClass(undefined)).toBeUndefined(); + expect(radiusClass(null)).toBeUndefined(); + }); +}); + +// ─── Transitions ──────────────────────────────────────────────────────────── + +describe('disableTransitionOnChange', () => { + it('suppresses transitions across an appearance switch', async () => { + const user = userEvent.setup(); + function Switcher() { + const { setValue } = useThemePreview(); + return ( + + ); + } + + render( + + + + ); + + const before = document.head.querySelectorAll('style').length; + await act(async () => { + await user.click(screen.getByRole('button')); + }); + // The guard style is torn down on the next tick, so assert it ran and + // cleaned up rather than trying to observe it mid-flight. + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 5)); + }); + expect(document.head.querySelectorAll('style').length).toBe(before); + }); + + it('does not suppress anything on the first render', () => { + const before = document.head.querySelectorAll('style').length; + render(content); + expect(document.head.querySelectorAll('style').length).toBe(before); + }); +}); + +// ─── Mount reconciliation ─────────────────────────────────────────────────── + +describe('mount reconciliation', () => { + it('leaves the element alone when nothing drifted', () => { + const observed: string[] = []; + function Watcher() { + useEffect(() => { + observed.push('mounted'); + }, []); + return null; + } + const { container } = render( + + + + ); + expect(observed).toEqual(['mounted']); + expect(themeElement(container)).toHaveAttribute('data-theme', 'light'); + }); +}); diff --git a/packages/raystack/components/theme-preview/context.ts b/packages/raystack/components/theme-preview/context.ts new file mode 100644 index 000000000..7e64e1c76 --- /dev/null +++ b/packages/raystack/components/theme-preview/context.ts @@ -0,0 +1,61 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { + Appearance, + ResolvedThemeSettings, + ThemeSettings +} from './settings'; + +/** The theme, as read and driven from anywhere inside a provider. */ +export interface ThemeHandle { + /** Settings as set, `system` and `auto` included. */ + value: ThemeSettings; + /** Settings as applied, with `system` and `auto` resolved. */ + resolved: ResolvedThemeSettings; + /** Takes a partial settings object. Controlled keys are ignored. */ + setValue: (next: Partial) => void; + /** What the OS reports, whatever the current setting is. */ + systemAppearance: Appearance; +} + +export interface ThemeContextValue extends ThemeHandle { + /** Whether this theme owns the document's colour scheme. */ + isRoot: boolean; +} + +export const ThemeContext = createContext(null); +ThemeContext.displayName = 'ThemePreviewContext'; + +/** The root provider's handle, carried past every nested scope. */ +export const RootThemeContext = createContext(null); +RootThemeContext.displayName = 'RootThemePreviewContext'; + +export interface UseThemePreviewReturn extends ThemeHandle { + /** The same shape bound to the root provider. */ + root: ThemeHandle; +} + +/** + * Reads the nearest theme. Throws outside a provider rather than returning a + * no-op: every colour token is declared under `[data-theme]`, so a tree with + * no provider has no colours at all. + */ +export function useThemePreview(): UseThemePreviewReturn { + const context = useContext(ThemeContext); + const root = useContext(RootThemeContext); + if (!context) { + throw new Error( + '`useThemePreview` must be called inside a ``. Wrap your ' + + 'application in one — component colours are declared under the theme ' + + "element's attributes and do not exist without it." + ); + } + return { ...context, root: root ?? context }; +} + +/** The raw context, for internals that must tolerate its absence. */ +export function useThemeContextOrNull(): ThemeContextValue | null { + return useContext(ThemeContext); +} diff --git a/packages/raystack/components/theme-preview/index.tsx b/packages/raystack/components/theme-preview/index.tsx new file mode 100644 index 000000000..62a5d0c29 --- /dev/null +++ b/packages/raystack/components/theme-preview/index.tsx @@ -0,0 +1,46 @@ +export { + type ThemeContextValue, + type ThemeHandle, + type UseThemePreviewReturn, + useThemePreview +} from './context'; +export { type ThemeInjectionProps, useThemeInjection } from './portal'; +export { radiusClass, radiusClasses, radiusVariants } from './radius'; +export { createThemeScript, type ThemeScriptParams } from './script'; +export { + ACCENT_COLORS, + type AccentColor, + APPEARANCE_VALUES, + APPEARANCES, + type Appearance, + type AppearanceSetting, + DEFAULT_SETTINGS, + GRAY_COLOR_VALUES, + GRAY_COLORS, + GRAY_PAIRING, + type GrayColor, + type GrayColorSetting, + PANEL_BACKGROUNDS, + type PanelBackground, + RADII, + type Radius, + REDUCED_MOTION_VALUES, + type ReducedMotion, + type ResolvedThemeSettings, + resolveSettings, + SCALINGS, + type Scaling, + THEME_SETTING_KEYS, + type ThemeSettingKey, + type ThemeSettings +} from './settings'; +export { + ThemePreviewSwitcher, + type ThemePreviewSwitcherProps +} from './switcher'; +export { + ThemePreview, + type ThemePreviewProps, + type ThemeRenderProp +} from './theme-preview'; +export { useSystemAppearance } from './use-system-appearance'; diff --git a/packages/raystack/components/theme-preview/portal.ts b/packages/raystack/components/theme-preview/portal.ts new file mode 100644 index 000000000..fc3ab20ad --- /dev/null +++ b/packages/raystack/components/theme-preview/portal.ts @@ -0,0 +1,44 @@ +'use client'; + +import { type RefObject, useMemo } from 'react'; + +import { useThemeContextOrNull } from './context'; +import { settingsToAttributes, THEME_CLASS } from './settings'; + +/** Where a portalling component puts its content. */ +export type PortalContainer = + | HTMLElement + | ShadowRoot + | null + | RefObject; + +export interface ThemeInjectionProps { + className: string; + [attribute: string]: string; +} + +/** + * Theme values cross a portal through React context rather than the DOM, so a + * portalled element has to re-emit them. The returned props merge onto that + * element rather than adding a node — spread them first and pass `className` + * explicitly afterwards: + * + * ```tsx + * const theme = useThemeInjection(); + * + * ``` + * + * Returns `undefined` outside a provider, leaving such a portal unchanged. + */ +export function useThemeInjection(): ThemeInjectionProps | undefined { + const theme = useThemeContextOrNull(); + const resolved = theme?.resolved; + + return useMemo(() => { + if (!resolved) return undefined; + return { + className: THEME_CLASS, + ...settingsToAttributes(resolved) + }; + }, [resolved]); +} diff --git a/packages/raystack/components/theme-preview/radius.module.css b/packages/raystack/components/theme-preview/radius.module.css new file mode 100644 index 000000000..df74dae0d --- /dev/null +++ b/packages/raystack/components/theme-preview/radius.module.css @@ -0,0 +1,33 @@ +/* + * The shared per-component radius override. + * + * It affects only the element it is set on — never re-declaring the radius + * token scale, since custom properties inherit — and it does not compound with + * the theme radius: the value is re-derived from the component's own base step, + * `--rs-radius-step`, declared next to its `border-radius` rule. + * + * Selectors are doubled because the override class and the component's own + * class have equal specificity and CSS Modules ordering is not guaranteed. + */ + +.radius-none.radius-none { + border-radius: 0; +} + +.radius-small.radius-small { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1) * 0.75); +} + +.radius-medium.radius-medium { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1)); +} + +.radius-large.radius-large { + border-radius: calc(var(--rs-radius-step, 4px) * var(--rs-scaling, 1) * 1.5); +} + +/* A literal length, not `--rs-radius-pill`: that token is 0 unless the theme + itself is `full`, and a component override must not depend on the theme. */ +.radius-full.radius-full { + border-radius: 9999px; +} diff --git a/packages/raystack/components/theme-preview/radius.ts b/packages/raystack/components/theme-preview/radius.ts new file mode 100644 index 000000000..3a37d3339 --- /dev/null +++ b/packages/raystack/components/theme-preview/radius.ts @@ -0,0 +1,30 @@ +import styles from './radius.module.css'; +import type { Radius } from './settings'; + +/** + * The per-component `radius` override, as a cva variant so no component + * carries bespoke override CSS. + * + * ```ts + * const button = cva(styles['button'], { + * variants: { ...radiusVariants, size: { … } } + * }); + * ``` + */ +export const radiusClasses = { + none: styles['radius-none'], + small: styles['radius-small'], + medium: styles['radius-medium'], + large: styles['radius-large'], + full: styles['radius-full'] +} satisfies Record; + +/** Drop-in `variants` fragment for a cva definition. */ +export const radiusVariants = { radius: radiusClasses }; + +/** The class for a `radius` prop, or `undefined` when it is unset. */ +export function radiusClass(radius?: Radius | null): string | undefined { + return radius ? radiusClasses[radius] : undefined; +} + +export type { Radius }; diff --git a/packages/raystack/components/theme-preview/script.ts b/packages/raystack/components/theme-preview/script.ts new file mode 100644 index 000000000..8274c3d7d --- /dev/null +++ b/packages/raystack/components/theme-preview/script.ts @@ -0,0 +1,81 @@ +/** + * The pre-hydration inline script. Server-rendered HTML cannot know a + * client-side value, so this patches the theme element's attributes before + * first paint: it renders as the first child of that element and corrects its + * own parent, whose opening tag has already been parsed. Only a namespace's + * uncontrolled settings appear here, which usually means appearance alone. + */ + +import { + GRAY_PAIRING, + SETTING_ATTRIBUTES, + SETTING_VALUES, + STORAGE_VERSION, + SYSTEM_APPEARANCE_QUERY, + type ThemeSettingKey +} from './settings'; + +/** Identifies the theme element when `document.currentScript` is unavailable. */ +export const THEME_ID_ATTRIBUTE = 'data-rs-theme-id'; + +/** JSON that is safe to drop inside a `