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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/component-icon-mappings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astryxdesign/core': patch
'@astryxdesign/cli': patch
---

[feature] Add themeable component icon slots so components can map semantic purposes to icon registry entries.

@cixzhang
8 changes: 8 additions & 0 deletions apps/docsite/scripts/generate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,13 @@ export interface ThemingTarget {
states?: string[];
}
export interface ComponentIconSlotDoc {
slot: string;
default: string | null;
description: string;
}
export interface ComponentVar {
name: string;
description: string;
Expand All @@ -760,6 +767,7 @@ export interface DerivedVar {
export interface ThemingDoc {
container?: boolean;
targets: ThemingTarget[];
icons?: ComponentIconSlotDoc[];
vars?: ComponentVar[];
derived?: DerivedVar[];
}
Expand Down
103 changes: 100 additions & 3 deletions apps/docsite/src/components/component-detail/Theming.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,88 @@ function TargetsTable({targets, props}: TargetsTableProps) {
);
}

interface IconSlotDoc {
slot: string;
default: string | null;
description: string;
}

interface IconSlotsTableProps {
icons: IconSlotDoc[];
}

function IconSlotsTable({icons}: IconSlotsTableProps) {
const isMobile = useMediaQuery('(max-width: 768px)');

if (isMobile) {
return (
<VStack gap={0}>
{icons.map(icon => (
<Fragment key={icon.slot}>
<Divider />
<VStack gap={1} style={{paddingBlock: 8}}>
<Text type="code" weight="bold">
{icon.slot}
</Text>
<Text type="code" color="secondary">
{icon.default ?? 'none'}
</Text>
<MarkdownText type="body" color="secondary">
{icon.description}
</MarkdownText>
</VStack>
</Fragment>
))}
</VStack>
);
}

const data = icons.map(icon => ({
slot: icon.slot as unknown,
fallback: (icon.default ?? 'none') as unknown,
description: icon.description as unknown,
})) as Record<string, unknown>[];

return (
<Table
data={data}
columns={[
{
key: 'slot',
header: 'Slot',
width: pixel(260),
renderCell: (item: Record<string, unknown>) => (
<Text type="code" weight="bold" style={{whiteSpace: 'nowrap'}}>
{item.slot as string}
</Text>
),
},
{
key: 'fallback',
header: 'Default icon',
width: pixel(160),
renderCell: (item: Record<string, unknown>) => (
<Text type="code" color="secondary">
{item.fallback as string}
</Text>
),
},
{
key: 'description',
header: 'Description',
renderCell: (item: Record<string, unknown>) => (
<MarkdownText type="body">
{item.description as string}
</MarkdownText>
),
},
]}
density="spacious"
dividers="rows"
/>
);
}

interface CssVarsTableProps {
vars: ComponentVar[];
}
Expand Down Expand Up @@ -262,8 +344,10 @@ export function Theming({theming, props}: ThemingProps) {
const hasTargets = theming.targets.length > 0;
const vars = publicVars(theming);
const hasVars = vars.length > 0;
const iconSlots = theming.icons ?? [];
const hasIconSlots = iconSlots.length > 0;

if (!hasTargets && !hasVars) {
if (!hasTargets && !hasVars && !hasIconSlots) {
return null;
}

Expand All @@ -284,8 +368,8 @@ export function Theming({theming, props}: ThemingProps) {
/>
<Text type="large" weight="normal">
Restyle this component with a <Text type="code">defineTheme</Text>{' '}
config. Target the component through the keys below, or override the
CSS variables it exposes.
config. Target the component through the keys below, map component
icon slots, or override the CSS variables it exposes.
</Text>
</VStack>

Expand All @@ -310,6 +394,19 @@ export function Theming({theming, props}: ThemingProps) {
</VStack>
)}

{hasIconSlots && (
<VStack gap={3}>
<Heading level={3}>Component icon slots</Heading>
<Text color="secondary">
These semantic slots map component-specific purposes to global
icon names with <Text type="code">defineTheme</Text>{' '}
<Text type="code">componentIcons</Text>. Use{' '}
<Text type="code">null</Text> to intentionally render no icon.
</Text>
<IconSlotsTable icons={iconSlots} />
</VStack>
)}

{hasVars && (
<VStack gap={3}>
<Heading level={3}>Themeable CSS variables</Heading>
Expand Down
26 changes: 26 additions & 0 deletions apps/storybook/stories/Selector.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -837,3 +837,29 @@ export const ThemedIcons: Story = {
);
},
};

const selectorComponentIconTheme = defineTheme({
name: 'selector-component-icon-demo',
icons: {
success: <span style={{fontSize: 14, lineHeight: 1}}>◆</span>,
},
componentIcons: {
'selector-selected-option': 'success',
},
});

export const ComponentIconMapping: Story = {
render: () => {
const [value, setValue] = useState<string | undefined>('Banana');
return (
<Theme theme={selectorComponentIconTheme} mode="light">
<Selector
label="Selected option uses a component icon slot"
options={['Apple', 'Banana', 'Cherry']}
value={value}
onChange={setValue}
/>
</Theme>
);
},
};
11 changes: 10 additions & 1 deletion packages/cli/api/theme/build/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,15 @@ function generateBuiltModule(themeDef, iconInfo) {
? `import { ${iconInfo.exportName} } from '${iconInfo.importPath}';\n`
: '';
const iconsField = iconInfo ? ` icons: ${iconInfo.exportName},` : '';
const iconReExport = iconInfo ? `\nexport { ${iconInfo.exportName} };\n` : '';
const componentIconsField = themeDef.componentIcons
? ` componentIcons: ${JSON.stringify(themeDef.componentIcons, null, 2)
.split('\n')
.map((line, i) => (i === 0 ? line : ' ' + line))
.join('\n')},`
: '';
const iconReExport = iconInfo ? `
export { ${iconInfo.exportName} };
` : '';

// Resolve token values — tuples become light-dark() strings
/** @type {Record<string, unknown>} */
Expand Down Expand Up @@ -763,6 +771,7 @@ export const ${toIdentifier(themeDef.name)}Theme = {
__built: true,
tokens: ${tokensStr},
${iconsField}
${componentIconsField}
};
${iconReExport}`;
}
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/api/theme/build/build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ describe('themeBuild() — receipt', () => {
outSpy.mockRestore();
}
});


it('preserves component icon mappings in the built theme object', async () => {
const themeFile = path.join(tmpDir, 'component-icons.mjs');
fs.writeFileSync(
themeFile,
`export default {
name: 'component-icons',
tokens: { '--color-bg': '#fff' },
componentIcons: { 'selector-selected-option': 'success' },
};
`,
);

const result = await themeBuild('component-icons.mjs', {}, {cwd: tmpDir});

expect(result?.type).toBe('theme.build');
const js = fs.readFileSync(path.join(tmpDir, 'component-icons.js'), 'utf-8');
expect(js).toContain('componentIcons');
expect(js).toContain("selector-selected-option");
expect(js).toContain("success");
});

});

describe('themeBuild() — nothing to build', () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/authoring/doctypes/base/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,26 @@ export interface ComponentThemingTarget {
states?: string[];
}

/**
* Documents a themeable component icon slot.
*
* Component icon slots map a component-specific purpose to a global icon
* registry name through defineTheme({componentIcons}).
*
* @example
* ```
* {slot: 'selector-selected-option', default: 'check', description: 'Icon shown next to the selected option.'}
* ```
*/
export interface ComponentIconSlotDoc {
/** Component-specific icon slot name used in `componentIcons`. */
slot: string;
/** Default global icon name used when the theme does not map this slot. */
default: string | null;
/** What this slot represents semantically. */
description: string;
}

/**
* Documents a CSS custom property exposed by a component for theming.
* These vars are set on the component's root element and can be overridden
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/authoring/doctypes/component/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ComponentExampleDoc,
ComponentPlaygroundConfig,
ComponentPropDoc,
ComponentIconSlotDoc,
ComponentThemingDerivedVar,
ComponentThemingTarget,
ComponentThemingVar,
Expand Down Expand Up @@ -110,6 +111,8 @@ export interface ComponentBaseDoc {
/** Selector targets rendered by this component.
* Each entry corresponds to an `themeProps()` call in the source. */
targets: ComponentThemingTarget[];
/** Component-specific icon slots exposed for theme icon mapping. */
icons?: ComponentIconSlotDoc[];
/** CSS custom properties exposed for theming. */
vars?: ComponentThemingVar[];
/** Maps standard CSS properties to internal vars for theme pipeline
Expand Down
1 change: 1 addition & 0 deletions packages/cli/authoring/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type {
ComponentBestPractice,
ComponentSlotElement,
ComponentPlaygroundConfig,
ComponentIconSlotDoc,
ComponentThemingTarget,
ComponentThemingVar,
ComponentThemingDerivedVar,
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/clients/cli/lib/component-format.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,25 @@ function formatTargetsTable(docs, themeData) {
return lines.join('\n');
}


/** @param {any} docs */
function formatComponentIconSlotsTable(docs) {
if (!docs.theming?.icons?.length) return '';

const lines = [];
lines.push('| Slot | Default icon | Description |');
lines.push('|------|--------------|-------------|');

for (const icon of docs.theming.icons) {
const fallback = icon.default == null ? 'none' : icon.default;
lines.push(
`| \`${mdCell(icon.slot)}\` | \`${mdCell(fallback)}\` | ${mdCell(icon.description || '')} |`,
);
}

return lines.join('\n');
}

/**
* Format full component docs (default mode, replaces cleanReadme).
*
Expand Down Expand Up @@ -300,6 +319,11 @@ export function formatFull(docs, options = {}) {
sections.push(exampleLines.join('\n'));
}

if (docs.theming.icons?.length) {
sections.push('**Component icon slots** — override these through `defineTheme({ componentIcons })`.\n');
sections.push(formatComponentIconSlotsTable(docs) + '\n');
}

// Legacy componentKey (for backward compatibility)
if (docs.theming.componentKey) {
sections.push(`Component key: \`${docs.theming.componentKey}\`\n`);
Expand Down Expand Up @@ -520,6 +544,14 @@ export function formatBrief(docs, componentName, importHint, options = {}) {
output.push(` ${shortDesc}`);
}

// Component icon slots (if any)
if (docs.theming?.icons?.length) {
const slots = docs.theming.icons
.map((/** @type {any} */ icon) => `${icon.slot}→${icon.default ?? 'none'}`)
.join(', ');
output.push(` Icon slots: ${slots}`);
}

// Component vars (if any — only show public vars)
if (docs.theming?.vars?.length) {
const varNames = docs.theming.vars
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/clients/cli/lib/component-format.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,31 @@ describe('formatFull theming override keys', () => {
expect(out).not.toContain("'astryx-base-table': {");
expect(out).not.toContain("'astryx-table-cell': {");
});


it('prints component icon slots in theming docs', () => {
const docs = {
name: 'Selector',
description: 'A selector.',
theming: {
targets: [{className: 'astryx-selector'}],
icons: [
{
slot: 'selector-selected-option',
default: 'check',
description: 'Icon shown for the selected option.',
},
],
},
};
const out = formatFull(docs);

expect(out).toContain('Component icon slots');
expect(out).toContain('`selector-selected-option`');
expect(out).toContain('`check`');
expect(out).toContain('defineTheme({ componentIcons })');
});

});

/** Pipes that actually separate cells — a `\|` is content, not a separator. */
Expand Down
Loading
Loading