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,