From a763cec3a2ffded8f501d55c9960b37ebcf4434b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 11 Jun 2026 11:12:31 -0400 Subject: [PATCH 01/25] feat(ui): Introduce mosaic recipes and conditions --- .changeset/mosaic-slot-recipes.md | 2 + .../swingset/src/components/StoryPreview.tsx | 6 +- .../swingset/src/stories/button.stories.tsx | 4 +- .../swingset/src/stories/input.stories.tsx | 4 +- packages/ui/src/mosaic/MosaicProvider.tsx | 18 +- .../mosaic/__tests__/MosaicProvider.test.tsx | 71 ++ .../src/mosaic/__tests__/conditions.test.ts | 68 ++ packages/ui/src/mosaic/__tests__/cva.test.ts | 721 ------------------ .../src/mosaic/__tests__/resolveSlot.test.ts | 47 ++ .../src/mosaic/__tests__/slot-recipe.test.ts | 225 ++++++ .../ui/src/mosaic/__tests__/utils.test.ts | 29 +- packages/ui/src/mosaic/appearance.ts | 88 +++ .../components/__tests__/button.test.tsx | 101 +++ packages/ui/src/mosaic/components/button.tsx | 36 +- packages/ui/src/mosaic/components/input.tsx | 40 +- packages/ui/src/mosaic/conditions.ts | 63 ++ packages/ui/src/mosaic/cva.ts | 156 ---- packages/ui/src/mosaic/registry.ts | 28 + packages/ui/src/mosaic/resolveSlot.ts | 21 + packages/ui/src/mosaic/slot-recipe.ts | 311 ++++++++ packages/ui/src/mosaic/useSlot.ts | 56 ++ packages/ui/src/mosaic/utils.ts | 18 - references/mosaic-architecture.md | 340 ++++++--- 23 files changed, 1389 insertions(+), 1064 deletions(-) create mode 100644 .changeset/mosaic-slot-recipes.md create mode 100644 packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx create mode 100644 packages/ui/src/mosaic/__tests__/conditions.test.ts delete mode 100644 packages/ui/src/mosaic/__tests__/cva.test.ts create mode 100644 packages/ui/src/mosaic/__tests__/resolveSlot.test.ts create mode 100644 packages/ui/src/mosaic/__tests__/slot-recipe.test.ts create mode 100644 packages/ui/src/mosaic/appearance.ts create mode 100644 packages/ui/src/mosaic/components/__tests__/button.test.tsx create mode 100644 packages/ui/src/mosaic/conditions.ts delete mode 100644 packages/ui/src/mosaic/cva.ts create mode 100644 packages/ui/src/mosaic/registry.ts create mode 100644 packages/ui/src/mosaic/resolveSlot.ts create mode 100644 packages/ui/src/mosaic/slot-recipe.ts create mode 100644 packages/ui/src/mosaic/useSlot.ts diff --git a/.changeset/mosaic-slot-recipes.md b/.changeset/mosaic-slot-recipes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-slot-recipes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/StoryPreview.tsx b/packages/swingset/src/components/StoryPreview.tsx index 158b777569f..d0673d407f4 100644 --- a/packages/swingset/src/components/StoryPreview.tsx +++ b/packages/swingset/src/components/StoryPreview.tsx @@ -57,7 +57,11 @@ export function StoryPreview({ name, storyModule }: StoryPreviewProps) {
- +
diff --git a/packages/swingset/src/stories/button.stories.tsx b/packages/swingset/src/stories/button.stories.tsx index edc17c6a73e..0df1af5c918 100644 --- a/packages/swingset/src/stories/button.stories.tsx +++ b/packages/swingset/src/stories/button.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { ButtonProps } from '@clerk/ui/mosaic/components/button'; -import { Button, buttonStyles } from '@clerk/ui/mosaic/components/button'; +import { Button, buttonRecipe } from '@clerk/ui/mosaic/components/button'; import type { StoryMeta } from '@/lib/types'; @@ -8,7 +8,7 @@ export const meta: StoryMeta = { group: 'Components', title: 'Button', source: 'packages/ui/src/mosaic/components/button.tsx', - styles: buttonStyles, + styles: buttonRecipe, }; // Story functions accept Record (knob values) and cast to ButtonProps. diff --git a/packages/swingset/src/stories/input.stories.tsx b/packages/swingset/src/stories/input.stories.tsx index 8cf2b06ba47..33bfe0296a6 100644 --- a/packages/swingset/src/stories/input.stories.tsx +++ b/packages/swingset/src/stories/input.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @emotion/react */ import type { InputProps } from '@clerk/ui/mosaic/components/input'; -import { Input, inputStyles } from '@clerk/ui/mosaic/components/input'; +import { Input, inputRecipe } from '@clerk/ui/mosaic/components/input'; import type { StoryMeta } from '@/lib/types'; @@ -8,7 +8,7 @@ export const meta: StoryMeta = { group: 'Components', title: 'Input', source: 'packages/ui/src/mosaic/components/input.tsx', - styles: inputStyles, + styles: inputRecipe, }; function knobsAsProps(props: Record) { diff --git a/packages/ui/src/mosaic/MosaicProvider.tsx b/packages/ui/src/mosaic/MosaicProvider.tsx index 40d28b347dc..2464b506671 100644 --- a/packages/ui/src/mosaic/MosaicProvider.tsx +++ b/packages/ui/src/mosaic/MosaicProvider.tsx @@ -4,8 +4,10 @@ import createCache from '@emotion/cache'; import { CacheProvider, type SerializedStyles } from '@emotion/react'; import React from 'react'; +import { MosaicAppearanceProvider, parseMosaicAppearance } from './appearance'; +import type { MosaicAppearance } from './appearance'; import { defaultMosaicVariables, resolveVariables } from './variables'; -import type { MosaicTheme, MosaicVariables } from './variables'; +import type { MosaicTheme } from './variables'; const getInsertionPoint = (): HTMLElement | null => { if (typeof document === 'undefined') { @@ -18,13 +20,17 @@ const MosaicThemeContext = React.createContext(null); export interface MosaicProviderProps { children: React.ReactNode; - variables?: MosaicVariables; nonce?: string; cssLayerName?: string; + /** Consumer overrides — `variables` (design tokens) + `elements` (per-slot styles), with optional per-flow scoping. */ + appearance?: MosaicAppearance; + /** The active flow key (`'signIn'`, `'userButton'`, …) used to resolve scoped overrides. */ + scope?: string; } -export function MosaicProvider({ children, variables, nonce, cssLayerName }: MosaicProviderProps) { - const theme = React.useMemo(() => resolveVariables(defaultMosaicVariables, variables), [variables]); +export function MosaicProvider({ children, nonce, cssLayerName, appearance, scope }: MosaicProviderProps) { + const theme = React.useMemo(() => resolveVariables(defaultMosaicVariables, appearance?.variables), [appearance]); + const parsedElements = React.useMemo(() => parseMosaicAppearance(appearance, scope), [appearance, scope]); const cache = React.useMemo(() => { const el = getInsertionPoint(); const emotionCache = createCache({ @@ -51,7 +57,9 @@ export function MosaicProvider({ children, variables, nonce, cssLayerName }: Mos }, [nonce, cssLayerName]); return ( - {children} + + {children} + ); } diff --git a/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx new file mode 100644 index 00000000000..1e8958c7718 --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/MosaicProvider.test.tsx @@ -0,0 +1,71 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicAppearance } from '../appearance'; +import { parseMosaicAppearance, useMosaicAppearance } from '../appearance'; +import { MosaicProvider, useMosaicTheme } from '../MosaicProvider'; + +const appearance: MosaicAppearance = { + elements: { + button: { color: 'green' }, + signIn: { button: { color: 'red' } }, + }, +}; + +describe('parseMosaicAppearance', () => { + it('returns [] with no appearance', () => { + expect(parseMosaicAppearance(undefined, 'signIn')).toEqual([]); + }); + + it('returns only the global layer (scope keys stripped) with no scope', () => { + expect(parseMosaicAppearance(appearance)).toEqual([{ button: { color: 'green' } }]); + }); + + it('returns [global, scoped] in order for a matching scope', () => { + expect(parseMosaicAppearance(appearance, 'signIn')).toEqual([ + { button: { color: 'green' } }, + { button: { color: 'red' } }, + ]); + }); + + it('omits an unmatched scope layer', () => { + expect(parseMosaicAppearance(appearance, 'signUp')).toEqual([{ button: { color: 'green' } }]); + }); + + it('emits only the scoped layer when there are no global slot overrides', () => { + const scopedOnly: MosaicAppearance = { elements: { signIn: { button: { color: 'red' } } } }; + expect(parseMosaicAppearance(scopedOnly, 'signIn')).toEqual([{ button: { color: 'red' } }]); + }); +}); + +describe('MosaicProvider appearance context', () => { + it('exposes [global, scoped] layers via useMosaicAppearance', () => { + const { result } = renderHook(() => useMosaicAppearance(), { + wrapper: ({ children }) => React.createElement(MosaicProvider, { appearance, scope: 'signIn' }, children), + }); + expect(result.current).toEqual([{ button: { color: 'green' } }, { button: { color: 'red' } }]); + }); + + it('defaults to [] when standalone (no appearance)', () => { + const { result } = renderHook(() => useMosaicAppearance()); + expect(result.current).toEqual([]); + }); +}); + +describe('MosaicProvider theme from appearance.variables', () => { + it('resolves the theme from variables nested in appearance (global only)', () => { + const withVars: MosaicAppearance = { variables: { rounded: { md: '1rem' } } }; + const { result } = renderHook(() => useMosaicTheme(), { + wrapper: ({ children }) => React.createElement(MosaicProvider, { appearance: withVars }, children), + }); + expect(result.current.rounded.md).toBe('1rem'); + }); + + it('falls back to default tokens when no variables are supplied', () => { + const { result } = renderHook(() => useMosaicTheme(), { + wrapper: ({ children }) => React.createElement(MosaicProvider, {}, children), + }); + expect(result.current.rounded.md).toBe('0.375rem'); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/conditions.test.ts b/packages/ui/src/mosaic/__tests__/conditions.test.ts new file mode 100644 index 00000000000..f0b80f9fd83 --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/conditions.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import { conditions, expandConditions } from '../conditions'; + +describe('expandConditions', () => { + it('rewrites _hover into an @media (hover: hover) + &:hover block', () => { + expect(expandConditions({ _hover: { color: 'red' } })).toEqual({ + '@media (hover: hover)': { '&:hover': { color: 'red' } }, + }); + }); + + it('maps each built-in condition to its selector chain', () => { + expect(expandConditions({ _focusVisible: { outline: '2px' } })).toEqual({ + '&:focus-visible': { outline: '2px' }, + }); + expect(expandConditions({ _disabled: { opacity: 0.5 } })).toEqual({ + '&[data-cl-disabled]': { opacity: 0.5 }, + }); + expect(expandConditions({ _invalid: { borderColor: 'red' } })).toEqual({ + '&[aria-invalid="true"]': { borderColor: 'red' }, + }); + expect(expandConditions({ _motionSafe: { transition: 'all 0.2s' } })).toEqual({ + '@media (prefers-reduced-motion: no-preference)': { transition: 'all 0.2s' }, + }); + }); + + it('merges a condition into a raw selector that resolves to the same wrapper', () => { + expect( + expandConditions({ + '@media (hover: hover)': { '&:focus': { color: 'blue' } }, + _hover: { color: 'red' }, + }), + ).toEqual({ + '@media (hover: hover)': { + '&:focus': { color: 'blue' }, + '&:hover': { color: 'red' }, + }, + }); + }); + + it('expands nested conditions recursively', () => { + expect(expandConditions({ _disabled: { _hover: { color: 'red' } } })).toEqual({ + '&[data-cl-disabled]': { + '@media (hover: hover)': { '&:hover': { color: 'red' } }, + }, + }); + }); + + it('leaves raw selectors and plain CSS properties untouched', () => { + expect( + expandConditions({ + color: 'red', + padding: 4, + '&:active': { color: 'blue' }, + }), + ).toEqual({ + color: 'red', + padding: 4, + '&:active': { color: 'blue' }, + }); + }); + + it('exposes the underscore-prefixed condition vocabulary', () => { + expect(Object.keys(conditions)).toEqual( + expect.arrayContaining(['_hover', '_focusVisible', '_active', '_disabled', '_invalid', '_motionSafe']), + ); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/cva.test.ts b/packages/ui/src/mosaic/__tests__/cva.test.ts deleted file mode 100644 index 6e37f7b5a93..00000000000 --- a/packages/ui/src/mosaic/__tests__/cva.test.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { describe, expect, expectTypeOf, it } from 'vitest'; - -import { cva } from '../cva'; -import type { SxProp, VariantProps } from '../cva'; -import { defaultMosaicVariables, resolveVariables } from '../variables'; -import type { MosaicTheme } from '../variables'; - -const mockTheme: MosaicTheme = resolveVariables(defaultMosaicVariables); - -describe('cva', () => { - it('applies base styles when no variants are selected', () => { - const styles = cva({ - base: { display: 'flex', alignItems: 'center' }, - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - }, - }); - - const res = styles()(mockTheme); - expect(res).toEqual({ display: 'flex', alignItems: 'center' }); - }); - - it('applies correct styles per variant prop', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ display: 'flex', fontSize: 12 }); - }); - - it('merges styles from multiple variant axes', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - color: { - blue: { backgroundColor: 'blue' }, - green: { backgroundColor: 'green' }, - }, - }, - }); - - const res = styles({ size: 'sm', color: 'blue' })(mockTheme); - expect(res).toEqual({ display: 'flex', fontSize: 12, backgroundColor: 'blue' }); - }); - - it('uses default variants when prop is omitted', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - color: { - blue: { backgroundColor: 'blue' }, - green: { backgroundColor: 'green' }, - }, - }, - defaultVariants: { color: 'blue' }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ display: 'flex', fontSize: 12, backgroundColor: 'blue' }); - }); - - it('overrides default variants with explicit props', () => { - const styles = cva({ - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - }, - defaultVariants: { size: 'sm' }, - }); - - const res = styles({ size: 'md' })(mockTheme); - expect(res).toEqual({ fontSize: 16 }); - }); - - it('applies boolean variant with true', () => { - const styles = cva({ - variants: { - disabled: { - false: null, - true: { opacity: 0.5, cursor: 'not-allowed' }, - }, - }, - }); - - const res = styles({ disabled: true })(mockTheme); - expect(res).toEqual({ opacity: 0.5, cursor: 'not-allowed' }); - }); - - it('applies boolean variant with false (null means no styles)', () => { - const styles = cva({ - variants: { - disabled: { - false: null, - true: { opacity: 0.5, cursor: 'not-allowed' }, - }, - }, - }); - - const res = styles({ disabled: false })(mockTheme); - expect(res).toEqual({}); - }); - - it('matches boolean variants in compound variants', () => { - const styles = cva({ - variants: { - intent: { - primary: { background: 'blue' }, - secondary: { background: 'white' }, - }, - disabled: { - false: null, - true: { opacity: 0.5 }, - }, - }, - compoundVariants: [{ intent: 'primary', disabled: false, css: { '&:hover': { background: 'darkblue' } } }], - defaultVariants: { disabled: false }, - }); - - const res = styles({ intent: 'primary' })(mockTheme); - expect(res).toEqual({ - background: 'blue', - '&:hover': { background: 'darkblue' }, - }); - }); - - it('deep merges nested pseudo-selectors', () => { - const styles = cva({ - base: { - backgroundColor: 'white', - '&:active': { backgroundColor: 'gray' }, - }, - variants: { - size: { - sm: { - fontSize: 12, - '&:active': { transform: 'scale(0.98)' }, - }, - }, - }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ - backgroundColor: 'white', - fontSize: 12, - '&:active': { - backgroundColor: 'gray', - transform: 'scale(0.98)', - }, - }); - }); - - it('applies compound variants when all conditions match', () => { - const styles = cva({ - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - color: { - blue: { backgroundColor: 'blue' }, - green: { backgroundColor: 'green' }, - }, - }, - compoundVariants: [{ size: 'sm', color: 'blue', css: { borderRadius: '9999px' } }], - defaultVariants: { color: 'blue' }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ fontSize: 12, backgroundColor: 'blue', borderRadius: '9999px' }); - }); - - it('does not apply compound variants when conditions do not match', () => { - const styles = cva({ - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - color: { - blue: { backgroundColor: 'blue' }, - green: { backgroundColor: 'green' }, - }, - }, - compoundVariants: [{ size: 'sm', color: 'green', css: { backgroundColor: 'notpossible' } }], - defaultVariants: { color: 'blue' }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ fontSize: 12, backgroundColor: 'blue' }); - }); - - it('compound variant styles override variant styles', () => { - const styles = cva({ - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - color: { - blue: { backgroundColor: 'blue' }, - green: { backgroundColor: 'green' }, - }, - }, - compoundVariants: [{ size: 'md', color: 'green', css: { backgroundColor: 'gainsboro' } }], - defaultVariants: { size: 'sm', color: 'blue' }, - }); - - const res = styles({ size: 'md', color: 'green' })(mockTheme); - expect(res).toEqual({ fontSize: 16, backgroundColor: 'gainsboro' }); - }); - - it('works with a static config (no theme function)', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - }, - }, - }); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ display: 'flex', fontSize: 12 }); - }); - - it('accesses MosaicTheme values in config function', () => { - const styles = cva(theme => ({ - base: { color: theme.color.primary }, - variants: { - size: { - sm: { padding: theme.spacing(2) }, - md: { padding: theme.spacing(4) }, - }, - }, - })); - - const res = styles({ size: 'sm' })(mockTheme); - expect(res).toEqual({ color: 'light-dark(oklch(0.205 0 0), oklch(0.922 0 0))', padding: 'calc(0.25rem * 2)' }); - }); - - it('returns base styles only when no variants provided and no defaults', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - }, - }); - - const res = styles()(mockTheme); - expect(res).toEqual({ display: 'flex' }); - }); - - it('ignores unknown props not in variants', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - size: { - sm: { fontSize: 12 }, - }, - }, - }); - - const res = styles({ size: 'sm', unknownProp: 'value' } as unknown as VariantProps)(mockTheme); - expect(res).toEqual({ display: 'flex', fontSize: 12 }); - }); - - it('handles null variant values (no styles applied)', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - state: { - active: { color: 'blue' }, - inactive: null, - }, - }, - }); - - const resActive = styles({ state: 'active' })(mockTheme); - expect(resActive).toEqual({ display: 'flex', color: 'blue' }); - - const resInactive = styles({ state: 'inactive' })(mockTheme); - expect(resInactive).toEqual({ display: 'flex' }); - }); - - describe('VariantProps type', () => { - it('extracts correct variant prop types', () => { - const styles = cva({ - variants: { - intent: { - primary: { background: 'blue' }, - secondary: { background: 'gray' }, - }, - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - disabled: { - false: null, - true: { opacity: 0.5 }, - }, - }, - }); - - type Props = VariantProps; - - // Valid assignments - const _valid1: Props = { intent: 'primary', size: 'sm', disabled: true }; - const _valid2: Props = { intent: 'secondary', disabled: false }; - const _valid3: Props = {}; - - // @ts-expect-error - invalid intent value - const _invalid1: Props = { intent: 'nonexistent' }; - - // @ts-expect-error - invalid size value - const _invalid2: Props = { size: 'xl' }; - - // @ts-expect-error - disabled should be boolean, not string - const _invalid3: Props = { disabled: 'true' }; - - // Suppress unused variable warnings - void [_valid1, _valid2, _valid3, _invalid1, _invalid2, _invalid3]; - }); - }); - - it('respects variant specificity - later variant in config wins on conflict', () => { - const styles = cva({ - variants: { - type: { - subtitle: { color: 'green', fontSize: 12 }, - }, - size: { - sm: { fontSize: 12 }, - md: { fontSize: 16 }, - }, - }, - }); - - const res = styles({ type: 'subtitle', size: 'md' })(mockTheme); - expect(res).toEqual({ color: 'green', fontSize: 16 }); - }); - - it('preserves nested pseudo-selectors from variant styles', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: { - color: { - primary: { - backgroundColor: 'blue', - '&:hover': { backgroundColor: 'darkblue' }, - }, - }, - }, - }); - - const res = styles({ color: 'primary' })(mockTheme); - expect(res).toEqual({ - display: 'flex', - backgroundColor: 'blue', - '&:hover': { backgroundColor: 'darkblue' }, - }); - }); -}); - -describe('variant styles with explicitly undefined props (regression: key-in-props bug)', () => { - // Mirrors the shape of a real component that destructures all props before passing to cva — - // the resulting object has keys present but set to undefined, which must still fall back to defaults. - const componentStyles = cva(theme => ({ - variants: { - color: { - primary: { - backgroundColor: theme.color.primary, - '&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) }, - '&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) }, - }, - }, - size: { sm: { fontSize: theme.text('sm').fontSize }, md: { fontSize: theme.text('base').fontSize } }, - disabled: { false: null, true: { opacity: 0.5, pointerEvents: 'none' } }, - }, - defaultVariants: { color: 'primary', size: 'md', disabled: false }, - })); - - it('hover and active styles are present when all props are explicitly undefined', () => { - const res = componentStyles({ color: undefined, size: undefined, disabled: undefined })(mockTheme); - expect(res['&:hover']).toBeDefined(); - expect(res['&:active']).toBeDefined(); - }); -}); - -describe('resolveVariants via cva', () => { - it('falls back to default variant when prop is explicitly undefined', () => { - const styles = cva({ - variants: { - color: { - primary: { backgroundColor: 'blue', '&:hover': { backgroundColor: 'darkblue' } }, - }, - }, - defaultVariants: { color: 'primary' }, - }); - - const res = styles({ color: undefined })(mockTheme); - expect(res).toEqual({ backgroundColor: 'blue', '&:hover': { backgroundColor: 'darkblue' } }); - }); - - it('applies defined props while falling back to defaults for undefined ones', () => { - const styles = cva({ - variants: { - size: { sm: { fontSize: 12 }, md: { fontSize: 16 } }, - color: { blue: { backgroundColor: 'blue' }, red: { backgroundColor: 'red' } }, - }, - defaultVariants: { size: 'md', color: 'blue' }, - }); - - const res = styles({ size: undefined, color: 'red' })(mockTheme); - expect(res).toEqual({ fontSize: 16, backgroundColor: 'red' }); - }); - - it('applies boolean default variant with non-null false styles', () => { - const styles = cva({ - variants: { - disabled: { - false: { cursor: 'pointer' }, - true: { opacity: 0.5, cursor: 'not-allowed' }, - }, - }, - defaultVariants: { disabled: false }, - }); - - const res = styles()(mockTheme); - expect(res).toEqual({ cursor: 'pointer' }); - }); - - it('applies boolean default variant with explicit undefined', () => { - const styles = cva({ - variants: { - disabled: { - false: { cursor: 'pointer' }, - true: { opacity: 0.5, cursor: 'not-allowed' }, - }, - }, - defaultVariants: { disabled: false }, - }); - - const res = styles({ disabled: undefined })(mockTheme); - expect(res).toEqual({ cursor: 'pointer' }); - }); -}); - -describe('sx prop', () => { - it('applies sx plain object, overriding variant styles', () => { - const styles = cva({ - variants: { - color: { primary: { backgroundColor: 'blue', color: 'white' } }, - }, - }); - - const res = styles({ color: 'primary', sx: { backgroundColor: 'red' } })(mockTheme); - expect(res).toEqual({ backgroundColor: 'red', color: 'white' }); - }); - - it('applies sx as a function receiving the theme', () => { - const styles = cva({ - base: { display: 'flex' }, - variants: {}, - }); - - const res = styles({ sx: theme => ({ color: theme.color.primary }) })(mockTheme); - expect(res).toEqual({ display: 'flex', color: mockTheme.color.primary }); - }); - - it('sx overrides base styles', () => { - const styles = cva({ - base: { display: 'flex', gap: 8 }, - variants: {}, - }); - - const res = styles({ sx: { gap: 16 } })(mockTheme); - expect(res).toEqual({ display: 'flex', gap: 16 }); - }); - - it('sx merges nested pseudo-selectors rather than replacing them', () => { - const styles = cva({ - variants: { - color: { - primary: { backgroundColor: 'blue', '&:hover': { backgroundColor: 'darkblue' } }, - }, - }, - }); - - const res = styles({ color: 'primary', sx: { '&:hover': { opacity: 0.9 } } })(mockTheme); - expect(res['&:hover']).toEqual({ backgroundColor: 'darkblue', opacity: 0.9 }); - }); - - it('sx overrides conflicting pseudo-selector leaf values', () => { - const styles = cva({ - variants: { - color: { - primary: { '&:hover': { backgroundColor: 'blue', color: 'white' } }, - }, - }, - }); - - const res = styles({ color: 'primary', sx: { '&:hover': { backgroundColor: 'red' } } })(mockTheme); - expect(res['&:hover']).toEqual({ backgroundColor: 'red', color: 'white' }); - }); -}); - -describe('compound variants', () => { - it('applies multiple matching compound variants in order, merging them', () => { - const styles = cva({ - variants: { - size: { sm: { fontSize: 12 } }, - color: { primary: { backgroundColor: 'blue' } }, - }, - compoundVariants: [ - { size: 'sm', color: 'primary', css: { borderRadius: 4 } }, - { size: 'sm', color: 'primary', css: { padding: 8 } }, - ], - }); - - const res = styles({ size: 'sm', color: 'primary' })(mockTheme); - expect(res.borderRadius).toBe(4); - expect(res.padding).toBe(8); - }); - - it('later compound variant overrides earlier one on conflicting property', () => { - const styles = cva({ - variants: { - size: { sm: { fontSize: 12 } }, - color: { primary: { backgroundColor: 'blue' } }, - }, - compoundVariants: [ - { size: 'sm', color: 'primary', css: { borderRadius: 4 } }, - { size: 'sm', color: 'primary', css: { borderRadius: 8 } }, - ], - }); - - const res = styles({ size: 'sm', color: 'primary' })(mockTheme); - expect(res.borderRadius).toBe(8); - }); - - it('compound variant pseudo-selector deep-merges with existing variant pseudo-selector', () => { - const styles = cva({ - variants: { - color: { primary: { '&:hover': { backgroundColor: 'blue' } } }, - disabled: { false: null, true: { opacity: 0.5 } }, - }, - compoundVariants: [{ color: 'primary', disabled: false, css: { '&:hover': { color: 'white' } } }], - defaultVariants: { disabled: false }, - }); - - const res = styles({ color: 'primary' })(mockTheme); - expect(res['&:hover']).toEqual({ backgroundColor: 'blue', color: 'white' }); - }); -}); - -describe('type safety', () => { - describe('SxProp', () => { - it('accepts a plain style object', () => { - const _sx: SxProp = { color: 'red', padding: 8 }; - void _sx; - }); - - it('accepts a function that receives MosaicTheme', () => { - const _sx: SxProp = (theme: MosaicTheme) => ({ color: theme.color.primary }); - void _sx; - }); - - it('rejects non-object/function values', () => { - // @ts-expect-error - string is not a valid SxProp - const _a: SxProp = 'red'; - // @ts-expect-error - number is not a valid SxProp - const _b: SxProp = 42; - void [_a, _b]; - }); - - it('sx function parameter is typed as MosaicTheme, not any', () => { - const _sx: SxProp = theme => { - // @ts-expect-error - nonexistent color key - void theme.color.nonexistent; - return {}; - }; - void _sx; - }); - }); - - describe('MosaicTheme helpers', () => { - it('spacing returns a typed template literal, not string', () => { - expectTypeOf(mockTheme.spacing(4)).toEqualTypeOf<'calc(0.25rem * 4)'>(); - }); - - it('text returns exact fontSize and lineHeight literal types', () => { - expectTypeOf(mockTheme.text('sm')).toEqualTypeOf<{ - fontSize: '0.875rem'; - lineHeight: 'calc(1.25 / 0.875)'; - }>(); - }); - - it('alpha returns a typed template literal', () => { - expectTypeOf( - mockTheme.alpha('primary', 50), - ).toEqualTypeOf<'color-mix(in oklab, light-dark(oklch(0.205 0 0), oklch(0.922 0 0)) 50%, transparent)'>(); - }); - - it('mix returns a typed template literal', () => { - expectTypeOf( - mockTheme.mix('primary', 'primaryForeground', 12), - ).toEqualTypeOf<'color-mix(in oklab, light-dark(oklch(0.205 0 0), oklch(0.922 0 0)), light-dark(oklch(0.985 0 0), oklch(0.205 0 0)) 12%)'>(); - }); - - it('rejects invalid keys on theme helpers', () => { - // Wrapped in a never-called function so TS checks types without executing - () => { - // @ts-expect-error - nonexistent color key - mockTheme.alpha('nonexistent', 50); - // @ts-expect-error - nonexistent color key - mockTheme.mix('primary', 'nonexistent', 10); - // @ts-expect-error - nonexistent fontSize key - mockTheme.text('4xl'); - }; - }); - }); - - describe('VariantProps boolean unwrapping', () => { - it('exposes boolean type for true/false variant keys, not string literals', () => { - const styles = cva({ - variants: { - disabled: { false: null, true: { opacity: 0.5 } }, - }, - }); - type Props = VariantProps; - - expectTypeOf().toEqualTypeOf(); - - // @ts-expect-error - string 'true' is not assignable to boolean - const _invalid: Props = { disabled: 'true' }; - void _invalid; - }); - }); - - describe('cva theme-function overload', () => { - it('rejects invalid theme property access inside config function', () => { - cva((_theme: MosaicTheme) => ({ - // @ts-expect-error - nonexistent color key - base: { color: _theme.color.nonexistent }, - variants: {}, - })); - }); - }); - - describe('defaultVariants', () => { - it('accepts valid variant values', () => { - cva({ - variants: { - color: { primary: { backgroundColor: 'blue' }, secondary: { backgroundColor: 'gray' } }, - disabled: { false: null, true: { opacity: 0.5 } }, - }, - defaultVariants: { color: 'primary', disabled: false }, - }); - }); - - it('rejects an invalid value for a variant key', () => { - cva({ - variants: { - color: { primary: { backgroundColor: 'blue' }, secondary: { backgroundColor: 'gray' } }, - }, - defaultVariants: { - // @ts-expect-error - 'danger' is not a valid color variant value - color: 'danger', - }, - }); - }); - - it('rejects an unknown variant key', () => { - cva({ - variants: { - color: { primary: { backgroundColor: 'blue' } }, - }, - defaultVariants: { - // @ts-expect-error - 'size' is not a declared variant - size: 'sm', - }, - }); - }); - - it('rejects a string for a boolean variant', () => { - cva({ - variants: { - disabled: { false: null, true: { opacity: 0.5 } }, - }, - defaultVariants: { - // @ts-expect-error - 'false' string is not assignable to boolean - disabled: 'false', - }, - }); - }); - }); -}); diff --git a/packages/ui/src/mosaic/__tests__/resolveSlot.test.ts b/packages/ui/src/mosaic/__tests__/resolveSlot.test.ts new file mode 100644 index 00000000000..9f127d0f8db --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/resolveSlot.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import type { ParsedMosaicElements } from '../appearance'; +import { resolveSlotCss } from '../resolveSlot'; + +describe('resolveSlotCss', () => { + it('returns [] when there are no layers', () => { + expect(resolveSlotCss('button', [])).toEqual([]); + }); + + it('returns [] when no layer targets the slot', () => { + const parsed: ParsedMosaicElements = [{ input: { color: 'red' } }]; + expect(resolveSlotCss('button', parsed)).toEqual([]); + }); + + it('returns the single matching override', () => { + const parsed: ParsedMosaicElements = [{ button: { color: 'lime' } }]; + expect(resolveSlotCss('button', parsed)).toEqual([{ color: 'lime' }]); + }); + + it('returns matching overrides in layer order (low → high)', () => { + const parsed: ParsedMosaicElements = [ + { button: { color: 'green' } }, // global + { button: { color: 'red' } }, // scoped — wins because it comes later + ]; + expect(resolveSlotCss('button', parsed)).toEqual([{ color: 'green' }, { color: 'red' }]); + }); + + it('skips layers that do not target the slot but keeps the rest', () => { + const parsed: ParsedMosaicElements = [ + { button: { color: 'green' } }, + { input: { color: 'blue' } }, + { button: { color: 'red' } }, + ]; + expect(resolveSlotCss('button', parsed)).toEqual([{ color: 'green' }, { color: 'red' }]); + }); + + it('ignores className string overrides (only object style rules are returned)', () => { + const parsed: ParsedMosaicElements = [{ button: 'MyButton' }, { button: { color: 'red' } }]; + expect(resolveSlotCss('button', parsed)).toEqual([{ color: 'red' }]); + }); + + it('preserves nested state-attribute selectors', () => { + const parsed: ParsedMosaicElements = [{ button: { color: 'red', '&[data-cl-disabled]': { opacity: 0.4 } } }]; + expect(resolveSlotCss('button', parsed)).toEqual([{ color: 'red', '&[data-cl-disabled]': { opacity: 0.4 } }]); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts new file mode 100644 index 00000000000..6f4b4610e2e --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/slot-recipe.test.ts @@ -0,0 +1,225 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicAppearance } from '../appearance'; +import { MosaicProvider } from '../MosaicProvider'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; +import { slot, useSlot } from '../useSlot'; + +function wrapper(appearance?: MosaicAppearance, scope?: string) { + return function Wrapper({ children }: { children: React.ReactNode }) { + return React.createElement(MosaicProvider, { appearance, scope }, children); + }; +} + +const buttonRecipe = defineSlotRecipe({ + slot: 'button', + base: { display: 'inline-flex', '&[data-cl-disabled]': { opacity: 0.5 } }, + variants: { + color: { primary: { color: 'white' }, danger: { color: 'red' } }, + size: { sm: { fontSize: 12 }, md: { fontSize: 14 } }, + loading: { false: null, true: { cursor: 'wait' } }, + }, + compoundVariants: [{ color: 'danger', size: 'sm', css: { fontWeight: 700 } }], + defaultVariants: { color: 'primary', size: 'md' }, +}); + +describe('defineSlotRecipe / useRecipe', () => { + it('single-slot shorthand exposes an implicit `root` slot', () => { + expect(buttonRecipe.single).toBe(true); + expect(buttonRecipe.slotKeys).toEqual(['root']); + expect(buttonRecipe.slotMap.root).toBe('button'); + }); + + it('applies base + default variants merged into one css object', () => { + const { result } = renderHook(() => useRecipe(buttonRecipe), { wrapper: wrapper() }); + expect(result.current.root.css).toEqual({ + display: 'inline-flex', + '&[data-cl-disabled]': { opacity: 0.5 }, + color: 'white', // default color: primary + fontSize: 14, // default size: md + }); + expect(result.current.root['data-cl-slot']).toBe('button'); + }); + + it('selected variants override defaults', () => { + const { result } = renderHook(() => useRecipe(buttonRecipe, { variants: { color: 'danger', size: 'sm' } }), { + wrapper: wrapper(), + }); + expect(result.current.root.css).toMatchObject({ color: 'red', fontSize: 12, fontWeight: 700 }); + }); + + it('coerces boolean variants to string keys', () => { + const { result } = renderHook(() => useRecipe(buttonRecipe, { variants: { loading: true } }), { + wrapper: wrapper(), + }); + expect(result.current.root.css).toMatchObject({ cursor: 'wait' }); + }); + + it('resolution order is base → variants → compound → sx → appearance (later wins)', () => { + const appearance: MosaicAppearance = { elements: { button: { color: 'green', fontSize: 99 } } }; + const { result } = renderHook( + () => useRecipe(buttonRecipe, { variants: { color: 'danger' }, sx: { color: 'blue' } }), + { wrapper: wrapper(appearance) }, + ); + // variant danger → color red, sx → color blue, appearance → color green (appearance is highest) + expect(result.current.root.css.color).toBe('green'); + expect(result.current.root.css.fontSize).toBe(99); + }); + + it('emits resolved variants as data-cl- attributes (defaults included)', () => { + const { result } = renderHook(() => useRecipe(buttonRecipe, { variants: { size: 'sm' } }), { wrapper: wrapper() }); + expect(result.current.root['data-cl-size']).toBe('sm'); + expect(result.current.root['data-cl-color']).toBe('primary'); // default still emitted, so md vs sm is targetable + }); + + it('uses presence semantics for boolean variants', () => { + const { result: on } = renderHook(() => useRecipe(buttonRecipe, { variants: { loading: true } }), { + wrapper: wrapper(), + }); + expect(on.current.root['data-cl-loading']).toBe(''); + + const { result: off } = renderHook(() => useRecipe(buttonRecipe, { variants: { loading: false } }), { + wrapper: wrapper(), + }); + expect(off.current.root).not.toHaveProperty('data-cl-loading'); + }); + + it('maps truthy state to data-cl attributes and omits falsy ones', () => { + const { result } = renderHook(() => useRecipe(buttonRecipe, { state: { disabled: true, loading: false } }), { + wrapper: wrapper(), + }); + expect(result.current.root['data-cl-disabled']).toBe(''); + expect(result.current.root).not.toHaveProperty('data-cl-loading'); + }); + + it('preserves nested state-attribute appearance overrides through the merge', () => { + const appearance: MosaicAppearance = { + elements: { button: { '&[data-cl-disabled]': { opacity: 0.2 } } }, + }; + const { result } = renderHook(() => useRecipe(buttonRecipe), { wrapper: wrapper(appearance) }); + expect(result.current.root.css['&[data-cl-disabled]']).toEqual({ opacity: 0.2 }); + }); +}); + +describe('conditions', () => { + const hoverRecipe = defineSlotRecipe({ + slot: 'button', + base: { color: 'black', _hover: { backgroundColor: 'white' } }, + }); + + it('expands recipe-authored conditions into nested selectors', () => { + const { result } = renderHook(() => useRecipe(hoverRecipe), { wrapper: wrapper() }); + expect(result.current.root.css).toEqual({ + color: 'black', + '@media (hover: hover)': { '&:hover': { backgroundColor: 'white' } }, + }); + }); + + it('lets a consumer override hover via appearance.elements._hover (consumer wins)', () => { + const appearance: MosaicAppearance = { elements: { button: { _hover: { backgroundColor: 'red' } } } }; + const { result } = renderHook(() => useRecipe(hoverRecipe), { wrapper: wrapper(appearance) }); + expect(result.current.root.css).toEqual({ + color: 'black', + '@media (hover: hover)': { '&:hover': { backgroundColor: 'red' } }, + }); + }); + + it('expands conditions provided through sx', () => { + const { result } = renderHook(() => useRecipe(hoverRecipe, { sx: { _focusVisible: { outline: '2px' } } }), { + wrapper: wrapper(), + }); + expect(result.current.root.css['&:focus-visible']).toEqual({ outline: '2px' }); + }); + + it('expands conditions for non-recipe slots via useSlot', () => { + const appearance: MosaicAppearance = { elements: { avatarBox: { _hover: { opacity: 0.8 } } } }; + const { result } = renderHook(() => useSlot('avatarBox'), { wrapper: wrapper(appearance) }); + expect(result.current.css).toEqual({ + '@media (hover: hover)': { '&:hover': { opacity: 0.8 } }, + }); + }); +}); + +describe('multi-slot recipe', () => { + const cardRecipe = defineSlotRecipe({ + slots: { root: { slot: 'card' }, header: { slot: 'cardHeader' }, body: { slot: 'cardBody' } }, + base: { root: { borderRadius: 8 }, header: { fontWeight: 600 }, body: {} }, + variants: { + tone: { + neutral: { root: { borderColor: 'gray' } }, + danger: { root: { borderColor: 'red', '&[data-cl-invalid]': { boxShadow: '0 0 1px red' } } }, + }, + }, + defaultVariants: { tone: 'neutral' }, + }); + + it('emits one data-cl-slot per slot and resolves per-slot css', () => { + const { result } = renderHook( + () => useRecipe(cardRecipe, { variants: { tone: 'danger' }, state: { invalid: true } }), + { + wrapper: wrapper(), + }, + ); + expect(result.current.root['data-cl-slot']).toBe('card'); + expect(result.current.header['data-cl-slot']).toBe('cardHeader'); + expect(result.current.body['data-cl-slot']).toBe('cardBody'); + expect(result.current.root.css).toMatchObject({ + borderRadius: 8, + borderColor: 'red', + '&[data-cl-invalid]': { boxShadow: '0 0 1px red' }, + }); + expect(result.current.header.css).toEqual({ fontWeight: 600 }); + }); + + it('attaches state attributes to every slot', () => { + const { result } = renderHook(() => useRecipe(cardRecipe, { state: { invalid: true } }), { wrapper: wrapper() }); + expect(result.current.root['data-cl-invalid']).toBe(''); + expect(result.current.header['data-cl-invalid']).toBe(''); + expect(result.current.body['data-cl-invalid']).toBe(''); + }); +}); + +describe('useSlot / slot sugar', () => { + it('slot() returns just the data-cl-slot attribute, no hook required', () => { + expect(slot('avatarBox')).toEqual({ 'data-cl-slot': 'avatarBox' }); + }); + + it('useSlot returns attrs + appearance-only css (no recipe styles)', () => { + const appearance: MosaicAppearance = { elements: { avatarBox: { color: 'lime' } } }; + const { result } = renderHook(() => useSlot('avatarBox', { state: { disabled: true } }), { + wrapper: wrapper(appearance), + }); + expect(result.current['data-cl-slot']).toBe('avatarBox'); + expect(result.current['data-cl-disabled']).toBe(''); + expect(result.current.css).toEqual({ color: 'lime' }); + }); + + it('useSlot merges sx before appearance overrides', () => { + const appearance: MosaicAppearance = { elements: { avatarBox: { color: 'lime' } } }; + const { result } = renderHook(() => useSlot('avatarBox', { sx: { color: 'blue', padding: 4 } }), { + wrapper: wrapper(appearance), + }); + expect(result.current.css).toEqual({ color: 'lime', padding: 4 }); + }); + + it('useSlot with no provider styling returns empty css and still emits the slot', () => { + const { result } = renderHook(() => useSlot('avatarBox'), { wrapper: wrapper() }); + expect(result.current).toEqual({ 'data-cl-slot': 'avatarBox', css: {} }); + }); +}); + +describe('type inference', () => { + it('infers variant prop names and values', () => { + // Type-only assertions — never executed, validated by `tsc` during type-check. + () => { + useRecipe(buttonRecipe, { variants: { color: 'danger', size: 'sm', loading: true } }); + // @ts-expect-error 'nope' is not a valid `color` + useRecipe(buttonRecipe, { variants: { color: 'nope' } }); + // @ts-expect-error `loading` is a boolean variant, not a string + useRecipe(buttonRecipe, { variants: { loading: 'true' } }); + }; + expect(true).toBe(true); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/utils.test.ts b/packages/ui/src/mosaic/__tests__/utils.test.ts index 3cd9ffead8b..a5c45258128 100644 --- a/packages/ui/src/mosaic/__tests__/utils.test.ts +++ b/packages/ui/src/mosaic/__tests__/utils.test.ts @@ -1,34 +1,7 @@ import { describe, expect, it } from 'vitest'; import { defaultMosaicVariables, resolveVariables } from '../variables'; -import { alpha, hover, motionSafe } from '../utils'; - -describe('hover', () => { - it('wraps styles in @media (hover: hover) and &:hover', () => { - expect(hover({ color: 'red' })).toEqual({ - '@media (hover: hover)': { - '&:hover': { color: 'red' }, - }, - }); - }); - - it('passes complex style objects through unchanged', () => { - const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' }; - expect(hover(styles)).toEqual({ - '@media (hover: hover)': { - '&:hover': styles, - }, - }); - }); -}); - -describe('motionSafe', () => { - it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => { - expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({ - '@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' }, - }); - }); -}); +import { alpha } from '../utils'; describe('alpha', () => { it('produces a color-mix expression', () => { diff --git a/packages/ui/src/mosaic/appearance.ts b/packages/ui/src/mosaic/appearance.ts new file mode 100644 index 00000000000..0126856f6f0 --- /dev/null +++ b/packages/ui/src/mosaic/appearance.ts @@ -0,0 +1,88 @@ +import * as React from 'react'; + +import type { MosaicElements } from './registry'; +import type { MosaicVariables } from './variables'; + +/** + * The flow-scope keys an `appearance` may carry. Overrides nested under one of these keys (inside + * `elements`) apply only inside the matching mounted flow — e.g. `appearance.elements.signIn.button` + * styles `button` only inside ``. Mirrors the legacy `parseAppearance` scoping. + */ +const SCOPE_KEYS = [ + 'signIn', + 'signUp', + 'userButton', + 'userProfile', + 'organizationProfile', + 'organizationSwitcher', + 'createOrganization', + 'organizationList', + 'oneTap', + 'waitlist', +] as const; + +export type MosaicScopeKey = (typeof SCOPE_KEYS)[number]; + +const SCOPE_KEY_SET = new Set(SCOPE_KEYS); + +/** + * The `elements` map: slot id → style override, plus optional per-flow scope keys whose values are + * nested slot → style maps. Scoping lives *inside* `elements`; it never carries `variables`. + */ +export type MosaicScopedElements = MosaicElements & { + [K in MosaicScopeKey]?: MosaicElements; +}; + +/** + * The public `appearance` object. `variables` overrides design tokens **globally** (not scopable); + * `elements` styles parts globally and, via nested scope keys, per flow. + */ +export interface MosaicAppearance { + variables?: MosaicVariables; + elements?: MosaicScopedElements; +} + +/** + * The resolver input: an ordered array of element layers, low → high precedence + * (`[global, scoped]`). Later layers win — scoping falls out of order, so the resolver needs no + * special-casing. + */ +export type ParsedMosaicElements = MosaicElements[]; + +const MosaicAppearanceContext = React.createContext([]); + +export const MosaicAppearanceProvider = MosaicAppearanceContext.Provider; + +/** Returns the ordered element layers from the nearest `MosaicProvider` (or `[]` standalone). */ +export const useMosaicAppearance = (): ParsedMosaicElements => React.useContext(MosaicAppearanceContext); + +/** + * Flattens `appearance.elements` into ordered layers for the active `scope`: the global slot + * overrides (scope keys stripped out) first, then the scoped overrides, so scoped wins by order. + */ +export function parseMosaicAppearance(appearance?: MosaicAppearance, scope?: string): ParsedMosaicElements { + const elements = appearance?.elements; + if (!elements) { + return []; + } + + const entries = elements as Record; + const global: MosaicElements = {}; + for (const key in entries) { + if (!SCOPE_KEY_SET.has(key)) { + global[key] = entries[key] as MosaicElements[string]; + } + } + + const layers: ParsedMosaicElements = []; + if (Object.keys(global).length > 0) { + layers.push(global); + } + if (scope && SCOPE_KEY_SET.has(scope)) { + const scoped = entries[scope] as MosaicElements | undefined; + if (scoped) { + layers.push(scoped); + } + } + return layers; +} diff --git a/packages/ui/src/mosaic/components/__tests__/button.test.tsx b/packages/ui/src/mosaic/components/__tests__/button.test.tsx new file mode 100644 index 00000000000..beada857bc8 --- /dev/null +++ b/packages/ui/src/mosaic/components/__tests__/button.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicAppearance } from '../../appearance'; +import { MosaicProvider } from '../../MosaicProvider'; +import { Button } from '../button'; + +/** Concatenates every inserted Emotion `