Skip to content

Commit 91d4ffd

Browse files
committed
feat(ui): add Mosaic Icon component and appearance.icons overrides
Adds <Icon name="..." /> rendering from a named glyph set, with per-name overrides via appearance.icons on MosaicProvider. Mosaic's sizing/color is applied to overrides so swapped glyphs stay visually consistent. Includes swingset docs.
1 parent f4ecc13 commit 91d4ffd

10 files changed

Lines changed: 413 additions & 3 deletions

File tree

.changeset/mosaic-icon.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/components/DocsViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
2828
input: dynamic(() => import('../stories/input.mdx')),
2929
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
3030
heading: dynamic(() => import('../stories/heading.mdx')),
31+
icon: dynamic(() => import('../stories/icon.mdx')),
3132
tabs: dynamic(() => import('../stories/tabs.component.mdx')),
3233
text: dynamic(() => import('../stories/text.mdx')),
3334
},

packages/swingset/src/lib/registry.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import {
1616
meta as headingMeta,
1717
Sizes as HeadingSizes,
1818
} from '../stories/heading.stories';
19+
import {
20+
Default as IconDefault,
21+
meta as iconMeta,
22+
Names as IconNames,
23+
Override as IconOverride,
24+
Sizes as IconSizes,
25+
} from '../stories/icon.stories';
1926
import {
2027
Default,
2128
Disabled as InputDisabled,
@@ -76,6 +83,14 @@ const tabsComponentModule: StoryModule = { meta: tabsComponentMeta, Default: Tab
7683

7784
const textModule: StoryModule = { meta: textMeta, Default: TextDefault, Sizes: TextSizes, Intents: TextIntents };
7885

86+
const iconModule: StoryModule = {
87+
meta: iconMeta,
88+
Default: IconDefault,
89+
Sizes: IconSizes,
90+
Names: IconNames,
91+
Override: IconOverride,
92+
};
93+
7994
// Headless primitives carry just `meta` (no story functions). Like every component
8095
// they're documented as a single overview page; their live demos come from `<Story>` /
8196
// `<Preview>` embeds in the MDX, which import the stories module directly.
@@ -104,6 +119,7 @@ export const registry: StoryModule[] = [
104119
inputModule,
105120
dialogComponentModule,
106121
headingModule,
122+
iconModule,
107123
tabsComponentModule,
108124
textModule,
109125
// Primitives — alphabetical within the group.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as IconStories from './icon.stories';
2+
3+
# Icon
4+
5+
Icon renders a named glyph from Mosaic's icon set. It sizes through the `size` variant and inherits color from `currentColor`. Any glyph can be swapped per name through `appearance.icons` on `MosaicProvider`. Mosaic's styling applies to an override just like the built-in glyph — so swapped icons stay visually consistent — and the override also receives `data-cl-slot="icon"` for targeting.
6+
7+
## Playground
8+
9+
<Preview
10+
name='Default'
11+
storyModule={IconStories}
12+
/>
13+
14+
## Props
15+
16+
<PropTable
17+
meta={IconStories.meta}
18+
extra={[{ name: 'name', type: 'IconName' }]}
19+
/>
20+
21+
## Usage
22+
23+
<Usage
24+
component='Icon'
25+
module='@clerk/ui/mosaic/components/icon'
26+
props={{ name: 'chevron-right' }}
27+
/>
28+
29+
---
30+
31+
## Examples
32+
33+
### Sizes
34+
35+
<Story
36+
name='Sizes'
37+
storyModule={IconStories}
38+
/>
39+
40+
### Names
41+
42+
<Story
43+
name='Names'
44+
storyModule={IconStories}
45+
/>
46+
47+
### Overriding a glyph
48+
49+
Pass `appearance.icons` to `MosaicProvider` to replace a glyph by name. The override is a function receiving the resolved props — Mosaic's sizing/color `className` and `data-cl-slot` — which you spread onto your own element. Because Mosaic's styling applies to the override too, the replacement only supplies its `viewBox` and paths.
50+
51+
<Story
52+
name='Override'
53+
storyModule={IconStories}
54+
/>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/** @jsxImportSource @emotion/react */
2+
import { Icon, iconRecipe } from '@clerk/ui/mosaic/components/icon';
3+
import type { IconProps } from '@clerk/ui/mosaic/components/icon';
4+
import { iconRegistry } from '@clerk/ui/mosaic/icons/registry';
5+
import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider';
6+
7+
import type { StoryMeta } from '@/lib/types';
8+
9+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
10+
// renders a code footer with its function's source. See `StoryModule.__source`.
11+
export { default as __source } from './icon.stories?raw';
12+
13+
export const meta: StoryMeta = {
14+
group: 'Components',
15+
title: 'Icon',
16+
source: 'packages/ui/src/mosaic/components/icon.tsx',
17+
styles: iconRecipe,
18+
};
19+
20+
// Story functions accept Record<string,unknown> (knob values) and cast to IconProps.
21+
// The cast is unavoidable: knobs are dynamically typed; Icon has a strict prop interface.
22+
// `name` is required and not a variant, so each story pins it.
23+
function knobsAsProps(props: Record<string, unknown>) {
24+
return props as unknown as IconProps;
25+
}
26+
27+
export function Default(props: Record<string, unknown>) {
28+
return (
29+
<Icon
30+
{...knobsAsProps(props)}
31+
name='chevron-right'
32+
/>
33+
);
34+
}
35+
36+
export function Sizes(props: Record<string, unknown>) {
37+
return (
38+
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
39+
<Icon
40+
{...knobsAsProps(props)}
41+
name='chevron-right'
42+
size='sm'
43+
/>
44+
<Icon
45+
{...knobsAsProps(props)}
46+
name='chevron-right'
47+
size='md'
48+
/>
49+
<Icon
50+
{...knobsAsProps(props)}
51+
name='chevron-right'
52+
size='lg'
53+
/>
54+
</div>
55+
);
56+
}
57+
58+
export function Names() {
59+
return (
60+
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
61+
{(Object.keys(iconRegistry) as Array<keyof typeof iconRegistry>).map(name => (
62+
<div
63+
key={name}
64+
style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'center', fontSize: 12 }}
65+
>
66+
<Icon
67+
name={name}
68+
size='lg'
69+
/>
70+
<code>{name}</code>
71+
</div>
72+
))}
73+
</div>
74+
);
75+
}
76+
77+
// Mosaic's styling (sizing/color) applies to the override just like the built-in glyph, so the
78+
// replacement only needs its viewBox + paths — spread `props` to receive the sizing className and
79+
// `data-cl-slot`.
80+
export function Override() {
81+
return (
82+
<MosaicProvider
83+
appearance={{
84+
icons: {
85+
'chevron-right': props => (
86+
<svg
87+
{...props}
88+
viewBox='0 0 20 20'
89+
fill='currentColor'
90+
>
91+
<circle
92+
cx={10}
93+
cy={10}
94+
r={6}
95+
/>
96+
</svg>
97+
),
98+
},
99+
}}
100+
>
101+
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
102+
<Icon name='chevron-right' />
103+
<Icon name='chevron-left' />
104+
</div>
105+
</MosaicProvider>
106+
);
107+
}

packages/ui/src/mosaic/MosaicProvider.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import createCache from '@emotion/cache';
44
import { CacheProvider } from '@emotion/react';
55
import React from 'react';
66

7-
import { MosaicAppearanceProvider, parseMosaicAppearance } from './appearance';
87
import type { MosaicAppearance } from './appearance';
9-
import { defaultMosaicVariables, resolveVariables } from './variables';
8+
import { MosaicAppearanceProvider, MosaicIconsProvider, parseMosaicAppearance } from './appearance';
109
import type { MosaicTheme } from './variables';
10+
import { defaultMosaicVariables, resolveVariables } from './variables';
1111

1212
const getInsertionPoint = (): HTMLElement | null => {
1313
if (typeof document === 'undefined') {
@@ -31,6 +31,7 @@ export interface MosaicProviderProps {
3131
export function MosaicProvider({ children, nonce, cssLayerName, appearance, scope }: MosaicProviderProps) {
3232
const theme = React.useMemo(() => resolveVariables(defaultMosaicVariables, appearance?.variables), [appearance]);
3333
const parsedElements = React.useMemo(() => parseMosaicAppearance(appearance, scope), [appearance, scope]);
34+
const icons = React.useMemo(() => appearance?.icons ?? {}, [appearance]);
3435
const cache = React.useMemo(() => {
3536
const el = getInsertionPoint();
3637
const emotionCache = createCache({
@@ -47,7 +48,9 @@ export function MosaicProvider({ children, nonce, cssLayerName, appearance, scop
4748
return (
4849
<MosaicThemeContext.Provider value={theme}>
4950
<MosaicAppearanceProvider value={parsedElements}>
50-
<CacheProvider value={cache}>{children}</CacheProvider>
51+
<MosaicIconsProvider value={icons}>
52+
<CacheProvider value={cache}>{children}</CacheProvider>
53+
</MosaicIconsProvider>
5154
</MosaicAppearanceProvider>
5255
</MosaicThemeContext.Provider>
5356
);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import type { MosaicAppearance } from '../appearance';
6+
import { Icon } from '../components/icon';
7+
import { MosaicProvider } from '../MosaicProvider';
8+
9+
const wrap = (ui: React.ReactElement, appearance?: MosaicAppearance) =>
10+
render(<MosaicProvider appearance={appearance}>{ui}</MosaicProvider>);
11+
12+
describe('Icon', () => {
13+
it('renders the default glyph for a known name', () => {
14+
const { container } = wrap(<Icon name='chevron-right' />);
15+
const svg = container.querySelector('svg[data-cl-slot="icon"]');
16+
expect(svg).not.toBeNull();
17+
expect(svg?.querySelector('path')).not.toBeNull();
18+
});
19+
20+
it('renders the override instead of the default glyph', () => {
21+
const appearance: MosaicAppearance = {
22+
icons: { 'chevron-right': props => <span {...props} data-testid='override' /> },
23+
};
24+
const { getByTestId, container } = wrap(<Icon name='chevron-right' />, appearance);
25+
expect(getByTestId('override')).not.toBeNull();
26+
// The default svg glyph is not rendered when overridden.
27+
expect(container.querySelector('svg')).toBeNull();
28+
});
29+
30+
it('applies Mosaic styling (className) and the slot attr to the override', () => {
31+
const appearance: MosaicAppearance = {
32+
icons: { 'chevron-right': props => <span {...props} data-testid='override' /> },
33+
};
34+
const { getByTestId } = wrap(<Icon name='chevron-right' size='lg' />, appearance);
35+
const el = getByTestId('override');
36+
expect(el.className).toBeTruthy();
37+
expect(el.getAttribute('data-cl-slot')).toBe('icon');
38+
});
39+
40+
it('applies appearance.elements.icon styling to an overridden glyph (consistent with the built-in)', () => {
41+
const appearance: MosaicAppearance = {
42+
elements: { icon: { opacity: 0.5 } },
43+
icons: { 'chevron-right': props => <span {...props} data-testid='override' /> },
44+
};
45+
const { getByTestId } = wrap(<Icon name='chevron-right' />, appearance);
46+
expect(getByTestId('override').className).toBeTruthy();
47+
});
48+
49+
it('falls through to the default when a different name is overridden', () => {
50+
const appearance: MosaicAppearance = {
51+
icons: { 'chevron-left': props => <span {...props} data-testid='override' /> },
52+
};
53+
const { container, queryByTestId } = wrap(<Icon name='chevron-right' />, appearance);
54+
expect(queryByTestId('override')).toBeNull();
55+
expect(container.querySelector('svg[data-cl-slot="icon"]')).not.toBeNull();
56+
});
57+
});

packages/ui/src/mosaic/appearance.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
11
import * as React from 'react';
22

3+
import type { IconName } from './icons/registry';
34
import type { MosaicElements } from './registry';
45
import type { MosaicVariables } from './variables';
56

7+
/**
8+
* The props handed to an icon override: the resolved `className` (Mosaic's sizing/color, applied to
9+
* the override exactly as to the built-in glyph, so icons stay visually consistent), `data-cl-slot`
10+
* for targeting, and any forwarded `svg` props. Spread them onto your element.
11+
*/
12+
export type MosaicIconRenderProps = React.ComponentPropsWithRef<'svg'> & { 'data-cl-slot'?: string };
13+
14+
/** Replaces a named icon's glyph. Spread the received props onto your element so Mosaic styling applies. */
15+
export type MosaicIconRenderer = (props: MosaicIconRenderProps) => React.ReactElement;
16+
17+
/** `appearance.icons`: per-name glyph overrides, applied globally. */
18+
export type MosaicIconOverrides = Partial<Record<IconName, MosaicIconRenderer>>;
19+
620
/**
721
* The flow-scope keys an `appearance` may carry. Overrides nested under one of these keys (inside
822
* `elements`) apply only inside the matching mounted flow — e.g. `appearance.elements.signIn.button`
@@ -40,6 +54,8 @@ export type MosaicScopedElements = MosaicElements & {
4054
export interface MosaicAppearance {
4155
variables?: MosaicVariables;
4256
elements?: MosaicScopedElements;
57+
/** Per-name icon glyph overrides (global). See `MosaicIconOverrides`. */
58+
icons?: MosaicIconOverrides;
4359
}
4460

4561
/**
@@ -56,6 +72,13 @@ export const MosaicAppearanceProvider = MosaicAppearanceContext.Provider;
5672
/** Returns the ordered element layers from the nearest `MosaicProvider` (or `[]` standalone). */
5773
export const useMosaicAppearance = (): ParsedMosaicElements => React.useContext(MosaicAppearanceContext);
5874

75+
const MosaicIconsContext = React.createContext<MosaicIconOverrides>({});
76+
77+
export const MosaicIconsProvider = MosaicIconsContext.Provider;
78+
79+
/** Returns the icon glyph overrides from the nearest `MosaicProvider` (or `{}` standalone). */
80+
export const useMosaicIcons = (): MosaicIconOverrides => React.useContext(MosaicIconsContext);
81+
5982
/**
6083
* Flattens `appearance.elements` into ordered layers for the active `scope`: the global slot
6184
* overrides (scope keys stripped out) first, then the scoped overrides, so scoped wins by order.

0 commit comments

Comments
 (0)