From 61535c14175ee10f5a936bc5eae04afe20dc6b23 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 08:10:09 +0000 Subject: [PATCH] feat(theme): add component icon mappings --- .changeset/component-icon-mappings.md | 8 ++ apps/docsite/scripts/generate-data.mjs | 8 ++ .../components/component-detail/Theming.tsx | 103 +++++++++++++++++- apps/storybook/stories/Selector.stories.tsx | 26 +++++ packages/cli/api/theme/build/build.mjs | 11 +- packages/cli/api/theme/build/build.test.mjs | 23 ++++ packages/cli/authoring/doctypes/base/type.ts | 20 ++++ .../cli/authoring/doctypes/component/type.ts | 3 + packages/cli/authoring/index.d.ts | 1 + .../cli/clients/cli/lib/component-format.mjs | 32 ++++++ .../clients/cli/lib/component-format.test.mjs | 25 +++++ packages/core/src/Icon/Icon.doc.mjs | 12 ++ packages/core/src/Icon/Icon.tsx | 26 ++++- .../core/src/Icon/globalIconRegistry.test.tsx | 34 ++++++ packages/core/src/Icon/globalIconRegistry.tsx | 61 ++++++++++- packages/core/src/Icon/index.ts | 5 + packages/core/src/Icon/useIcon.ts | 40 ++++++- packages/core/src/Selector/Selector.doc.mjs | 8 ++ packages/core/src/Selector/Selector.test.tsx | 49 +++++++++ packages/core/src/Selector/Selector.tsx | 15 +-- packages/core/src/theme/defineTheme.test.ts | 23 ++++ packages/core/src/theme/defineTheme.ts | 17 ++- packages/core/src/theme/index.ts | 6 + 23 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 .changeset/component-icon-mappings.md diff --git a/.changeset/component-icon-mappings.md b/.changeset/component-icon-mappings.md new file mode 100644 index 000000000000..f99a4d6b0070 --- /dev/null +++ b/.changeset/component-icon-mappings.md @@ -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 diff --git a/apps/docsite/scripts/generate-data.mjs b/apps/docsite/scripts/generate-data.mjs index 3579f952f41d..94995a5b44bc 100644 --- a/apps/docsite/scripts/generate-data.mjs +++ b/apps/docsite/scripts/generate-data.mjs @@ -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; @@ -760,6 +767,7 @@ export interface DerivedVar { export interface ThemingDoc { container?: boolean; targets: ThemingTarget[]; + icons?: ComponentIconSlotDoc[]; vars?: ComponentVar[]; derived?: DerivedVar[]; } diff --git a/apps/docsite/src/components/component-detail/Theming.tsx b/apps/docsite/src/components/component-detail/Theming.tsx index 29f4abd36141..e43bfa13a82b 100644 --- a/apps/docsite/src/components/component-detail/Theming.tsx +++ b/apps/docsite/src/components/component-detail/Theming.tsx @@ -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 ( + + {icons.map(icon => ( + + + + + {icon.slot} + + + {icon.default ?? 'none'} + + + {icon.description} + + + + ))} + + ); + } + + const data = icons.map(icon => ({ + slot: icon.slot as unknown, + fallback: (icon.default ?? 'none') as unknown, + description: icon.description as unknown, + })) as Record[]; + + return ( + ) => ( + + {item.slot as string} + + ), + }, + { + key: 'fallback', + header: 'Default icon', + width: pixel(160), + renderCell: (item: Record) => ( + + {item.fallback as string} + + ), + }, + { + key: 'description', + header: 'Description', + renderCell: (item: Record) => ( + + {item.description as string} + + ), + }, + ]} + density="spacious" + dividers="rows" + /> + ); +} + interface CssVarsTableProps { vars: ComponentVar[]; } @@ -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; } @@ -284,8 +368,8 @@ export function Theming({theming, props}: ThemingProps) { /> Restyle this component with a defineTheme{' '} - 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. @@ -310,6 +394,19 @@ export function Theming({theming, props}: ThemingProps) { )} + {hasIconSlots && ( + + Component icon slots + + These semantic slots map component-specific purposes to global + icon names with defineTheme{' '} + componentIcons. Use{' '} + null to intentionally render no icon. + + + + )} + {hasVars && ( Themeable CSS variables diff --git a/apps/storybook/stories/Selector.stories.tsx b/apps/storybook/stories/Selector.stories.tsx index 53a6e8a8e8fc..9177870e2532 100644 --- a/apps/storybook/stories/Selector.stories.tsx +++ b/apps/storybook/stories/Selector.stories.tsx @@ -837,3 +837,29 @@ export const ThemedIcons: Story = { ); }, }; + +const selectorComponentIconTheme = defineTheme({ + name: 'selector-component-icon-demo', + icons: { + success: , + }, + componentIcons: { + 'selector-selected-option': 'success', + }, +}); + +export const ComponentIconMapping: Story = { + render: () => { + const [value, setValue] = useState('Banana'); + return ( + + + + ); + }, +}; diff --git a/packages/cli/api/theme/build/build.mjs b/packages/cli/api/theme/build/build.mjs index d2c5ff25e4dc..cdc899bc5011 100644 --- a/packages/cli/api/theme/build/build.mjs +++ b/packages/cli/api/theme/build/build.mjs @@ -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} */ @@ -763,6 +771,7 @@ export const ${toIdentifier(themeDef.name)}Theme = { __built: true, tokens: ${tokensStr}, ${iconsField} +${componentIconsField} }; ${iconReExport}`; } diff --git a/packages/cli/api/theme/build/build.test.mjs b/packages/cli/api/theme/build/build.test.mjs index 1d27f7895d36..3c802b35fdef 100644 --- a/packages/cli/api/theme/build/build.test.mjs +++ b/packages/cli/api/theme/build/build.test.mjs @@ -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', () => { diff --git a/packages/cli/authoring/doctypes/base/type.ts b/packages/cli/authoring/doctypes/base/type.ts index ef545e6078de..e5970347611c 100644 --- a/packages/cli/authoring/doctypes/base/type.ts +++ b/packages/cli/authoring/doctypes/base/type.ts @@ -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 diff --git a/packages/cli/authoring/doctypes/component/type.ts b/packages/cli/authoring/doctypes/component/type.ts index a802e450b28a..fd6bda83a12f 100644 --- a/packages/cli/authoring/doctypes/component/type.ts +++ b/packages/cli/authoring/doctypes/component/type.ts @@ -10,6 +10,7 @@ import type { ComponentExampleDoc, ComponentPlaygroundConfig, ComponentPropDoc, + ComponentIconSlotDoc, ComponentThemingDerivedVar, ComponentThemingTarget, ComponentThemingVar, @@ -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 diff --git a/packages/cli/authoring/index.d.ts b/packages/cli/authoring/index.d.ts index 1bcb0ba77cc4..55d9615578bd 100644 --- a/packages/cli/authoring/index.d.ts +++ b/packages/cli/authoring/index.d.ts @@ -62,6 +62,7 @@ export type { ComponentBestPractice, ComponentSlotElement, ComponentPlaygroundConfig, + ComponentIconSlotDoc, ComponentThemingTarget, ComponentThemingVar, ComponentThemingDerivedVar, diff --git a/packages/cli/clients/cli/lib/component-format.mjs b/packages/cli/clients/cli/lib/component-format.mjs index 8585ee5698b6..268de9f179f6 100644 --- a/packages/cli/clients/cli/lib/component-format.mjs +++ b/packages/cli/clients/cli/lib/component-format.mjs @@ -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). * @@ -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`); @@ -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 diff --git a/packages/cli/clients/cli/lib/component-format.test.mjs b/packages/cli/clients/cli/lib/component-format.test.mjs index d8dc736da23b..c9e38a46b16a 100644 --- a/packages/cli/clients/cli/lib/component-format.test.mjs +++ b/packages/cli/clients/cli/lib/component-format.test.mjs @@ -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. */ diff --git a/packages/core/src/Icon/Icon.doc.mjs b/packages/core/src/Icon/Icon.doc.mjs index 8e798bdf7fe3..bfc0c79d1c8b 100644 --- a/packages/core/src/Icon/Icon.doc.mjs +++ b/packages/core/src/Icon/Icon.doc.mjs @@ -34,6 +34,12 @@ export const docs = { description: 'Icon size.', default: "'md'", }, + { + name: 'fallbackIcon', + type: 'IconName | null', + description: + 'Fallback global icon name when icon is a component-specific icon slot. Components use this for semantic purposes like selector-selected-option while letting themes remap through defineTheme({componentIcons}). Set null when the component slot defaults to no icon. Valid semantic names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone.', + }, { name: 'label', type: 'string', @@ -92,6 +98,12 @@ export const docsZh = { description: '图标尺寸。', default: "'md'", }, + { + name: 'fallbackIcon', + type: 'IconName | null', + description: + 'Fallback global icon name when icon is a component-specific icon slot. Components use this for semantic purposes like selector-selected-option while letting themes remap through defineTheme({componentIcons}). Set null when the component slot defaults to no icon. Valid semantic names: close, chevronDown, chevronLeft, chevronRight, check, success, error, warning, info, calendar, clock, externalLink, menu, moreHorizontal, search, arrowUp, arrowDown, arrowsUpDown, funnel, eyeSlash, viewColumns, copy, checkDouble, wrench, stop, microphone.', + }, { name: 'label', type: 'string', diff --git a/packages/core/src/Icon/Icon.tsx b/packages/core/src/Icon/Icon.tsx index 9390ed7108cb..cbefff4417b9 100644 --- a/packages/core/src/Icon/Icon.tsx +++ b/packages/core/src/Icon/Icon.tsx @@ -27,8 +27,8 @@ import * as stylex from '@stylexjs/stylex'; import type {StyleXStyles} from '@stylexjs/stylex'; import {colorVars} from '../theme/tokens.stylex'; import {useThemeName} from '../theme/useTheme'; -import {getIcon} from './globalIconRegistry'; -import type {IconName} from './globalIconRegistry'; +import {getComponentIcon, getIcon} from './globalIconRegistry'; +import type {ComponentIconSlotName, IconName} from './globalIconRegistry'; import {mergeProps} from '../utils'; import {themeProps} from '../utils/themeProps'; @@ -201,7 +201,7 @@ export interface IconProps extends Omit< * - A semantic name string (e.g. 'close', 'chevronDown') — resolved from theme or built-in fallback * - An SVG icon component (e.g. from @heroicons/react) — rendered directly */ - icon: IconType | IconName; + icon: IconType | IconName | ComponentIconSlotName; /** * The color variant of the icon. * @default 'inherit' @@ -246,6 +246,15 @@ export interface IconProps extends Omit< * ``` */ label?: string; + /** + * Fallback global icon name when `icon` is a component-specific slot. + * + * Components use this to render semantic purposes like + * `selector-selected-option` while letting themes remap that purpose through + * `defineTheme({componentIcons})`. Set to `null` when the default for the + * component slot is intentionally no icon. + */ + fallbackIcon?: IconName | null; /** * StyleX styles created via `stylex.create()`. Folded into the icon's own * `stylex.props()` call (as the last argument) so it merges with the base @@ -297,6 +306,7 @@ export function Icon({ color = 'inherit', size = 'md', label, + fallbackIcon, ref, className, style, @@ -312,6 +322,7 @@ export function Icon({ return ( , 'ref' | 'color'>; }) { const themeName = useThemeName(); - const resolvedIcon = getIcon(name, themeName); + const resolvedIcon = + fallbackIcon !== undefined + ? getComponentIcon(name as ComponentIconSlotName, fallbackIcon, themeName) + : getIcon(name, themeName); if (resolvedIcon == null) { return null; diff --git a/packages/core/src/Icon/globalIconRegistry.test.tsx b/packages/core/src/Icon/globalIconRegistry.test.tsx index 84f5d7bc213c..047bb897d62d 100644 --- a/packages/core/src/Icon/globalIconRegistry.test.tsx +++ b/packages/core/src/Icon/globalIconRegistry.test.tsx @@ -10,6 +10,8 @@ import { getIconRegistry, getIcon, getExtendedIcon, + getComponentIcon, + getComponentIconName, resetIcons, } from './globalIconRegistry'; @@ -117,6 +119,38 @@ describe('iconRegistry (global, RSC-compatible)', () => { expect(getIcon('check', theme)).toBe('theme-check'); }); + it('resolves component icon slots from a registered theme name', () => { + defineTheme({ + name: 'brand', + icons: {success: 'theme-success'}, + componentIcons: {'selector-selected-option': 'success'}, + }); + + expect( + getComponentIconName('selector-selected-option', 'check', 'brand'), + ).toBe('success'); + expect(getComponentIcon('selector-selected-option', 'check', 'brand')).toBe( + 'theme-success', + ); + }); + + it('falls back for unmapped component icon slots and honors null mappings', () => { + const theme = defineTheme({ + name: 'brand', + componentIcons: {'selector-selected-option': null}, + }); + + expect(getComponentIconName('selector-selected-option', 'check')).toBe( + 'check', + ); + expect( + getComponentIconName('selector-selected-option', 'check', theme), + ).toBe(null); + expect( + getComponentIcon('selector-selected-option', 'check', theme), + ).toBeNull(); + }); + it('resetIcons clears the global registry', () => { registerIcons({close: 'custom'}); expect(getIcon('close')).toBe('custom'); diff --git a/packages/core/src/Icon/globalIconRegistry.tsx b/packages/core/src/Icon/globalIconRegistry.tsx index 03dfe86d98ee..0dc22ec6591f 100644 --- a/packages/core/src/Icon/globalIconRegistry.tsx +++ b/packages/core/src/Icon/globalIconRegistry.tsx @@ -71,6 +71,19 @@ export type ExtendedIconName = IconName | (string & {}); */ export type IconRegistry = Record; +/** + * Semantic component icon slots. Core declares slots for built-in components; + * external component packages can add their own slots with module augmentation. + */ +export interface ComponentIconSlotMap { + 'selector-selected-option': true; +} + +export type ComponentIconSlotName = keyof ComponentIconSlotMap & string; +export type ComponentIconMap = Partial< + Record +>; + export type IconRegistrySource = DefinedTheme | string | null | undefined; // ============================================================================= @@ -79,18 +92,22 @@ export type IconRegistrySource = DefinedTheme | string | null | undefined; let globalRegistry: Record = {}; -function getThemeIconOverrides( - source: IconRegistrySource, -): Partial | null { +function getTheme(source: IconRegistrySource): DefinedTheme | null { if (source == null) { return null; } if (typeof source === 'string') { - return getRegisteredTheme(source)?.icons ?? null; + return getRegisteredTheme(source); } - return source.icons ?? null; + return source; +} + +function getThemeIconOverrides( + source: IconRegistrySource, +): Partial | null { + return getTheme(source)?.icons ?? null; } /** @@ -209,6 +226,40 @@ export function getExtendedIcon( ); } +/** + * Resolve a component-specific semantic slot to a concrete icon name. + * + * The mapping is theme-scoped: `componentIcons[slot]` chooses which global icon + * name should represent the component purpose. `undefined` falls back to the + * component default; `null` intentionally renders no icon. + */ +export function getComponentIconName( + slot: ComponentIconSlotName, + fallback: IconName | null, + source?: IconRegistrySource, +): IconName | null { + const theme = getTheme(source); + const mapped = theme?.componentIcons?.[slot]; + + if (mapped === undefined) { + return fallback; + } + + return mapped; +} + +/** + * Resolve a component-specific semantic slot to a concrete icon node. + */ +export function getComponentIcon( + slot: ComponentIconSlotName, + fallback: IconName | null, + source?: IconRegistrySource, +): ReactNode { + const iconName = getComponentIconName(slot, fallback, source); + return iconName == null ? null : getIcon(iconName, source); +} + /** * Reset the global registry. For testing only. * @internal diff --git a/packages/core/src/Icon/index.ts b/packages/core/src/Icon/index.ts index 4dbff802660a..d83b6e66a0b9 100644 --- a/packages/core/src/Icon/index.ts +++ b/packages/core/src/Icon/index.ts @@ -21,6 +21,8 @@ export { getIconRegistry, getIcon, getExtendedIcon, + getComponentIcon, + getComponentIconName, resetIcons, } from './globalIconRegistry'; export type { @@ -28,4 +30,7 @@ export type { ExtendedIconName, IconRegistry, IconRegistrySource, + ComponentIconSlotMap, + ComponentIconSlotName, + ComponentIconMap, } from './globalIconRegistry'; diff --git a/packages/core/src/Icon/useIcon.ts b/packages/core/src/Icon/useIcon.ts index e3302dadb731..b82c84b076ab 100644 --- a/packages/core/src/Icon/useIcon.ts +++ b/packages/core/src/Icon/useIcon.ts @@ -11,13 +11,43 @@ import type {ReactNode} from 'react'; import {useThemeName} from '../theme/useTheme'; -import {getIcon, type IconName} from './globalIconRegistry'; +import { + getComponentIcon, + getIcon, + type ComponentIconSlotName, + type IconName, +} from './globalIconRegistry'; /** - * Resolve a semantic icon name from the nearest Theme, falling back through - * root theme registration, global icon overrides, and built-in defaults. + * Resolve a global semantic icon name from the nearest Theme. */ -export function useIcon(name: IconName): ReactNode { +export function useIcon(name: IconName): ReactNode; + +/** + * Resolve a component-specific semantic icon slot from the nearest Theme. + * + * `fallback` is the component's default global icon name. A theme can remap + * the slot through `defineTheme({componentIcons})`; mapping the slot to `null` + * intentionally renders no icon. + */ +export function useIcon( + slot: ComponentIconSlotName, + fallback: IconName | null, +): ReactNode; + +export function useIcon( + nameOrSlot: IconName | ComponentIconSlotName, + fallback?: IconName | null, +): ReactNode { const themeName = useThemeName(); - return getIcon(name, themeName); + + if (arguments.length === 2) { + return getComponentIcon( + nameOrSlot as ComponentIconSlotName, + fallback ?? null, + themeName, + ); + } + + return getIcon(nameOrSlot, themeName); } diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index 4a35914d411a..8c2c9719ec48 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -29,6 +29,14 @@ export const docs = { {className: 'astryx-selector-clear-icon'}, {className: 'astryx-selector-indicator-icon', states: ['state']}, ], + icons: [ + { + slot: 'selector-selected-option', + default: 'check', + description: + 'Icon shown at the end of the currently selected option in the listbox. Map to another global icon name, or null to hide it.', + }, + ], }, description: 'Dropdown selector for choosing from a list of options.', props: [ diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 1b59e0561d82..2f7bbbb0876c 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -23,6 +23,7 @@ import {SelectorOption} from './SelectorOption'; import {Icon} from '../Icon'; import {InputGroup, InputGroupText} from '../InputGroup'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; +import {Theme} from '../theme/Theme'; import {defineTheme} from '../theme/defineTheme'; import {generateThemeCSSFlat} from '../theme/generateThemeRules'; @@ -148,6 +149,54 @@ describe('Selector', () => { expect(screen.getByRole('combobox')).toHaveTextContent('Banana'); }); + it('resolves the selected-option icon through the component icon slot', () => { + const theme = defineTheme({ + name: 'selector-selected-option-icon-test', + icons: {success: 'selected-slot-icon'}, + componentIcons: {'selector-selected-option': 'success'}, + }); + + render( + + {}} + /> + , + ); + + const selectedOption = document.querySelector('[aria-selected="true"]'); + expect(selectedOption).not.toBeNull(); + expect(selectedOption).toHaveTextContent('Banana'); + expect(selectedOption).toHaveTextContent('selected-slot-icon'); + }); + + it('allows a theme to remove the selected-option icon slot', () => { + const theme = defineTheme({ + name: 'selector-selected-option-null-test', + componentIcons: {'selector-selected-option': null}, + }); + + render( + + {}} + /> + , + ); + + const selectedOption = screen.getByRole('option', { + name: /Banana/, + hidden: true, + }); + expect(selectedOption.querySelector('.astryx-icon')).toBeNull(); + }); + it('renders custom option endContent', async () => { const user = userEvent.setup(); render( diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index dee927d5f57a..e5334d39f742 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -306,12 +306,6 @@ const styles = stylex.create({ flex: 1, minWidth: 0, }, - itemCheckmark: { - flexShrink: 0, - width: 16, - height: 16, - color: colorVars['--color-icon-primary'], - }, itemHighlighted: { backgroundColor: colorVars['--color-overlay-hover'], }, @@ -1049,7 +1043,14 @@ export function Selector( )} - {isSelected && } + {isSelected && ( + + )} ); }, diff --git a/packages/core/src/theme/defineTheme.test.ts b/packages/core/src/theme/defineTheme.test.ts index 8004818af6c7..4418c15dd0c0 100644 --- a/packages/core/src/theme/defineTheme.test.ts +++ b/packages/core/src/theme/defineTheme.test.ts @@ -1071,6 +1071,29 @@ describe('defineTheme extends', () => { }); }); + it('preserves component icon mappings', () => { + const theme = defineTheme({ + name: 'icons', + componentIcons: {'selector-selected-option': 'check'}, + }); + + expect(theme.componentIcons?.['selector-selected-option']).toBe('check'); + }); + + it('merges component icon mappings — child overrides base including null', () => { + const base = defineTheme({ + name: 'base', + componentIcons: {'selector-selected-option': 'check'}, + }); + const child = defineTheme({ + name: 'child', + extends: base, + componentIcons: {'selector-selected-option': null}, + }); + + expect(child.componentIcons?.['selector-selected-option']).toBeNull(); + }); + it('merges icons — child overrides base', () => { const baseIcons = {close: 'X', menu: 'M'} as Partial; const childIcons = {close: 'Y'} as Partial; diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index f0c713c32977..ce106c8dd5f0 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -30,7 +30,7 @@ * ``` */ -import type {IconRegistry} from '../Icon/globalIconRegistry'; +import type {ComponentIconMap, IconRegistry} from '../Icon/globalIconRegistry'; import type {TypographyConfig, FontWeight} from './types'; import { resolveOnMedia, @@ -282,6 +282,11 @@ export interface DefineThemeInput { components?: ComponentStyleMap; /** Icon registry — maps semantic icon names to React nodes */ icons?: Partial; + /** + * Component icon slot mappings — maps component-specific purposes to global + * semantic icon names. Use `null` to intentionally render no icon. + */ + componentIcons?: ComponentIconMap; /** * Default syntax highlighting theme for code components. * Sets --color-syntax-* tokens at the theme root. Can be overridden @@ -331,6 +336,8 @@ export interface DefinedTheme { components?: ComponentStyleMap; /** Icon registry */ icons?: Partial; + /** Component icon slot mappings */ + componentIcons?: ComponentIconMap; /** Whether this theme has been pre-compiled by theme build CLI */ __built?: true; /** @@ -625,11 +632,19 @@ export function defineTheme(input: DefineThemeInput): DefinedTheme { ? {...base.icons, ...input.icons} : (input.icons ?? base?.icons); + // 6. Merge component icon mappings — input mappings override base mappings. + // `null` is an intentional override meaning “render no icon”. + const componentIcons = + input.componentIcons && base?.componentIcons + ? {...base.componentIcons, ...input.componentIcons} + : (input.componentIcons ?? base?.componentIcons); + const theme: DefinedTheme = { name: input.name, tokens, components, icons, + componentIcons, __inputTokens: input.tokens, __onDark, __onLight, diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index e2230431f0bf..8e060a077d3a 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -134,6 +134,12 @@ export type { export {useTheme, useThemeName, ThemeContext} from './useTheme'; export type {UseThemeReturn, ThemeContextValue} from './useTheme'; + +export type { + ComponentIconMap, + ComponentIconSlotMap, + ComponentIconSlotName, +} from '../Icon'; export { resolveThemeToken, resolveThemeTokens,