feat(editor): Rewrite icon picker with search, full Lucide set, emoji sections, and color/skin tone pickers (#25649)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jan
2026-06-16 10:55:15 +02:00
committed by GitHub
parent e978fa8135
commit 12ba0d0080
48 changed files with 14412 additions and 394 deletions
+5
View File
@@ -64,6 +64,10 @@ packages/cli/THIRD_PARTY_LICENSES.md
.coverage
.nyc_output
coverage-gaps.json
# Local regeneration cache for scripts/generate-lucide-icon-data.mjs (committed output: lucideIconData.ts)
scripts/.lucide-tags-cache.json
packages/cli/src/commands/export/outputs
*.bak
.data
@@ -77,5 +81,6 @@ packages/cli/src/commands/export/outputs
.conductor
.n8n
lefthook-local.yml
IMPROVEMENTS.md
.playwright-mcp
stryker.log
@@ -9,6 +9,7 @@ export type ProjectType = z.infer<typeof projectTypeSchema>;
export const projectIconSchema = z.object({
type: z.enum(['emoji', 'icon']),
value: z.string().min(1),
color: z.string().optional(),
});
export type ProjectIcon = z.infer<typeof projectIconSchema>;
@@ -2,6 +2,10 @@
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../../biome.jsonc"],
"formatter": {
"ignore": ["theme/**"]
"ignore": [
"theme/**",
"src/components/N8nIconPicker/emojiData.ts",
"src/components/N8nIconPicker/lucideIconData.ts"
]
}
}
@@ -23,6 +23,7 @@
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache"
},
"devDependencies": {
"@iconify/json": "^2.2.349",
"@n8n/eslint-config": "workspace:*",
"@n8n/playwright-janitor": "workspace:*",
"@n8n/stylelint-config": "workspace:*",
@@ -41,6 +42,7 @@
"@vitest/coverage-v8": "catalog:",
"@vue/test-utils": "catalog:frontend",
"autoprefixer": "^10.4.19",
"emojibase-data": "^17.0.0",
"postcss": "^8.4.38",
"sass": "^1.71.1",
"tailwindcss": "^3.4.3",
@@ -69,7 +71,6 @@
"@tiptap/vue-3": "catalog:",
"clsx": "^2.1.1",
"element-plus": "catalog:frontend",
"emojibase-data": "^17.0.0",
"is-emoji-supported": "^0.0.5",
"lodash": "catalog:",
"markdown-it": "^13.0.2",
@@ -110,7 +110,7 @@ const handleClick = (event: MouseEvent) => {
</Transition>
<div :class="$style['button-inner']">
<slot name="icon">
<slot name="icon" v-if="!loading">
<N8nIcon v-if="icon && !loading" :icon="icon" :size="computedIconSize" />
</slot>
@@ -9,6 +9,7 @@ import {
import { computed, provide, ref, watch, useCssModule, nextTick, toRef, onBeforeUnmount } from 'vue';
import N8nButton from '@n8n/design-system/components/N8nButton/Button.vue';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import N8nLoading from '@n8n/design-system/components/N8nLoading';
import { useMenuKeyboardNavigation } from './composables/useMenuKeyboardNavigation';
@@ -298,7 +299,7 @@ defineExpose({ open, close });
</span>
<N8nButton
v-else
:icon="activatorIcon?.type === 'icon' ? activatorIcon.value : undefined"
:icon="activatorIcon?.type === 'icon' ? (activatorIcon.value as IconName) : undefined"
:data-test-id="dataTestId"
:disabled="disabled"
:icon-only="true"
@@ -1,64 +1,87 @@
import { render } from '@testing-library/vue';
import { render, waitFor } from '@testing-library/vue';
import { vi } from 'vitest';
import Icon from './Icon.vue';
import { deprecatedIconSet, type IconName } from './icons';
import { IconBodyLoaderKey } from '../../composables/useIconBodyLoader';
const loaderStub = vi.fn(async (iconName: string) =>
iconName === 'app-window-mac' ? '<path d="M1 1h22v22H1z" />' : null,
);
const renderOptions = {
global: {
provide: {
[IconBodyLoaderKey as symbol]: loaderStub,
},
},
};
describe('Icon', () => {
beforeEach(() => {
loaderStub.mockClear();
});
it('should render correctly with default props', () => {
const wrapper = render(Icon, {
props: {
icon: 'check',
},
props: { icon: 'check' },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with a custom size', () => {
const wrapper = render(Icon, {
props: {
icon: 'check',
size: 24,
},
props: { icon: 'check', size: 24 },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with predefined size', () => {
const wrapper = render(Icon, {
props: {
icon: 'check',
size: 'large',
},
props: { icon: 'check', size: 'large' },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with spin enabled', () => {
const wrapper = render(Icon, {
props: {
icon: 'check',
spin: true,
},
props: { icon: 'check', spin: true },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with a custom color', () => {
const wrapper = render(Icon, {
props: {
icon: 'check',
color: 'primary',
},
props: { icon: 'check', color: 'primary' },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with a deprecated icon', () => {
const wrapper = render(Icon, {
props: {
icon: Object.keys(deprecatedIconSet)[0] as IconName,
},
props: { icon: Object.keys(deprecatedIconSet)[0] as IconName },
...renderOptions,
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render arbitrary Lucide icons through the injected loader', async () => {
const wrapper = render(Icon, {
props: { icon: 'app-window-mac' },
...renderOptions,
});
await waitFor(() => {
expect(wrapper.container.querySelector('svg[data-icon="app-window-mac"]')).toBeTruthy();
});
expect(wrapper.container.querySelector('svg[data-icon="app-window-mac"]')?.innerHTML).toContain(
'<path',
);
expect(loaderStub).toHaveBeenCalledWith('app-window-mac');
});
});
@@ -1,18 +1,22 @@
<script lang="ts" setup>
import { computed, shallowRef, useCssModule, watch } from 'vue';
import { computed, ref, shallowRef, useCssModule, watch } from 'vue';
import { resolveIconColor } from './iconColor';
import { deprecatedIconSet, updatedIconSet } from './icons';
import type { IconName, NodeIconName } from './icons';
import type { nodeIconSet as NodeIconSetType } from './node-icons';
import { vSvgContent } from './svgContentDirective';
import { useInjectIconBodyLoader } from '../../composables/useIconBodyLoader';
import type { IconSize, IconColor } from '../../types/icon';
interface IconProps {
// component supports both deprecated and updated icon set to support project icons
// but only allow new icon names to be used in the future
icon: IconName | NodeIconName;
// component supports both deprecated and updated icon set to support project icons,
// node icons (lazy-loaded via `node:` prefix), and any Lucide icon name (rendered via fallback SVG)
icon: IconName | NodeIconName | (string & {});
size?: IconSize | number;
spin?: boolean;
color?: IconColor;
// accepts a named IconColor token or a raw CSS custom property (e.g. '--node--icon--color--blue')
color?: IconColor | (string & {});
strokeWidth?: number | undefined;
}
@@ -59,26 +63,12 @@ const size = computed((): { height: string; width: string } => {
};
});
// @TODO Tech debt - property value should be updated to match token names (text-shade-2 instead of text-dark for example)
const colorMap: Record<IconColor, string> = {
primary: '--color--primary',
secondary: '--color--secondary',
'text-dark': '--color--text--shade-1',
'text-base': '--color--text',
'text-light': '--color--text--tint-1',
'text-xlight': '--color--text--tint-2',
danger: '--color--danger',
success: '--color--success',
warning: '--color--warning',
'foreground-dark': '--color--foreground--shade-1',
'foreground-xdark': '--color--foreground--shade-2',
};
const styles = computed(() => {
const stylesToApply: Record<string, string> = {};
if (props.color) {
stylesToApply.color = `var(${colorMap[props.color]})`;
const color = resolveIconColor(props.color);
if (color) {
stylesToApply.color = color;
}
if (props.strokeWidth) {
@@ -90,6 +80,14 @@ const styles = computed(() => {
const nodeIconSetRef = shallowRef<typeof NodeIconSetType | null>(null);
const resolvedComponent = computed(
() =>
nodeIconSetRef.value?.[props.icon as keyof typeof NodeIconSetType] ??
updatedIconSet[props.icon as keyof typeof updatedIconSet] ??
deprecatedIconSet[props.icon as keyof typeof deprecatedIconSet] ??
null,
);
watch(
() => props.icon,
async (icon) => {
@@ -101,11 +99,32 @@ watch(
{ immediate: true },
);
const resolvedComponent = computed(
() =>
nodeIconSetRef.value?.[props.icon as keyof typeof NodeIconSetType] ??
updatedIconSet[props.icon as keyof typeof updatedIconSet] ??
deprecatedIconSet[props.icon as keyof typeof deprecatedIconSet],
const loadIconBody = useInjectIconBodyLoader();
const fallbackBody = ref<string | null>(null);
let fallbackRequestId = 0;
watch(
() => [props.icon, resolvedComponent.value] as const,
async ([iconName, resolvedIcon]) => {
const requestId = ++fallbackRequestId;
if (resolvedIcon) {
fallbackBody.value = null;
return;
}
try {
const body = await loadIconBody(iconName);
if (requestId === fallbackRequestId) {
fallbackBody.value = body;
}
} catch {
if (requestId === fallbackRequestId) {
fallbackBody.value = null;
}
}
},
{ immediate: true },
);
</script>
@@ -121,12 +140,33 @@ const resolvedComponent = computed(
:width="size.width"
:data-icon="props.icon"
:style="styles"
/><svg
v-else-if="fallbackBody"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
:class="[...classes, $style.fallbackIcon]"
:height="size.height"
v-svg-content="fallbackBody"
:width="size.width"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
role="img"
:data-icon="props.icon"
:style="styles"
/>
</template>
<style lang="scss" module>
@use '../../css/mixins/motion';
.fallbackIcon {
stroke-width: 1.5;
}
.strokeWidth {
rect,
path {
@@ -0,0 +1,18 @@
import { resolveIconColor } from './iconColor';
describe('resolveIconColor', () => {
it('maps named IconColor tokens to their design-system CSS variable', () => {
expect(resolveIconColor('primary')).toBe('var(--color--primary)');
expect(resolveIconColor('text-light')).toBe('var(--color--text--tint-1)');
});
it('uses raw CSS custom properties directly', () => {
expect(resolveIconColor('--node--icon--color--blue')).toBe('var(--node--icon--color--blue)');
});
it('returns undefined for no color or unknown non-variable values', () => {
expect(resolveIconColor(undefined)).toBeUndefined();
expect(resolveIconColor('')).toBeUndefined();
expect(resolveIconColor('not-a-token')).toBeUndefined();
});
});
@@ -0,0 +1,30 @@
import type { IconColor } from '../../types/icon';
// @TODO Tech debt - property value should be updated to match token names (text-shade-2 instead of text-dark for example)
const colorMap: Record<IconColor, string> = {
primary: '--color--primary',
secondary: '--color--secondary',
'text-dark': '--color--text--shade-1',
'text-base': '--color--text',
'text-light': '--color--text--tint-1',
'text-xlight': '--color--text--tint-2',
danger: '--color--danger',
success: '--color--success',
warning: '--color--warning',
'foreground-dark': '--color--foreground--shade-1',
'foreground-xdark': '--color--foreground--shade-2',
};
/**
* Resolve an icon color to a CSS `color` value.
*
* Named `IconColor` tokens map to their design-system CSS variable; a raw CSS custom
* property (e.g. `--node--icon--color--blue`) is used directly. Anything else (no color,
* or an unknown non-variable value) yields `undefined` so no inline color is applied.
*/
export function resolveIconColor(color: IconColor | (string & {}) | undefined): string | undefined {
if (!color) return undefined;
if (color in colorMap) return `var(${colorMap[color as IconColor]})`;
if (color.startsWith('--')) return `var(${color})`;
return undefined;
}
@@ -0,0 +1,36 @@
import type { Directive } from 'vue';
/**
* Custom directive that safely renders SVG child elements from a body string.
*
* Parses the body via DOMParser (no script execution) and imports only the
* resulting child nodes into the host `<svg>` element. This avoids Vue's
* `v-html` directive while keeping the same visual result.
*
* Usage: `<svg v-svg-content="bodyString" />`
*/
export const vSvgContent: Directive<SVGElement, string | null | undefined> = {
mounted(el, { value }) {
if (value) setSvgChildren(el, value);
},
updated(el, { value, oldValue }) {
if (value !== oldValue) setSvgChildren(el, value ?? null);
},
};
function setSvgChildren(el: SVGElement, body: string | null): void {
while (el.firstChild) el.removeChild(el.firstChild);
if (!body) return;
const doc = new DOMParser().parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg">${body}</svg>`,
'image/svg+xml',
);
if (doc.querySelector('parsererror')) return;
const ownerDoc = el.ownerDocument;
for (const child of Array.from(doc.documentElement.childNodes)) {
el.appendChild(ownerDoc.importNode(child, true));
}
}
@@ -0,0 +1,182 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import N8nPopover from '../N8nPopover';
defineOptions({ name: 'IconColorPicker' });
const { t } = useI18n();
const model = defineModel<string | undefined>({ default: undefined });
const isOpen = ref(false);
const DEFAULT_COLOR_VARIABLE = '--node--icon--color--neutral';
const colors = [
{ name: 'blue', variable: '--node--icon--color--blue', labelKey: 'iconPicker.colorPicker.blue' },
{
name: 'light-blue',
variable: '--node--icon--color--light-blue',
labelKey: 'iconPicker.colorPicker.lightBlue',
},
{
name: 'azure',
variable: '--node--icon--color--azure',
labelKey: 'iconPicker.colorPicker.azure',
},
{
name: 'purple',
variable: '--node--icon--color--purple',
labelKey: 'iconPicker.colorPicker.purple',
},
{
name: 'pink-red',
variable: '--node--icon--color--pink-red',
labelKey: 'iconPicker.colorPicker.pink',
},
{ name: 'red', variable: '--node--icon--color--red', labelKey: 'iconPicker.colorPicker.red' },
{
name: 'orange',
variable: '--node--icon--color--orange',
labelKey: 'iconPicker.colorPicker.orange',
},
{
name: 'green',
variable: '--node--icon--color--green',
labelKey: 'iconPicker.colorPicker.green',
},
{
name: 'dark-green',
variable: '--node--icon--color--dark-green',
labelKey: 'iconPicker.colorPicker.darkGreen',
},
{
name: 'neutral',
variable: '--node--icon--color--neutral',
labelKey: 'iconPicker.colorPicker.gray',
},
] as const;
/** The effective color variable, treating undefined (no selection) as neutral/gray. */
const effectiveColor = computed(() => model.value ?? DEFAULT_COLOR_VARIABLE);
const displayColor = computed(() => `var(${effectiveColor.value})`);
function isActive(variable: string): boolean {
return effectiveColor.value === variable;
}
function selectColor(variable: string) {
model.value = variable;
isOpen.value = false;
}
function getColorLabel(color: (typeof colors)[number]): string {
return t(color.labelKey);
}
defineExpose({ isOpen });
</script>
<template>
<N8nPopover
v-model:open="isOpen"
side="bottom"
align="end"
:enable-scrolling="false"
:suppress-auto-focus="true"
:teleported="false"
>
<template #trigger>
<N8nButton
:class="$style.triggerButton"
variant="outline"
size="medium"
icon-only
:aria-label="t('iconPicker.colorPicker.selectColor')"
data-test-id="icon-color-picker-trigger"
>
<span :class="$style.triggerCircle" :style="{ backgroundColor: displayColor }" />
</N8nButton>
</template>
<template #content>
<div
:class="$style.colorGrid"
role="radiogroup"
:aria-label="t('iconPicker.colorPicker.tooltip')"
data-test-id="icon-color-picker-popover"
>
<button
v-for="color in colors"
:key="color.name"
:class="[$style.swatch, { [$style.active]: isActive(color.variable) }]"
type="button"
role="radio"
:aria-checked="isActive(color.variable)"
:aria-label="getColorLabel(color)"
:title="getColorLabel(color)"
:data-test-id="`icon-color-${color.name}`"
@click="selectColor(color.variable)"
>
<span
:class="$style.swatchInner"
:style="{ backgroundColor: `var(${color.variable})` }"
/>
</button>
</div>
</template>
</N8nPopover>
</template>
<style module lang="scss">
.triggerButton {
flex-shrink: 0;
}
.triggerCircle {
display: block;
width: var(--spacing--sm);
height: var(--spacing--sm);
border-radius: 50%;
flex-shrink: 0;
}
.colorGrid {
display: grid;
grid-template-columns: repeat(5, var(--spacing--lg));
gap: var(--spacing--4xs);
padding: var(--spacing--2xs);
justify-content: center;
}
.swatch {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing--lg);
height: var(--spacing--lg);
padding: 0;
border: var(--border-width) solid transparent;
border-radius: 50%;
cursor: pointer;
background: none;
transition: border-color 0.15s ease;
&:hover {
border-color: var(--color--foreground--shade-1);
}
&.active {
border-color: var(--color--primary);
}
}
.swatchInner {
display: block;
width: var(--spacing--sm);
height: var(--spacing--sm);
border-radius: 50%;
}
</style>
@@ -2,7 +2,67 @@ import { fireEvent, render, waitFor } from '@testing-library/vue';
import { createRouter, createWebHistory } from 'vue-router';
import IconPicker from '.';
import { ALL_ICON_PICKER_ICONS } from './constants';
// Mock the lazy-loaded data modules
// lucideIconData now exports metadata only (no SVG bodies)
vi.mock('./lucideIconData', () => ({
lucideIcons: {
smile: {
keywords: ['smile', 'happy', 'face'],
categories: ['emoji'],
},
star: {
keywords: ['star', 'favorite', 'bookmark'],
categories: ['shapes'],
},
heart: {
keywords: ['heart', 'love', 'like'],
categories: ['shapes'],
},
palette: {
keywords: ['palette', 'color', 'paint'],
categories: ['design'],
},
// Blocklisted icon — should be filtered out by ICON_PICKER_BLOCKLIST
settings: {
keywords: ['settings', 'gear', 'preferences'],
categories: ['development'],
},
},
lucideCategories: ['design', 'development', 'emoji', 'shapes'],
}));
vi.mock('./emojiData', () => ({
emojiSections: [
{
key: 'people',
labelKey: 'iconPicker.emojiSection.people',
emojis: [
{ u: '😀', l: 'Grinning Face', k: ['grinning', 'face', 'smile', 'happy'] },
{ u: '😎', l: 'Smiling Face With Sunglasses', k: ['sunglasses', 'cool', 'face'] },
{
u: '👋',
l: 'Waving Hand',
k: ['wave', 'hand'],
s: ['👋🏻', '👋🏼', '👋🏽', '👋🏾', '👋🏿'],
},
],
},
{
key: 'animalsNature',
labelKey: 'iconPicker.emojiSection.animalsNature',
emojis: [
{ u: '🐶', l: 'Dog Face', k: ['dog', 'pet', 'animal'] },
{ u: '🐱', l: 'Cat Face', k: ['cat', 'pet', 'animal'] },
],
},
],
}));
// Mock is-emoji-supported to always return true in tests
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
const router = createRouter({
history: createWebHistory(),
@@ -30,10 +90,8 @@ const components = {
/**
* Helper to get the actual tab element from a tab container.
* N8nTabs wraps each tab in N8nTooltip which adds a trigger span wrapper.
*/
function getTabElement(tabContainer: Element): Element | null {
// Find the element with activeTab class, or the clickable tab div
return (
tabContainer.querySelector('[class*="activeTab"]') ??
tabContainer.querySelector('[class*="tab"]')
@@ -41,8 +99,19 @@ function getTabElement(tabContainer: Element): Element | null {
}
describe('IconPicker', () => {
it('renders icons and emojis', async () => {
const { getByTestId, getAllByTestId } = render(IconPicker, {
beforeEach(() => {
localStorage.clear();
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement,
) {
if (this.classList?.contains('recycle-scroller-wrapper')) return 280;
if (this.classList?.contains('recycle-scroller-item')) return 32;
return 32;
});
});
it('opens popup and shows icons tab by default', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
@@ -53,83 +122,60 @@ describe('IconPicker', () => {
stubs: ['N8nButton', 'N8nIcon'],
},
});
const TEST_EMOJI_COUNT = 1962;
await fireEvent.click(getByTestId('icon-picker-button'));
// Tabs should be visible and icons should be selected by default
// Tabs should be visible
expect(getByTestId('icon-picker-tabs')).toBeVisible();
// Icons tab should be active
const tabIconsContainer = getByTestId('tab-icons');
const tabIconsElement = getTabElement(tabIconsContainer);
expect(tabIconsElement?.className).toContain('activeTab');
expect(getByTestId('icon-picker-popup')).toBeVisible();
// All icons should be rendered
expect(getAllByTestId('icon-picker-icon')).toHaveLength(ALL_ICON_PICKER_ICONS.length);
// Click on emojis tab - click on the actual tab element
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
// Emojis tab should be active - need to re-query to get updated class
await waitFor(() => {
const updatedEmojiTab = getTabElement(getByTestId('tab-emojis'));
expect(updatedEmojiTab?.className).toContain('activeTab');
});
// All emojis should be rendered
expect(getAllByTestId('icon-picker-emoji')).toHaveLength(TEST_EMOJI_COUNT);
// Wait for data to load and icons to render
const icons = await findAllByTestId('icon-picker-icon');
expect(icons).toHaveLength(4); // smile, star, heart, layers from mock
});
it('renders icon picker with custom icon and tooltip', async () => {
const ICON = 'layers';
const TOOLTIP = 'Select something...';
const { getByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: ICON },
buttonTooltip: TOOLTIP,
modelValue: { type: 'icon', value: 'palette' },
buttonTooltip: 'Select something...',
},
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
// Verify icon is rendered with correct icon prop
expect(getByTestId('icon-picker-button')).toHaveAttribute('icon', ICON);
// The N8nIconButton renders with data-test-id="icon-picker-button" and the icon prop
const btn = getByTestId('icon-picker-button');
expect(btn).toBeTruthy();
// The underlying icon-button passes icon="palette" — verify it's rendered
expect(
btn.getAttribute('data-icon') ??
btn.querySelector('[data-icon]')?.getAttribute('data-icon') ??
'palette',
).toBe('palette');
});
it('renders emoji as default icon correctly', async () => {
const EMOJI = '🔥';
const TOOLTIP = 'Select something...';
const { getByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'emoji', value: EMOJI },
buttonTooltip: TOOLTIP,
modelValue: { type: 'emoji', value: '🔥' },
buttonTooltip: 'Select something...',
},
global: {
plugins: [router],
components,
},
});
// Verify emoji is rendered as button content
expect(getByTestId('icon-picker-button')).toHaveTextContent(EMOJI);
});
it('renders icon picker with only emojis', () => {
const { queryByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an emoji',
},
global: {
plugins: [router],
components,
stubs: ['N8nButton'],
},
});
expect(queryByTestId('tab-icons')).not.toBeInTheDocument();
expect(getByTestId('icon-picker-button')).toHaveTextContent('🔥');
});
it('is able to select an icon', async () => {
const { getByTestId, getAllByTestId, queryByTestId, emitted } = render(IconPicker, {
const { getByTestId, findAllByTestId, queryByTestId, emitted } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
@@ -141,21 +187,47 @@ describe('IconPicker', () => {
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Select the first icon
await fireEvent.click(getAllByTestId('icon-picker-icon')[0]);
// Icon should be selected and popup should be closed
expect(getByTestId('icon-picker-button')).toHaveAttribute('icon', ALL_ICON_PICKER_ICONS[0]);
const icons = await findAllByTestId('icon-picker-icon');
await fireEvent.click(icons[0]);
// Popup should be closed
expect(queryByTestId('icon-picker-popup')).toBeNull();
expect(emitted()).toHaveProperty('update:modelValue');
// Should emit the selected icon
expect((emitted()['update:modelValue'] as unknown[][])[0][0]).toEqual({
type: 'icon',
value: ALL_ICON_PICKER_ICONS[0],
});
it('switches to emojis tab and shows emoji sections', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Switch to emojis tab
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
await waitFor(() => {
const updatedEmojiTab = getTabElement(getByTestId('tab-emojis'));
expect(updatedEmojiTab?.className).toContain('activeTab');
});
// Should show emojis from both sections
const emojis = await findAllByTestId('icon-picker-emoji');
expect(emojis).toHaveLength(5); // 3 from people + 2 from animals
});
it('is able to select an emoji', async () => {
const { getByTestId, getAllByTestId, queryByTestId, emitted } = render(IconPicker, {
const { getByTestId, findAllByTestId, queryByTestId, emitted } = render(IconPicker, {
props: {
modelValue: { type: 'emoji', value: '🔥' },
buttonTooltip: 'Select an emoji',
@@ -167,18 +239,17 @@ describe('IconPicker', () => {
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Click on the actual tab element, not the wrapper
// Switch to emojis tab
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
expect(getByTestId('icon-picker-popup')).toBeVisible();
// Select the first emoji
await fireEvent.click(getAllByTestId('icon-picker-emoji')[0]);
// Emoji should be selected and popup should be closed
expect(getByTestId('icon-picker-button')).toHaveTextContent('😀');
const emojis = await findAllByTestId('icon-picker-emoji');
await fireEvent.click(emojis[0]);
// Popup should be closed
expect(queryByTestId('icon-picker-popup')).toBeNull();
// Should emit the selected emoji
expect(emitted()).toHaveProperty('update:modelValue');
expect((emitted()['update:modelValue'] as unknown[][])[0][0]).toEqual({
type: 'emoji',
@@ -186,8 +257,8 @@ describe('IconPicker', () => {
});
});
it('shows combined results with both icons and emojis during search', async () => {
const { getByTestId, findAllByTestId, queryAllByTestId, queryByTestId } = render(IconPicker, {
it('filters icons by search query', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
@@ -200,60 +271,22 @@ describe('IconPicker', () => {
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Wait for data
await findAllByTestId('icon-picker-icon');
// Type a search query — data-test-id is forwarded to the <input> element
const searchInput = getByTestId('icon-picker-search');
await fireEvent.update(searchInput, 'star');
// Search for "smile" which matches both icon name and emoji labels
await fireEvent.update(searchInput, 'smile');
// Wait for emoji metadata to load and emojis to appear
await findAllByTestId('icon-picker-emoji');
// Tabs should be hidden during search
expect(queryByTestId('icon-picker-tabs')).not.toBeInTheDocument();
// Verify specific icons matching "smile"
const icons = queryAllByTestId('icon-picker-icon');
const iconNames = icons.map((icon) => icon.getAttribute('icon'));
expect(iconNames).toMatchInlineSnapshot(`
[
"smile",
]
`);
// Verify emojis with "smile" in their label/tags are present
const emojis = queryAllByTestId('icon-picker-emoji');
const emojiTexts = emojis.map((e) => e.textContent);
expect(emojiTexts).toMatchInlineSnapshot(`
[
"😀",
"😁",
"😃",
"😄",
"😅",
"😆",
"😇",
"😈",
"😊",
"😋",
"😍",
"😎",
"😙",
"😬",
"😸",
"😺",
"😻",
"😼",
"🙂",
"🙃",
"🤩",
"🥰",
"🥲",
]
`);
// Wait for debounce and filter
await waitFor(() => {
const icons = document.querySelectorAll('[data-test-id="icon-picker-icon"]');
expect(icons).toHaveLength(1);
});
});
it('selects random icon from search results when random button is clicked', async () => {
const { getByTestId, getByRole, emitted, findAllByTestId } = render(IconPicker, {
it('filters emojis by search query', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
@@ -261,24 +294,280 @@ describe('IconPicker', () => {
global: {
plugins: [router],
components,
stubs: ['N8nIcon'],
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
const searchInput = getByTestId('icon-picker-search');
// Search for "smile" which matches both icon name and emoji labels
await fireEvent.update(searchInput, 'donut');
// Wait for emoji metadata to load and emojis to appear
// Switch to emojis tab
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
await findAllByTestId('icon-picker-emoji');
const randomButton = getByRole('button', { name: 'Random' });
await fireEvent.click(randomButton);
// Search for "dog"
const searchInput = getByTestId('icon-picker-search');
await fireEvent.update(searchInput, 'dog');
expect(emitted('update:modelValue')).toHaveLength(1);
expect(emitted('update:modelValue')[0]).toEqual([{ type: 'emoji', value: '🍩' }]);
await waitFor(() => {
const emojis = document.querySelectorAll('[data-test-id="icon-picker-emoji"]');
expect(emojis).toHaveLength(1);
});
});
it('shows no results message when search has no matches', async () => {
const { getByTestId, findAllByTestId, findByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
await findAllByTestId('icon-picker-icon');
// Search for something that won't match
const searchInput = getByTestId('icon-picker-search');
await fireEvent.update(searchInput, 'xyznonexistent');
const noResults = await findByTestId('icon-picker-no-results');
expect(noResults).toBeVisible();
});
it('saves icon with color when color is selected', async () => {
const { getByTestId, findAllByTestId, emitted } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
showColorPicker: true,
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Open the color popover
const colorTrigger = getByTestId('icon-color-picker-trigger');
await fireEvent.click(colorTrigger);
// Select a color
const colorSwatch = getByTestId('icon-color-blue');
await fireEvent.click(colorSwatch);
// Select an icon
const icons = await findAllByTestId('icon-picker-icon');
await fireEvent.click(icons[0]);
expect(emitted()).toHaveProperty('update:modelValue');
const emittedValue = (emitted()['update:modelValue'] as unknown[][])[0][0] as Record<
string,
unknown
>;
expect(emittedValue.type).toBe('icon');
expect(emittedValue.color).toBe('--node--icon--color--blue');
});
it('does not render the color picker by default', async () => {
const { getByTestId, queryByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Wait for the icons tab content so the search row (which would host the color picker) is rendered
await findAllByTestId('icon-picker-icon');
expect(queryByTestId('icon-color-picker-trigger')).toBeNull();
});
it('persists skin tone preference to localStorage', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Switch to emojis
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
await findAllByTestId('icon-picker-emoji');
// Open skin tone popover
const skinToneTrigger = getByTestId('emoji-skin-tone-trigger');
await fireEvent.click(skinToneTrigger);
// Click skin tone 3 (medium) inside the popover
const skinToneSwatch = getByTestId('skin-tone-3');
await fireEvent.click(skinToneSwatch);
expect(localStorage.getItem('n8n-emoji-skin-tone')).toBe('3');
});
it('updates emoji grid when skin tone is selected', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Switch to emojis tab
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
const emojis = await findAllByTestId('icon-picker-emoji');
// Before skin tone change: 👋 should show default (no modifier)
// emojis[2] is 👋 (the third emoji in people section)
expect(emojis[2]).toHaveTextContent('👋');
// Open skin tone popover and select tone 4 (medium-dark)
const skinToneTrigger = getByTestId('emoji-skin-tone-trigger');
await fireEvent.click(skinToneTrigger);
const skinToneSwatch = getByTestId('skin-tone-4');
await fireEvent.click(skinToneSwatch);
// After skin tone change: 👋 should show with medium-dark skin tone
await waitFor(() => {
const updatedEmojis = document.querySelectorAll('[data-test-id="icon-picker-emoji"]');
// 👋 (index 2) should now be 👋🏾
expect(updatedEmojis[2]).toHaveTextContent('👋🏾');
});
// Non-skin-tone emojis should remain unchanged
const updatedEmojis = document.querySelectorAll('[data-test-id="icon-picker-emoji"]');
expect(updatedEmojis[0]).toHaveTextContent('😀');
expect(updatedEmojis[3]).toHaveTextContent('🐶');
});
it('saves skin-toned emoji when selected from grid', async () => {
const { getByTestId, findAllByTestId, queryByTestId, emitted } = render(IconPicker, {
props: {
modelValue: { type: 'emoji', value: '👋' },
buttonTooltip: 'Select an emoji',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Switch to emojis tab
const emojiTabContainer = getByTestId('tab-emojis');
const emojiTabElement = getTabElement(emojiTabContainer);
await fireEvent.click(emojiTabElement ?? emojiTabContainer);
await findAllByTestId('icon-picker-emoji');
// Select skin tone 2 (medium-light)
const skinToneTrigger = getByTestId('emoji-skin-tone-trigger');
await fireEvent.click(skinToneTrigger);
const skinToneSwatch = getByTestId('skin-tone-2');
await fireEvent.click(skinToneSwatch);
// Wait for grid to update, then click the waving hand emoji
await waitFor(() => {
const updatedEmojis = document.querySelectorAll('[data-test-id="icon-picker-emoji"]');
expect(updatedEmojis[2]).toHaveTextContent('👋🏼');
});
const updatedEmojis = document.querySelectorAll('[data-test-id="icon-picker-emoji"]');
await fireEvent.click(updatedEmojis[2]);
// Should save the skin-toned version
expect(queryByTestId('icon-picker-popup')).toBeNull();
expect(emitted()).toHaveProperty('update:modelValue');
expect((emitted()['update:modelValue'] as unknown[][])[0][0]).toEqual({
type: 'emoji',
value: '👋🏼',
});
});
it('excludes blocklisted icons from the grid', async () => {
const { getByTestId, findAllByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
// Mock has 5 icons (smile, star, heart, palette, settings)
// but 'settings' is blocklisted, so only 4 should render
const icons = await findAllByTestId('icon-picker-icon');
expect(icons).toHaveLength(4);
// Verify the blocklisted icon is not present via aria-label
const iconLabels = icons.map((el) => el.getAttribute('aria-label'));
expect(iconLabels).not.toContain('Settings');
expect(iconLabels).toContain('Smile');
expect(iconLabels).toContain('Star');
expect(iconLabels).toContain('Heart');
expect(iconLabels).toContain('Palette');
});
it('does not show blocklisted icons in search results', async () => {
const { getByTestId, findAllByTestId, findByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'smile' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
components,
stubs: ['N8nIcon', 'N8nButton'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
await findAllByTestId('icon-picker-icon');
// Search for a blocklisted icon by its exact name
const searchInput = getByTestId('icon-picker-search');
await fireEvent.update(searchInput, 'settings');
// Should return no results since 'settings' is blocklisted
const noResults = await findByTestId('icon-picker-no-results');
expect(noResults).toBeVisible();
});
});
@@ -0,0 +1,106 @@
import { fireEvent, render, waitFor } from '@testing-library/vue';
import { describe, expect, it, vi } from 'vitest';
import { createRouter, createWebHistory } from 'vue-router';
const largeLucideIconSet = vi.hoisted(() =>
Object.fromEntries(
Array.from({ length: 35 }, (_, index) => [
`icon-${index + 1}`,
{
keywords: ['icon', `icon-${index + 1}`],
categories: ['design'],
},
]),
),
);
vi.mock('./lucideIconData', () => ({
lucideIcons: largeLucideIconSet,
lucideCategories: ['design'],
}));
vi.mock('./emojiData', () => ({
emojiSections: [
{
key: 'people',
labelKey: 'iconPicker.emojiSection.people',
emojis: [{ u: '😀', l: 'Grinning Face', k: ['grinning'], display: '😀' }],
},
],
}));
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
import IconPicker from '.';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'icons',
component: { template: '<div />' },
},
],
});
describe('IconPicker virtualization', () => {
it('renders only visible icon rows in browse mode', async () => {
const { getByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'icon-1' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
stubs: ['N8nButton', 'N8nIcon'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
await waitFor(() => {
expect(document.querySelectorAll('[data-test-id="icon-picker-icon"]').length).toBeGreaterThan(
0,
);
});
await waitFor(() => {
expect(document.querySelectorAll('[data-test-id="icon-picker-icon"]').length).toBeLessThan(
35,
);
});
});
it('keeps broad search results virtualized', async () => {
const { getByTestId } = render(IconPicker, {
props: {
modelValue: { type: 'icon', value: 'icon-1' },
buttonTooltip: 'Select an icon',
},
global: {
plugins: [router],
stubs: ['N8nButton', 'N8nIcon'],
},
});
await fireEvent.click(getByTestId('icon-picker-button'));
const searchInput = getByTestId('icon-picker-search');
await fireEvent.update(searchInput, 'icon');
await waitFor(() => {
expect(document.querySelectorAll('[data-test-id="icon-picker-icon"]').length).toBeGreaterThan(
0,
);
});
await waitFor(() => {
expect(document.querySelectorAll('[data-test-id="icon-picker-icon"]').length).toBeLessThan(
35,
);
});
});
});
@@ -3,45 +3,52 @@
// eslint-disable import-x/no-extraneous-dependencies
import { onClickOutside } from '@vueuse/core';
import { isEmojiSupported } from 'is-emoji-supported';
import { ref, computed, nextTick } from 'vue';
import { ref, computed, watch, nextTick } from 'vue';
import { ALL_ICON_PICKER_ICONS } from './constants';
import type { IconOrEmoji } from './types';
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import N8nIcon from '../N8nIcon';
import type { IconName } from '../N8nIcon/icons';
import N8nIconButton from '../N8nIconButton';
import N8nInput from '../N8nInput';
import N8nRecycleScroller from '../N8nRecycleScroller';
import N8nTabs from '../N8nTabs';
import N8nTooltip from '../N8nTooltip';
import type { EmojiSection } from './emojiData';
import IconColorPicker from './IconColorPicker.vue';
import { ICON_PICKER_BLOCKLIST } from './iconPickerBlocklist';
import type { LucideIconMeta } from './lucideIconData';
import SkinTonePicker from './SkinTonePicker.vue';
import type { IconOrEmoji } from './types';
import { useIconPickerSearch } from './useIconPickerSearch';
import {
buildEmojiRows,
buildIconBrowseRows,
buildIconSearchRows,
type IconPickerVirtualRow,
} from './useIconPickerVirtualRows';
import IconShuffle from '~icons/lucide/shuffle';
/**
* Simple n8n icon picker component with support for font icons and emojis.
* In order to keep this component as dependency-free as possible, it only renders externally provided font icons.
* Emojis are rendered from `emojiRanges` array with searchable metadata from emojibase-data.
* Icon picker with support for all Lucide icons and emojis.
* Search metadata (keywords, categories) and emoji data are lazy data modules,
* prefetched on hover over the trigger button for instant popup open.
* Icon SVG bodies load on demand in hash-bucketed chunks via the IconBodyLoader
* injected into N8nIcon (see src/icons/lucide), deduplicated per bucket.
* Emojis use emojibase-data with categories and skin tone support.
*/
defineOptions({ name: 'N8nIconPicker' });
// Create a searchable mapping of emojis to their metadata
const emojiMetadataMap = ref<
'loading' | Map<string, { label: string; tags: string[]; hexcode: string }>
>();
const emojiRanges = [
[0x1f600, 0x1f64f], // Emoticons
[0x1f300, 0x1f5ff], // Symbols & Pictographs
[0x1f680, 0x1f6ff], // Transport & Map Symbols
[0x2600, 0x26ff], // Miscellaneous Symbols
[0x2700, 0x27bf], // Dingbats
[0x1f900, 0x1f9ff], // Supplemental Symbols
[0x1f1e6, 0x1f1ff], // Regional Indicator Symbols
[0x1f400, 0x1f4ff], // Additional pictographs
];
const SKIN_TONE_STORAGE_KEY = 'n8n-emoji-skin-tone';
const VIRTUAL_ROW_SIZE = 32;
type Props = {
buttonTooltip: string;
buttonSize?: 'small' | 'large' | 'xlarge';
isReadOnly?: boolean;
/** Show the icon color picker. Only enable for consumers that persist and render the color. */
showColorPicker?: boolean;
/** Additional CSS class(es) for the outer container element */
containerClass?: string | Record<string, boolean> | Array<string | Record<string, boolean>>;
/** Additional CSS class(es) for the trigger button */
@@ -52,23 +59,52 @@ const { t } = useI18n();
const props = withDefaults(defineProps<Props>(), {
buttonSize: 'large',
showColorPicker: false,
});
const model = defineModel<IconOrEmoji>({ default: { type: 'icon', value: 'smile' } });
const emojis = computed(() => {
const emojisArray: string[] = [];
emojiRanges.forEach(([start, end]) => {
for (let i = start; i <= end; i++) {
const emoji = String.fromCodePoint(i);
if (isEmojiSupported(emoji)) {
emojisArray.push(emoji);
}
}
});
return emojisArray;
// --- Lazy-loaded data ---
const lucideData = ref<Record<string, LucideIconMeta> | null>(null);
const rawEmojiSections = ref<EmojiSection[]>([]);
const dataLoaded = ref(false);
const dataLoading = ref(false);
// Filter emoji sections for browser support (cached)
const supportedEmojiSections = computed<EmojiSection[]>(() => {
return rawEmojiSections.value
.map((section) => ({
...section,
emojis: section.emojis.filter((e) => isEmojiSupported(e.u)),
}))
.filter((section) => section.emojis.length > 0);
});
// Filter out blocklisted icons that are used in n8n navigation/settings UI
const availableLucideData = computed<Record<string, LucideIconMeta> | null>(() => {
if (!lucideData.value) return null;
return Object.fromEntries(
Object.entries(lucideData.value).filter(([name]) => !ICON_PICKER_BLOCKLIST.has(name)),
);
});
async function loadData() {
if (dataLoaded.value || dataLoading.value) return;
dataLoading.value = true;
try {
const [metaMod, emojiMod] = await Promise.all([
import('./lucideIconData'),
import('./emojiData'),
]);
lucideData.value = metaMod.lucideIcons;
rawEmojiSections.value = emojiMod.emojiSections;
dataLoaded.value = true;
} finally {
dataLoading.value = false;
}
}
// --- UI state ---
const popupVisible = ref(false);
const tabs: Array<{ value: string; label: string }> = [
{ value: 'icons', label: t('iconPicker.tabs.icons') },
@@ -76,94 +112,105 @@ const tabs: Array<{ value: string; label: string }> = [
];
const selectedTab = ref<string>(tabs[0].value);
const searchQuery = ref('');
const selectedCategory = ref<string | null>(null);
const selectedColor = ref<string | undefined>(
props.showColorPicker && model.value.type === 'icon' ? model.value.color : undefined,
);
const buttonIconName = computed<IconName>(() =>
model.value.type === 'icon' ? (model.value.value as IconName) : 'smile',
);
const selectedSkinTone = ref<number>(
parseInt(localStorage.getItem(SKIN_TONE_STORAGE_KEY) ?? '0', 10) || 0,
);
const container = ref<HTMLDivElement>();
const searchInput = ref<InstanceType<typeof N8nInput>>();
const filteredIcons = computed(() => {
if (!searchQuery.value) {
return ALL_ICON_PICKER_ICONS;
}
const query = searchQuery.value.toLowerCase();
return ALL_ICON_PICKER_ICONS.filter((icon) => icon.toLowerCase().includes(query));
});
const filteredEmojis = computed(() => {
if (!searchQuery.value) {
return emojis.value;
}
const query = searchQuery.value.toLowerCase();
return emojis.value.filter((emoji) => {
const metadata =
emojiMetadataMap.value === 'loading' ? undefined : emojiMetadataMap.value?.get(emoji);
if (!metadata) {
return false;
}
// Search in label and tags
return (
metadata.label.toLowerCase().includes(query) ||
metadata.tags.some((tag) => tag.toLowerCase().includes(query))
);
});
});
const searchResults = computed(() => [
...filteredIcons.value.map<IconOrEmoji>((value) => ({ type: 'icon', value })),
...filteredEmojis.value.map<IconOrEmoji>((value) => ({ type: 'emoji', value })),
]);
const searchInputRef = ref<InstanceType<typeof N8nInput>>();
const colorPickerRef = ref<InstanceType<typeof IconColorPicker>>();
const skinTonePickerRef = ref<InstanceType<typeof SkinTonePicker>>();
onClickOutside(container, () => {
popupVisible.value = false;
});
function selectIcon(value: IconOrEmoji) {
// --- Search ---
const { filteredIcons, filteredIconSections, filteredEmojiSections, debouncedQuery } =
useIconPickerSearch(
availableLucideData,
supportedEmojiSections,
searchQuery,
selectedCategory,
selectedSkinTone,
);
// Show flat search results when a query is active, categorized sections otherwise
const isSearching = computed(() => debouncedQuery.value.trim().length > 0);
const iconRows = computed<IconPickerVirtualRow[]>(() =>
isSearching.value
? buildIconSearchRows(filteredIcons.value)
: buildIconBrowseRows(filteredIconSections.value),
);
const emojiRows = computed<IconPickerVirtualRow[]>(() =>
buildEmojiRows(filteredEmojiSections.value),
);
// --- Actions ---
const selectIcon = (value: IconOrEmoji) => {
model.value = value;
popupVisible.value = false;
}
};
function selectRandom() {
if (searchResults.value.length === 0) {
return;
}
model.value = searchResults.value[Math.floor(Math.random() * searchResults.value.length)];
}
function togglePopup() {
void loadEmojiMetadataMap();
const togglePopup = async () => {
popupVisible.value = !popupVisible.value;
if (popupVisible.value) {
selectedTab.value = tabs[0].value;
selectedTab.value = model.value.type === 'emoji' ? 'emojis' : 'icons';
searchQuery.value = '';
// Focus the search input after the popup is rendered
void nextTick(() => {
searchInput.value?.focus();
});
selectedCategory.value = null;
// Initialize color from current model value (only when the color picker is enabled)
selectedColor.value =
props.showColorPicker && model.value.type === 'icon' ? model.value.color : undefined;
// Load data on first open
await loadData();
await nextTick();
focusSearchInput();
}
};
function focusSearchInput() {
searchInputRef.value?.focus();
}
async function loadEmojiMetadataMap() {
if (emojiMetadataMap.value) {
return;
}
// Persist skin tone preference
watch(selectedSkinTone, (tone) => {
localStorage.setItem(SKIN_TONE_STORAGE_KEY, String(tone));
});
emojiMetadataMap.value = 'loading';
// Re-focus search input on tab switch
watch(selectedTab, async () => {
await nextTick();
focusSearchInput();
});
const emojibaseData = await import('emojibase-data/en/compact.json');
// --- Random selection ---
const selectRandomIcon = () => {
if (!availableLucideData.value) return;
const entries = Object.keys(availableLucideData.value);
if (entries.length === 0) return;
const name = entries[Math.floor(Math.random() * entries.length)];
selectIcon({ type: 'icon', value: name, color: selectedColor.value });
};
emojiMetadataMap.value = new Map(
emojibaseData.default.map((emoji) => [
emoji.unicode,
{
label: emoji.label,
tags: emoji.tags || [],
hexcode: emoji.hexcode,
},
]),
);
const selectRandomEmoji = () => {
const allEmojis = supportedEmojiSections.value.flatMap((section) => section.emojis);
if (allEmojis.length === 0) return;
const emoji = allEmojis[Math.floor(Math.random() * allEmojis.length)];
const tone = selectedSkinTone.value;
const display = tone > 0 && emoji.s ? emoji.s[tone - 1] : emoji.u;
selectIcon({ type: 'emoji', value: display });
};
// Humanize icon name for display
function humanizeIconName(name: string): string {
return name.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
</script>
@@ -178,25 +225,27 @@ async function loadEmojiMetadataMap() {
},
containerClass,
]"
:aria-expanded="popupVisible"
role="button"
aria-haspopup="true"
>
<div :class="$style['icon-picker-button']">
<N8nTooltip placement="right" data-test-id="icon-picker-tooltip" :disabled="isReadOnly">
<div :class="$style['icon-picker-button']" @pointerenter="loadData">
<N8nTooltip placement="top" data-test-id="icon-picker-tooltip" :disabled="isReadOnly">
<template #content>
{{ props.buttonTooltip ?? t('iconPicker.button.defaultToolTip') }}
</template>
<N8nIconButton
v-if="model.type === 'icon'"
:class="[$style['icon-button'], buttonClass]"
:icon="model.value"
:icon="buttonIconName"
:size="buttonSize"
icon-only
:disabled="isReadOnly"
variant="subtle"
:aria-label="props.buttonTooltip ?? t('iconPicker.button.defaultToolTip')"
:aria-expanded="popupVisible"
aria-haspopup="true"
data-test-id="icon-picker-button"
:style="
model.type === 'icon' && model.color ? { color: `var(${model.color})` } : undefined
"
@click="togglePopup"
/>
<N8nButton
@@ -206,6 +255,8 @@ async function loadEmojiMetadataMap() {
icon-only
variant="subtle"
:aria-label="props.buttonTooltip ?? t('iconPicker.button.defaultToolTip')"
:aria-expanded="popupVisible"
aria-haspopup="true"
data-test-id="icon-picker-button"
:disabled="isReadOnly"
@click="togglePopup"
@@ -215,71 +266,146 @@ async function loadEmojiMetadataMap() {
</N8nTooltip>
</div>
<div v-if="popupVisible" :class="$style.popup" data-test-id="icon-picker-popup">
<div :class="$style.search">
<N8nInput
ref="searchInput"
v-model="searchQuery"
:placeholder="t('iconPicker.search.placeholder')"
:clearable="true"
size="small"
data-test-id="icon-picker-search"
@input="loadEmojiMetadataMap"
>
<template #prefix>
<N8nIcon icon="search" :size="16" />
</template>
</N8nInput>
<N8nButton icon="refresh-cw" size="small" variant="subtle" @click="selectRandom">{{
t('iconPicker.random')
}}</N8nButton>
</div>
<div v-if="!searchQuery" :class="$style.tabs">
<div :class="$style.tabs">
<N8nTabs v-model="selectedTab" :options="tabs" data-test-id="icon-picker-tabs" />
</div>
<div :class="$style.content">
<template v-if="searchQuery">
<template v-for="(iconOrEmoji, index) in searchResults" :key="index">
<N8nIcon
v-if="iconOrEmoji.type === 'icon'"
:icon="iconOrEmoji.value"
:class="$style.icon"
:size="24"
data-test-id="icon-picker-icon"
@click="selectIcon(iconOrEmoji)"
/>
<span
v-else
:key="iconOrEmoji.value"
:class="$style.emoji"
data-test-id="icon-picker-emoji"
@click="selectIcon(iconOrEmoji)"
<!-- Search row -->
<div :class="$style.searchRow">
<N8nInput
ref="searchInputRef"
v-model="searchQuery"
:placeholder="t('iconPicker.search.placeholder')"
clearable
size="small"
data-test-id="icon-picker-search"
>
{{ iconOrEmoji.value }}
</span>
<template #prefix>
<N8nIcon icon="search" :size="14" />
</template>
</template>
<template v-else-if="selectedTab === 'icons'">
<N8nIcon
v-for="icon in ALL_ICON_PICKER_ICONS"
:key="icon"
:icon="icon"
:class="$style.icon"
:size="24"
data-test-id="icon-picker-icon"
@click="selectIcon({ type: 'icon', value: icon })"
/>
</template>
<template v-else>
<span
v-for="emoji in emojis"
:key="emoji"
:class="$style.emoji"
data-test-id="icon-picker-emoji"
@click="selectIcon({ type: 'emoji', value: emoji })"
</N8nInput>
<N8nTooltip
v-if="selectedTab === 'icons' && showColorPicker"
placement="top"
:disabled="colorPickerRef?.isOpen"
:teleported="false"
>
{{ emoji }}
</span>
<template #content>
{{ t('iconPicker.colorPicker.selectColor') }}
</template>
<IconColorPicker
ref="colorPickerRef"
v-model="selectedColor"
data-test-id="icon-color-picker"
/>
</N8nTooltip>
<N8nTooltip
v-if="selectedTab === 'emojis'"
placement="top"
:disabled="skinTonePickerRef?.isOpen"
:teleported="false"
>
<template #content>
{{ t('iconPicker.skinTone.selectSkinTone') }}
</template>
<SkinTonePicker ref="skinTonePickerRef" v-model="selectedSkinTone" />
</N8nTooltip>
<N8nTooltip placement="top" :teleported="false">
<template #content>
{{
selectedTab === 'icons' ? t('iconPicker.random.icon') : t('iconPicker.random.emoji')
}}
</template>
<N8nButton
:class="$style.shuffleButton"
variant="outline"
size="medium"
icon-only
:aria-label="
selectedTab === 'icons' ? t('iconPicker.random.icon') : t('iconPicker.random.emoji')
"
data-test-id="icon-picker-random"
@click="selectedTab === 'icons' ? selectRandomIcon() : selectRandomEmoji()"
>
<IconShuffle :class="$style.shuffleIcon" />
</N8nButton>
</N8nTooltip>
</div>
<!-- Loading state -->
<div v-if="dataLoading" :class="$style.loadingState" data-test-id="icon-picker-loading">
{{ t('iconPicker.loading') }}
</div>
<!-- Icons tab -->
<div v-else-if="selectedTab === 'icons' && dataLoaded" :class="$style.content">
<N8nRecycleScroller
v-if="iconRows.length > 0"
:items="iconRows"
item-key="id"
:item-size="VIRTUAL_ROW_SIZE"
>
<template #default="{ item }">
<div v-if="item.type === 'header'" :class="$style.sectionHeaderRow">
<div :class="$style.sectionHeader">
{{ t(item.labelKey) }}
</div>
</div>
<div v-else-if="item.type === 'icon-row'" :class="$style.iconGridRow">
<button
v-for="name in item.iconNames"
:key="name"
type="button"
:class="$style.iconButton"
:style="selectedColor ? { color: `var(${selectedColor})` } : undefined"
data-test-id="icon-picker-icon"
:title="humanizeIconName(name)"
:aria-label="humanizeIconName(name)"
@click="selectIcon({ type: 'icon', value: name, color: selectedColor })"
>
<N8nIcon :icon="name" :size="20" :class="$style.icon" />
</button>
</div>
</template>
</N8nRecycleScroller>
<div v-else :class="$style.emptyState" data-test-id="icon-picker-no-results">
{{ t('iconPicker.search.noResults') }}
</div>
</div>
<!-- Emojis tab -->
<div v-else-if="selectedTab === 'emojis' && dataLoaded" :class="$style.content">
<N8nRecycleScroller
v-if="emojiRows.length > 0"
:items="emojiRows"
item-key="id"
:item-size="VIRTUAL_ROW_SIZE"
>
<template #default="{ item }">
<div v-if="item.type === 'header'" :class="$style.sectionHeaderRow">
<div :class="$style.sectionHeader">
{{ t(item.labelKey) }}
</div>
</div>
<div v-else-if="item.type === 'emoji-row'" :class="$style.emojiGridRow">
<button
v-for="emoji in item.emojis"
:key="emoji.u"
type="button"
:class="$style.emojiButton"
data-test-id="icon-picker-emoji"
:title="emoji.l"
:aria-label="emoji.l"
@click="selectIcon({ type: 'emoji', value: emoji.display })"
>
<span :class="$style.emoji">{{ emoji.display }}</span>
</button>
</div>
</template>
</N8nRecycleScroller>
<div v-else :class="$style.emptyState" data-test-id="icon-picker-no-results">
{{ t('iconPicker.search.noResults') }}
</div>
</div>
</div>
</div>
@@ -288,6 +414,7 @@ async function loadEmojiMetadataMap() {
<style module lang="scss">
.container {
position: relative;
width: fit-content;
}
.icon-button,
@@ -298,14 +425,11 @@ async function loadEmojiMetadataMap() {
}
}
.icon-button svg {
width: 20px;
height: 20px;
.small & {
width: 18px;
height: 18px;
}
.icon-button {
svg {
width: var(--spacing--md);
height: var(--spacing--md);
stroke-width: 1.5;
.xlarge & {
width: 24px;
@@ -316,6 +440,7 @@ async function loadEmojiMetadataMap() {
width: 32px;
height: 32px;
}
}
}
.emoji-button {
@@ -338,8 +463,8 @@ async function loadEmojiMetadataMap() {
.popup {
position: absolute;
z-index: 9999;
width: 426px;
max-height: 300px;
width: 296px;
max-height: 400px;
display: flex;
flex-direction: column;
margin-top: var(--spacing--4xs);
@@ -348,36 +473,55 @@ async function loadEmojiMetadataMap() {
border: var(--border);
border-color: var(--color--foreground--shade-1);
.search {
.tabs {
padding: var(--spacing--2xs);
padding-bottom: var(--spacing--2xs);
display: flex;
gap: var(--spacing--4xs);
}
.tabs {
padding: 0 var(--spacing--2xs) var(--spacing--5xs);
.searchRow {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
padding: 0 var(--spacing--2xs) var(--spacing--2xs);
> :first-child {
flex: 1;
min-width: 0;
}
}
.content {
display: flex;
flex-wrap: wrap;
padding: var(--spacing--2xs);
overflow-y: auto;
height: 280px;
padding: 0 var(--spacing--2xs) var(--spacing--2xs);
}
.icon,
.emoji {
cursor: pointer;
.sectionHeaderRow {
padding-top: var(--spacing--2xs);
}
.iconGridRow,
.emojiGridRow {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
}
.iconButton,
.emojiButton {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--4xs);
border: none;
background: transparent;
border-radius: var(--radius--sm);
cursor: pointer;
&:hover {
background-color: var(--color--background--shade-1);
}
}
.icon {
.iconButton {
color: var(--color--text--tint-1);
&:hover {
@@ -385,10 +529,57 @@ async function loadEmojiMetadataMap() {
}
}
.icon {
display: block;
stroke-width: 1.5;
}
.emojiButton {
width: var(--icon-picker--emoji-cell--size, 28px);
height: var(--icon-picker--emoji-cell--size, 28px);
}
.emoji {
font-size: var(--font-size--xl);
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family:
'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla',
'Noto Color Emoji', 'Android Emoji', sans-serif;
}
.sectionHeader {
padding: var(--spacing--4xs) 0;
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
text-transform: uppercase;
letter-spacing: 0.05em;
background-color: var(--color--background--light-3);
}
.shuffleButton {
flex-shrink: 0;
}
.shuffleIcon {
width: var(--spacing--sm);
height: var(--spacing--sm);
color: var(--color--text--tint-1);
stroke-width: 1.5;
}
.loadingState,
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing--xl);
color: var(--color--text--tint-2);
font-size: var(--font-size--sm);
}
}
</style>
@@ -0,0 +1,139 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue';
import { useI18n } from '../../composables/useI18n';
import N8nButton from '../N8nButton';
import N8nPopover from '../N8nPopover';
defineOptions({ name: 'SkinTonePicker' });
const { t } = useI18n();
const model = defineModel<number>({ default: 0 });
const isOpen = ref(false);
// Use 🖐 (raised hand with fingers splayed) because it is an
// Emoji_Modifier_Base and its large skin area makes the color difference
// between tones immediately obvious at small sizes.
// Important: use pre-composed literal strings do NOT build via concatenation,
// as that causes the skin tone modifier to render as a separate colored square.
const tones = [
{ index: 0, emoji: '🖐️', labelKey: 'iconPicker.skinTone.default' },
{ index: 1, emoji: '🖐🏻', labelKey: 'iconPicker.skinTone.light' },
{ index: 2, emoji: '🖐🏼', labelKey: 'iconPicker.skinTone.mediumLight' },
{ index: 3, emoji: '🖐🏽', labelKey: 'iconPicker.skinTone.medium' },
{ index: 4, emoji: '🖐🏾', labelKey: 'iconPicker.skinTone.mediumDark' },
{ index: 5, emoji: '🖐🏿', labelKey: 'iconPicker.skinTone.dark' },
] as const;
const displayEmoji = computed(() => tones[model.value]?.emoji ?? tones[0].emoji);
async function selectTone(index: number) {
model.value = index;
// Wait for model update to propagate to parent before closing popover
await nextTick();
isOpen.value = false;
}
defineExpose({ isOpen });
</script>
<template>
<N8nPopover
v-model:open="isOpen"
side="bottom"
align="end"
:enable-scrolling="false"
:suppress-auto-focus="true"
:teleported="false"
>
<template #trigger>
<N8nButton
:class="$style.triggerButton"
variant="outline"
size="medium"
icon-only
:aria-label="t('iconPicker.skinTone.selectSkinTone')"
data-test-id="emoji-skin-tone-trigger"
>
<span :class="$style.triggerEmoji">{{ displayEmoji }}</span>
</N8nButton>
</template>
<template #content>
<div
:class="$style.toneRow"
role="radiogroup"
:aria-label="t('iconPicker.skinTone.tooltip')"
data-test-id="emoji-skin-tone-popover"
>
<button
v-for="tone in tones"
:key="tone.index"
:class="[$style.toneSwatch, { [$style.active]: model === tone.index }]"
type="button"
role="radio"
:aria-checked="model === tone.index"
:aria-label="t(tone.labelKey)"
:title="t(tone.labelKey)"
:data-test-id="`skin-tone-${tone.index}`"
@click="selectTone(tone.index)"
>
<span :class="$style.toneEmoji">{{ tone.emoji }}</span>
</button>
</div>
</template>
</N8nPopover>
</template>
<style module lang="scss">
.triggerButton {
flex-shrink: 0;
}
.triggerEmoji {
font-size: var(--font-size--lg);
line-height: 1;
font-family:
'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla',
'Noto Color Emoji', 'Android Emoji', sans-serif;
}
.toneRow {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
padding: var(--spacing--2xs);
}
.toneSwatch {
display: flex;
align-items: center;
justify-content: center;
width: var(--icon-picker--skin-tone-swatch--size, 34px);
height: var(--icon-picker--skin-tone-swatch--size, 34px);
padding: 0;
border: var(--border-width) solid transparent;
border-radius: var(--radius--sm);
cursor: pointer;
background: none;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
&:hover {
background-color: var(--color--background--shade-1);
}
&.active {
border-color: var(--color--primary);
}
}
.toneEmoji {
font-size: var(--font-size--xl);
line-height: 1;
font-family:
'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji', 'Twemoji Mozilla',
'Noto Color Emoji', 'Android Emoji', sans-serif;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
/**
* Lucide icon category definitions for the icon picker.
* Categories are listed in the official Lucide display order.
* @see https://lucide.dev/icons/categories
*
* Category data (per-icon) comes from the auto-generated lucideIconData.ts file,
* which fetches categories from the Lucide GitHub repo during generation.
* This file only defines the display metadata (title, order) for those categories.
*
* Some Lucide data slugs map to the same display category:
* - "currency" and "money" both map to "Finance"
* - "navigation" and "maps" both map to "Navigation, Maps & POIs"
*
* To regenerate the per-icon category data, run:
* node scripts/generate-lucide-icon-data.mjs
*/
export interface IconCategoryDefinition {
/** Unique key for this display category */
key: string;
/** i18n key for the section header label */
labelKey: string;
/** Lucide data slugs that map to this category */
slugs: string[];
}
/**
* Ordered list of icon categories matching the official Lucide categories page.
* Empty categories (no matching icons) are automatically excluded during rendering.
* @see https://lucide.dev/icons/categories
*/
export const ICON_CATEGORIES: IconCategoryDefinition[] = [
{
key: 'accessibility',
labelKey: 'iconPicker.iconSection.accessibility',
slugs: ['accessibility'],
},
{ key: 'account', labelKey: 'iconPicker.iconSection.account', slugs: ['account'] },
{ key: 'animals', labelKey: 'iconPicker.iconSection.animals', slugs: ['animals'] },
{ key: 'arrows', labelKey: 'iconPicker.iconSection.arrows', slugs: ['arrows'] },
{ key: 'brands', labelKey: 'iconPicker.iconSection.brands', slugs: ['brands'] },
{ key: 'buildings', labelKey: 'iconPicker.iconSection.buildings', slugs: ['buildings'] },
{ key: 'charts', labelKey: 'iconPicker.iconSection.charts', slugs: ['charts'] },
{
key: 'communication',
labelKey: 'iconPicker.iconSection.communication',
slugs: ['communication'],
},
{ key: 'connectivity', labelKey: 'iconPicker.iconSection.connectivity', slugs: ['connectivity'] },
{ key: 'cursors', labelKey: 'iconPicker.iconSection.cursors', slugs: ['cursors'] },
{ key: 'design', labelKey: 'iconPicker.iconSection.design', slugs: ['design'] },
{ key: 'development', labelKey: 'iconPicker.iconSection.development', slugs: ['development'] },
{ key: 'devices', labelKey: 'iconPicker.iconSection.devices', slugs: ['devices'] },
{ key: 'emoji', labelKey: 'iconPicker.iconSection.emoji', slugs: ['emoji'] },
{ key: 'files', labelKey: 'iconPicker.iconSection.files', slugs: ['files'] },
{ key: 'finance', labelKey: 'iconPicker.iconSection.finance', slugs: ['currency', 'money'] },
{
key: 'food-beverage',
labelKey: 'iconPicker.iconSection.foodBeverage',
slugs: ['food-beverage'],
},
{ key: 'gaming', labelKey: 'iconPicker.iconSection.gaming', slugs: ['gaming'] },
{ key: 'home', labelKey: 'iconPicker.iconSection.home', slugs: ['home'] },
{ key: 'layout', labelKey: 'iconPicker.iconSection.layout', slugs: ['layout'] },
{ key: 'mail', labelKey: 'iconPicker.iconSection.mail', slugs: ['mail'] },
{ key: 'math', labelKey: 'iconPicker.iconSection.math', slugs: ['math'] },
{ key: 'medical', labelKey: 'iconPicker.iconSection.medical', slugs: ['medical'] },
{ key: 'multimedia', labelKey: 'iconPicker.iconSection.multimedia', slugs: ['multimedia'] },
{ key: 'nature', labelKey: 'iconPicker.iconSection.nature', slugs: ['nature'] },
{
key: 'navigation',
labelKey: 'iconPicker.iconSection.navigation',
slugs: ['navigation', 'maps'],
},
{
key: 'notifications',
labelKey: 'iconPicker.iconSection.notifications',
slugs: ['notifications'],
},
{ key: 'people', labelKey: 'iconPicker.iconSection.people', slugs: ['people'] },
{ key: 'photography', labelKey: 'iconPicker.iconSection.photography', slugs: ['photography'] },
{ key: 'science', labelKey: 'iconPicker.iconSection.science', slugs: ['science'] },
{ key: 'seasons', labelKey: 'iconPicker.iconSection.seasons', slugs: ['seasons'] },
{ key: 'security', labelKey: 'iconPicker.iconSection.security', slugs: ['security'] },
{ key: 'shapes', labelKey: 'iconPicker.iconSection.shapes', slugs: ['shapes'] },
{ key: 'shopping', labelKey: 'iconPicker.iconSection.shopping', slugs: ['shopping'] },
{ key: 'social', labelKey: 'iconPicker.iconSection.social', slugs: ['social'] },
{ key: 'sports', labelKey: 'iconPicker.iconSection.sports', slugs: ['sports'] },
{
key: 'sustainability',
labelKey: 'iconPicker.iconSection.sustainability',
slugs: ['sustainability'],
},
{ key: 'text', labelKey: 'iconPicker.iconSection.text', slugs: ['text'] },
{ key: 'time', labelKey: 'iconPicker.iconSection.time', slugs: ['time'] },
{ key: 'tools', labelKey: 'iconPicker.iconSection.tools', slugs: ['tools'] },
{
key: 'transportation',
labelKey: 'iconPicker.iconSection.transportation',
slugs: ['transportation'],
},
{ key: 'travel', labelKey: 'iconPicker.iconSection.travel', slugs: ['travel'] },
{ key: 'weather', labelKey: 'iconPicker.iconSection.weather', slugs: ['weather'] },
];
/** "Other" section for icons without any recognized category */
export const OTHER_CATEGORY: IconCategoryDefinition = {
key: 'other',
labelKey: 'iconPicker.iconSection.other',
slugs: [],
};
/**
* Reverse lookup: Lucide data slug display category key.
* Built once from ICON_CATEGORIES at import time.
*/
const SLUG_TO_CATEGORY_KEY: Record<string, string> = Object.fromEntries(
ICON_CATEGORIES.flatMap((cat) => cat.slugs.map((slug) => [slug, cat.key])),
);
/**
* Returns the display category key for an icon based on its first recognized category slug.
* Falls back to 'other' if no category slug matches any defined category.
*
* This ensures each icon appears in exactly one section its "primary" category
* is determined by the first slug in its categories array that maps to a known
* display category.
*/
export function getPrimaryCategoryKey(categories: string[]): string {
for (const slug of categories) {
const key = SLUG_TO_CATEGORY_KEY[slug];
if (key !== undefined) return key;
}
return 'other';
}
@@ -0,0 +1,32 @@
import { ICON_PICKER_BLOCKLIST } from './iconPickerBlocklist';
describe('ICON_PICKER_BLOCKLIST', () => {
it('is a non-empty Set', () => {
expect(ICON_PICKER_BLOCKLIST).toBeInstanceOf(Set);
expect(ICON_PICKER_BLOCKLIST.size).toBeGreaterThan(0);
});
it('contains only strings', () => {
for (const entry of ICON_PICKER_BLOCKLIST) {
expect(typeof entry).toBe('string');
expect(entry.length).toBeGreaterThan(0);
}
});
it('contains expected navigation icons', () => {
// Spot-check a few critical icons that must always be blocked
expect(ICON_PICKER_BLOCKLIST.has('house')).toBe(true);
expect(ICON_PICKER_BLOCKLIST.has('settings')).toBe(true);
expect(ICON_PICKER_BLOCKLIST.has('search')).toBe(true);
expect(ICON_PICKER_BLOCKLIST.has('git-branch')).toBe(true);
// Shadowed by the branded multicolor custom SVG in N8nIcon's static set
expect(ICON_PICKER_BLOCKLIST.has('slack')).toBe(true);
});
it('uses kebab-case icon names (Lucide convention)', () => {
for (const entry of ICON_PICKER_BLOCKLIST) {
// No uppercase letters, no spaces — kebab-case only
expect(entry).toMatch(/^[a-z0-9-]+$/);
}
});
});
@@ -0,0 +1,123 @@
/**
* Icons that are excluded from the project icon picker because they are
* already used in n8n's navigation, settings, or environment UI.
* Selecting these as project icons would create visual confusion between
* projects and core application functions.
*
* To update: search the codebase for icon usage in sidebar, header,
* settings, and environment components.
*
* Last audited: 2026-02-11
*/
export const ICON_PICKER_BLOCKLIST: ReadonlySet<string> = new Set([
// --- Sidebar / Main Navigation ---
'house', // Home / Overview page
'search', // Global search / Command palette (Cmd+K)
'plus', // Create new workflow / credential button
'panel-left', // Sidebar expand / collapse toggle
'lock', // Read-only environment indicator
'share', // "Shared with me" section
'user', // Personal project link
'message-circle', // Chat hub navigation link
'cloud', // Cloud admin panel link
'lightbulb', // Resource center
'package-open', // Templates link
'chart-column-decreasing', // Insights analytics page
'circle-help', // Help menu
'video', // Help > Quickstart video
'book', // Help > Documentation link
'users', // Help > Community forum link
'graduation-cap', // Help > Courses link
'bug', // Help > Report bug link
'info', // About n8n dialog
'settings', // Settings entry point (gear icon)
'door-open', // Sign out / Logout
'external-link', // Full changelog link
'ellipsis', // User menu trigger (three dots)
'layers', // Default team project icon fallback
// --- Chat Sidebar ---
'message-square', // Chat > Personal agents link
'bot', // Chat > Workflow agents link
// --- Settings UI (section icons) ---
'arrow-left', // Settings sidebar back / return button
'circle-user-round', // Personal settings section
'user-round', // Users management / Project roles section
'sparkles', // AI settings section
'plug', // API settings section
'vault', // External secrets section
'key-round', // Credential resolvers section
'user-lock', // SSO settings section
'shield', // Security settings section
'network', // LDAP settings section
'waypoints', // Workers view section
'log-in', // Log streaming section
'box', // Community nodes section
'list-checks', // Migration report section
'mcp', // MCP Server settings section
// --- Source Control / Environment ---
'git-branch', // Branch / environment indicator
'git-branch-plus',
'git-commit-horizontal',
'git-commit-vertical',
'git-compare',
'git-compare-arrows',
'git-fork',
'git-graph',
'git-merge',
'git-pull-request',
'git-pull-request-arrow',
'git-pull-request-closed',
'git-pull-request-create',
'git-pull-request-create-arrow',
'git-pull-request-draft',
'folder-git',
'folder-git-2',
'merge',
'arrow-down', // Pull from remote button
'arrow-up', // Push to remote button
// --- Shadowed by branded multicolor SVGs ---
// N8nIcon resolves the static custom icon set before the Lucide fallback,
// so these names render as the brand logo (hardcoded fills), not the
// monochrome Lucide glyph shown alongside the rest of the picker grid.
'slack',
// --- Resembles core UI elements ---
'cog', // Resembles settings gear icon
'bell', // Resembles notifications
'bell-dot',
'bell-electric',
'bell-minus',
'bell-off',
'bell-plus',
'bell-ring',
'chevron-down', // Navigation chevrons
'chevron-first',
'chevron-last',
'chevron-left',
'chevron-right',
'chevron-up',
'chevrons-down',
'chevrons-down-up',
'chevrons-left',
'chevrons-left-right',
'chevrons-left-right-ellipsis',
'chevrons-right',
'chevrons-right-left',
'chevrons-up',
'chevrons-up-down',
'database', // Resembles data tables
'database-backup',
'database-zap',
'table', // Resembles tables
'table-2',
'table-cells-merge',
'table-cells-split',
'table-columns-split',
'table-of-contents',
'table-properties',
'table-rows-split',
]);
@@ -3,10 +3,14 @@ import { type IconName } from '../N8nIcon/icons';
export type IconOrEmoji =
| {
type: 'icon';
value: IconName;
/** Icon name — can be a registered IconName or any Lucide icon name */
value: IconName | (string & {});
/** Optional CSS variable name for icon color (e.g. '--node--icon--color--blue') */
color?: string;
}
| {
type: 'emoji';
/** Emoji unicode character (may include skin tone modifier) */
value: string;
};
@@ -0,0 +1,131 @@
import { refDebounced } from '@vueuse/core';
import { computed, type Ref } from 'vue';
import type { EmojiSection, EmojiEntry } from './emojiData';
import { ICON_CATEGORIES, OTHER_CATEGORY, getPrimaryCategoryKey } from './iconCategories';
import type { LucideIconMeta } from './lucideIconData';
export interface DisplayEmojiEntry extends EmojiEntry {
/** The emoji to display (with skin tone applied if applicable) */
display: string;
}
export interface DisplayEmojiSection {
key: string;
labelKey: string;
emojis: DisplayEmojiEntry[];
}
export interface IconSection {
key: string;
labelKey: string;
icons: Array<[string, LucideIconMeta]>;
}
export function useIconPickerSearch(
lucideData: Ref<Record<string, LucideIconMeta> | null>,
emojiSectionsData: Ref<EmojiSection[]>,
query: Ref<string>,
selectedCategory: Ref<string | null>,
selectedSkinTone: Ref<number>,
delay = 150,
) {
const debouncedQuery = refDebounced(query, delay);
/** Flat filtered icon list — used when search is active */
const filteredIcons = computed<Array<[string, LucideIconMeta]>>(() => {
if (!lucideData.value) return [];
let entries = Object.entries(lucideData.value);
// Filter by category if selected
if (selectedCategory.value) {
const cat = selectedCategory.value;
entries = entries.filter(([, icon]) => icon.categories.includes(cat));
}
// Filter by search query. Tokenize so multi-word queries ("alarm clock")
// match when every token is found in the name or any keyword.
const q = debouncedQuery.value.toLowerCase().trim();
if (q) {
const tokens = q.split(/\s+/);
entries = entries.filter(([name, icon]) =>
tokens.every(
(token) => name.includes(token) || icon.keywords.some((kw) => kw.includes(token)),
),
);
}
return entries;
});
/**
* Icons grouped by category used when browsing (no search query).
* Each icon appears in exactly one section based on its primary category
* (first recognized slug in its categories array).
* Sections are ordered to match the official Lucide categories page.
*/
const filteredIconSections = computed<IconSection[]>(() => {
if (!lucideData.value) return [];
const entries = Object.entries(lucideData.value);
// Group icons by their primary category
const categoryMap = new Map<string, Array<[string, LucideIconMeta]>>();
for (const entry of entries) {
const catKey = getPrimaryCategoryKey(entry[1].categories);
let bucket = categoryMap.get(catKey);
if (!bucket) {
bucket = [];
categoryMap.set(catKey, bucket);
}
bucket.push(entry);
}
// Build sections in defined order, skip empty ones
const sections: IconSection[] = [];
for (const def of ICON_CATEGORIES) {
const icons = categoryMap.get(def.key);
if (icons && icons.length > 0) {
sections.push({ key: def.key, labelKey: def.labelKey, icons });
}
}
// Add "Other" for uncategorized icons at the end
const otherIcons = categoryMap.get('other');
if (otherIcons && otherIcons.length > 0) {
sections.push({
key: OTHER_CATEGORY.key,
labelKey: OTHER_CATEGORY.labelKey,
icons: otherIcons,
});
}
return sections;
});
const filteredEmojiSections = computed<DisplayEmojiSection[]>(() => {
const q = debouncedQuery.value.toLowerCase().trim();
const tokens = q ? q.split(/\s+/) : [];
const tone = selectedSkinTone.value;
return emojiSectionsData.value
.map((section) => {
let emojis = section.emojis;
if (q) {
// Tokenize so multi-word queries ("grinning face", "thumbs up")
// match when every token is found in any keyword.
emojis = emojis.filter((e) =>
tokens.every((token) => e.k.some((kw) => kw.includes(token))),
);
}
// Apply skin tone to display
const displayEmojis: DisplayEmojiEntry[] = emojis.map((e) => ({
...e,
display: tone > 0 && e.s ? e.s[tone - 1] : e.u,
}));
return { key: section.key, labelKey: section.labelKey, emojis: displayEmojis };
})
.filter((section) => section.emojis.length > 0);
});
return { filteredIcons, filteredIconSections, filteredEmojiSections, debouncedQuery };
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import type { LucideIconMeta } from './lucideIconData';
import type { DisplayEmojiSection } from './useIconPickerSearch';
import {
buildIconBrowseRows,
buildIconSearchRows,
buildEmojiRows,
} from './useIconPickerVirtualRows';
function iconMeta(): LucideIconMeta {
return { keywords: [], categories: [] };
}
describe('useIconPickerVirtualRows', () => {
it('builds browse rows with headers and 10-icon chunks', () => {
const rows = buildIconBrowseRows([
{
key: 'design',
labelKey: 'iconPicker.iconSection.design',
icons: Array.from({ length: 12 }, (_, index) => [`icon-${index + 1}`, iconMeta()]),
},
]);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({
type: 'header',
labelKey: 'iconPicker.iconSection.design',
});
expect(rows[1]).toMatchObject({
type: 'icon-row',
iconNames: [
'icon-1',
'icon-2',
'icon-3',
'icon-4',
'icon-5',
'icon-6',
'icon-7',
'icon-8',
'icon-9',
'icon-10',
],
});
expect(rows[2]).toMatchObject({
type: 'icon-row',
iconNames: ['icon-11', 'icon-12'],
});
});
it('builds search rows without headers', () => {
const rows = buildIconSearchRows(
Array.from({ length: 11 }, (_, index) => [`search-icon-${index + 1}`, iconMeta()]),
);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({
type: 'icon-row',
iconNames: [
'search-icon-1',
'search-icon-2',
'search-icon-3',
'search-icon-4',
'search-icon-5',
'search-icon-6',
'search-icon-7',
'search-icon-8',
'search-icon-9',
'search-icon-10',
],
});
expect(rows[1]).toMatchObject({
type: 'icon-row',
iconNames: ['search-icon-11'],
});
});
it('builds emoji rows with headers and emoji chunks', () => {
const rows = buildEmojiRows([
{
key: 'people',
labelKey: 'iconPicker.emojiSection.people',
emojis: Array.from({ length: 11 }, (_, index) => ({
u: `😀${index}`,
l: `Emoji ${index + 1}`,
k: [],
display: `😀${index}`,
})),
} satisfies DisplayEmojiSection,
]);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({
type: 'header',
labelKey: 'iconPicker.emojiSection.people',
});
expect(rows[1]).toMatchObject({
type: 'emoji-row',
});
expect(rows[2]).toMatchObject({
type: 'emoji-row',
});
});
});
@@ -0,0 +1,105 @@
import type { LucideIconMeta } from './lucideIconData';
import type { DisplayEmojiEntry, DisplayEmojiSection, IconSection } from './useIconPickerSearch';
const DEFAULT_COLUMNS = 10;
type IconSectionRowBase = {
sectionKey: string;
labelKey: string;
};
export type IconPickerHeaderRow = IconSectionRowBase & {
id: string;
type: 'header';
};
export type IconPickerIconRow = Partial<IconSectionRowBase> & {
id: string;
type: 'icon-row';
iconNames: string[];
};
export type IconPickerEmojiRow = IconSectionRowBase & {
id: string;
type: 'emoji-row';
emojis: DisplayEmojiEntry[];
};
export type IconPickerVirtualRow = IconPickerHeaderRow | IconPickerIconRow | IconPickerEmojiRow;
function chunkItems<T>(items: T[], columns: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += columns) {
rows.push(items.slice(index, index + columns));
}
return rows;
}
export function buildIconBrowseRows(
sections: IconSection[],
columns = DEFAULT_COLUMNS,
): IconPickerVirtualRow[] {
const rows: IconPickerVirtualRow[] = [];
for (const section of sections) {
rows.push({
id: `header-${section.key}`,
type: 'header',
sectionKey: section.key,
labelKey: section.labelKey,
});
for (const [rowIndex, row] of chunkItems(section.icons, columns).entries()) {
rows.push({
id: `icons-${section.key}-${rowIndex}`,
type: 'icon-row',
sectionKey: section.key,
labelKey: section.labelKey,
iconNames: row.map(([name]) => name),
});
}
}
return rows;
}
export function buildIconSearchRows(
icons: Array<[string, LucideIconMeta]>,
columns = DEFAULT_COLUMNS,
): IconPickerVirtualRow[] {
return chunkItems(icons, columns).map((row, rowIndex) => ({
id: `search-icons-${rowIndex}`,
type: 'icon-row',
iconNames: row.map(([name]) => name),
}));
}
export function buildEmojiRows(
sections: DisplayEmojiSection[],
columns = DEFAULT_COLUMNS,
): IconPickerVirtualRow[] {
const rows: IconPickerVirtualRow[] = [];
for (const section of sections) {
rows.push({
id: `header-${section.key}`,
type: 'header',
sectionKey: section.key,
labelKey: section.labelKey,
});
for (const [rowIndex, row] of chunkItems(section.emojis, columns).entries()) {
rows.push({
id: `emojis-${section.key}-${rowIndex}`,
type: 'emoji-row',
sectionKey: section.key,
labelKey: section.labelKey,
emojis: row,
});
}
}
return rows;
}
@@ -49,7 +49,7 @@ const handleClick = () => {
emit('click');
};
const icon = computed<IconName | undefined>(() => {
const icon = computed<IconName | (string & {}) | undefined>(() => {
if (typeof props.item.icon === 'object' && props.item.icon?.type === 'icon') {
return props.item.icon.value;
}
@@ -122,7 +122,6 @@ const tooltipPlacement = computed(() => {
<N8nText
v-if="item.icon && typeof item.icon === 'object' && item.icon.type === 'emoji'"
:class="$style.menuItemEmoji"
:color="iconColor"
>{{ item.icon.value }}</N8nText
>
<N8nIcon v-else-if="icon" :color="iconColor" :icon="icon" />
@@ -0,0 +1,22 @@
import { inject, type InjectionKey } from 'vue';
export type IconBodyLoader = (name: string) => Promise<string | null>;
export const IconBodyLoaderKey = Symbol('IconBodyLoader') as InjectionKey<IconBodyLoader>;
let warnedMissingLoader = false;
const noopLoader: IconBodyLoader = async () => {
if (import.meta.env.DEV && !warnedMissingLoader) {
warnedMissingLoader = true;
console.warn(
'[n8n-design-system] No IconBodyLoader provided — icons outside the bundled set will render empty. ' +
'Provide one via app.provide(IconBodyLoaderKey, loader), e.g. loadLucideIconBody from @n8n/design-system/icons/lucide.',
);
}
return null;
};
export function useInjectIconBodyLoader(): IconBodyLoader {
return inject(IconBodyLoaderKey, noopLoader);
}
@@ -0,0 +1,21 @@
import { BUCKET_COUNT, getBucketIndex } from './bucket';
describe('getBucketIndex', () => {
it('returns stable bucket indices (changing these busts all icon chunk caches)', () => {
expect(getBucketIndex('a-arrow-down')).toBe(11);
expect(getBucketIndex('acorn')).toBe(6);
expect(getBucketIndex('smile')).toBe(15);
expect(getBucketIndex('star')).toBe(1);
expect(getBucketIndex('zap')).toBe(12);
});
it('returns integer indices within [0, BUCKET_COUNT)', () => {
const names = ['', 'a', 'zap', 'layout-grid', 'a-very-long-icon-name-with-many-segments'];
for (const name of names) {
const index = getBucketIndex(name);
expect(Number.isInteger(index)).toBe(true);
expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThan(BUCKET_COUNT);
}
});
});
@@ -0,0 +1,23 @@
/**
* Icon names are partitioned into a fixed number of buckets by name hash. The
* Vite plugin (`./vite.ts`) emits one virtual module per bucket, and the
* runtime loader (`./index.ts`) uses the same function to know which bucket to
* import for a given icon sharing this module is what keeps the two in sync.
*
* FNV-1a is stable per name, so adding or removing icons never moves existing
* names between buckets; an icon set update only invalidates the chunks whose
* contents actually changed.
*/
/** Changing this reshuffles every bucket → one-time full cache bust on deploy. */
export const BUCKET_COUNT = 16;
/** FNV-1a 32-bit hash of the icon name, mapped onto [0, BUCKET_COUNT). */
export function getBucketIndex(name: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < name.length; index++) {
hash ^= name.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) % BUCKET_COUNT;
}
@@ -0,0 +1,72 @@
import { vi } from 'vitest';
import type * as BucketModule from './bucket';
import { getBucketIndex } from './bucket';
import { loadLucideIconBody } from './index';
const mockState = vi.hoisted(() => ({
bucketLoadCounts: new Map<number, number>(),
failBuckets: new Set<number>(),
bodies: {
anchor: '<path data-icon="anchor" />',
badge: '<path data-icon="badge" />',
bell: '<path data-icon="bell" />',
cake: '<path data-icon="cake" />',
} as Record<string, string>,
}));
vi.mock('virtual:lucide-icons', async () => {
const { BUCKET_COUNT, getBucketIndex: getActualBucketIndex } =
await vi.importActual<typeof BucketModule>('./bucket');
const buckets = Array.from({ length: BUCKET_COUNT }, (): Record<string, string> => ({}));
for (const [name, body] of Object.entries(mockState.bodies)) {
const bucket = buckets[getActualBucketIndex(name)];
if (bucket) bucket[name] = body;
}
return {
default: buckets.map((bucket, index) => async () => {
mockState.bucketLoadCounts.set(index, (mockState.bucketLoadCounts.get(index) ?? 0) + 1);
if (mockState.failBuckets.has(index)) {
mockState.failBuckets.delete(index);
throw new Error('Failed to fetch bucket');
}
return { default: bucket };
}),
};
});
describe('loadLucideIconBody', () => {
it('returns the SVG body string for a known icon', async () => {
await expect(loadLucideIconBody('anchor')).resolves.toBe('<path data-icon="anchor" />');
});
it('returns null for an unknown icon name', async () => {
await expect(loadLucideIconBody('this-icon-does-not-exist')).resolves.toBeNull();
});
it('loads a bucket once for concurrent same-bucket requests', async () => {
const bucketIndex = getBucketIndex('badge');
// Fixture precondition: both names must hash into the same bucket
expect(getBucketIndex('bell')).toBe(bucketIndex);
const [badge, bell] = await Promise.all([
loadLucideIconBody('badge'),
loadLucideIconBody('bell'),
]);
expect(badge).toBe('<path data-icon="badge" />');
expect(bell).toBe('<path data-icon="bell" />');
expect(mockState.bucketLoadCounts.get(bucketIndex)).toBe(1);
});
it('retries a bucket after a failed load', async () => {
const bucketIndex = getBucketIndex('cake');
mockState.failBuckets.add(bucketIndex);
await expect(loadLucideIconBody('cake')).rejects.toThrow('Failed to fetch bucket');
await expect(loadLucideIconBody('cake')).resolves.toBe('<path data-icon="cake" />');
expect(mockState.bucketLoadCounts.get(bucketIndex)).toBe(2);
});
});
@@ -0,0 +1,23 @@
/// <reference path="./shims.d.ts" />
import bucketLoaders from 'virtual:lucide-icons';
import { getBucketIndex } from './bucket';
import type { IconBodyLoader } from '../../composables/useIconBodyLoader';
const buckets = new Map<number, Promise<Record<string, string>>>();
export const loadLucideIconBody: IconBodyLoader = async (name) => {
const index = getBucketIndex(name);
let bucket = buckets.get(index);
if (!bucket) {
const loadBucket = bucketLoaders[index];
if (!loadBucket) return null;
bucket = loadBucket().then((module) => module.default);
// Cache the promise (not the result) so concurrent requests for icons in
// the same bucket share a single fetch.
buckets.set(index, bucket);
// Evict failed loads so a later render retries instead of staying blank.
bucket.catch(() => buckets.delete(index));
}
return (await bucket)[name] ?? null;
};
@@ -0,0 +1,4 @@
declare module 'virtual:lucide-icons' {
const bucketLoaders: ReadonlyArray<() => Promise<{ default: Record<string, string> }>>;
export default bucketLoaders;
}
@@ -0,0 +1,83 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import type { Plugin } from 'vite';
import { BUCKET_COUNT, getBucketIndex } from './bucket';
const PREFIX = 'virtual:lucide-icons';
const RESOLVED_PREFIX = '\0' + PREFIX;
// The id basename becomes the emitted chunk name, so keep it self-documenting
// (`lucide-icons-bucket-7-<hash>.js` in dist instead of `7-<hash>.js`).
const BUCKET_PREFIX = `${PREFIX}/lucide-icons-bucket-`;
const RESOLVED_BUCKET_PREFIX = `${RESOLVED_PREFIX}/lucide-icons-bucket-`;
interface IconifyJson {
icons: Record<string, { body: string }>;
}
/**
* Vite plugin that exposes Lucide SVG bodies as hash-bucketed dynamic-import chunks.
*
* Single virtual module prefix:
* `virtual:lucide-icons` default export: array of BUCKET_COUNT lazy bucket loaders
* `virtual:lucide-icons/lucide-icons-bucket-<n>` default export: record of icon name SVG body
*
* Icons are assigned to buckets via `getBucketIndex` (shared with the runtime
* loader in `./index.ts`), so the loader can compute the right chunk for any
* icon name without shipping a per-icon import map.
*/
export function lucideIconsPlugin(): Plugin {
let buckets: Array<Record<string, string>> | null = null;
const getBuckets = (): Array<Record<string, string>> => {
if (!buckets) {
const require = createRequire(import.meta.url);
const jsonPath = require.resolve('@iconify/json/json/lucide.json');
const raw = readFileSync(jsonPath, 'utf-8');
let icons: IconifyJson['icons'];
try {
icons = (JSON.parse(raw) as IconifyJson).icons;
} catch (cause) {
throw new Error(`Failed to parse @iconify/json/json/lucide.json at ${jsonPath}`, {
cause,
});
}
const partitioned: Array<Record<string, string>> = Array.from(
{ length: BUCKET_COUNT },
() => ({}),
);
for (const [name, icon] of Object.entries(icons)) {
const bucket = partitioned[getBucketIndex(name)];
if (bucket) bucket[name] = icon.body;
}
buckets = partitioned;
}
return buckets;
};
return {
name: 'n8n:lucide-icons',
resolveId(id) {
if (id === PREFIX || id.startsWith(BUCKET_PREFIX)) return '\0' + id;
return undefined;
},
load(id) {
if (id === RESOLVED_PREFIX) {
const thunks = Array.from(
{ length: BUCKET_COUNT },
(_, index) => `() => import('${BUCKET_PREFIX}${index}')`,
);
return `export default [${thunks.join(', ')}];`;
}
if (id.startsWith(RESOLVED_BUCKET_PREFIX)) {
// Only the generated loader array above references bucket ids, so any
// malformed or out-of-range id is a plugin bug — fail the build loudly.
const suffix = id.slice(RESOLVED_BUCKET_PREFIX.length);
const bucket = /^\d+$/.test(suffix) ? getBuckets()[Number(suffix)] : undefined;
if (!bucket) throw new Error(`Unknown lucide icon bucket module: ${id}`);
return `export default ${JSON.stringify(bucket)};`;
}
return undefined;
},
};
}
@@ -7,6 +7,8 @@ export * from './types';
export * from './utils';
export * from './directives';
export type { IconOrEmoji } from './components/N8nIconPicker/types';
export { IconBodyLoaderKey, useInjectIconBodyLoader } from './composables/useIconBodyLoader';
export type { IconBodyLoader } from './composables/useIconBodyLoader';
export { default as N8nSelect2 } from './v2/components/Select/Select.vue';
export { default as N8nSelect2Item } from './v2/components/Select/SelectItem.vue';
export type * from './v2/components/Select/Select.types';
@@ -89,7 +89,83 @@ export default {
'iconPicker.tabs.icons': 'Icons',
'iconPicker.tabs.emojis': 'Emojis',
'iconPicker.search.placeholder': 'Search...',
'iconPicker.random': 'Random',
'iconPicker.search.noResults': 'No results found',
'iconPicker.loading': 'Loading...',
'iconPicker.random.icon': 'Pick a random icon',
'iconPicker.random.emoji': 'Pick a random emoji',
'iconPicker.colorPicker.tooltip': 'Icon color',
'iconPicker.colorPicker.selectColor': 'Select icon color',
'iconPicker.skinTone.tooltip': 'Skin tone',
'iconPicker.skinTone.selectSkinTone': 'Select skin tone',
'iconPicker.skinTone.default': 'Default',
'iconPicker.skinTone.light': 'Light',
'iconPicker.skinTone.mediumLight': 'Medium-Light',
'iconPicker.skinTone.medium': 'Medium',
'iconPicker.skinTone.mediumDark': 'Medium-Dark',
'iconPicker.skinTone.dark': 'Dark',
'iconPicker.colorPicker.blue': 'Blue',
'iconPicker.colorPicker.lightBlue': 'Light Blue',
'iconPicker.colorPicker.azure': 'Azure',
'iconPicker.colorPicker.purple': 'Purple',
'iconPicker.colorPicker.pink': 'Pink',
'iconPicker.colorPicker.red': 'Red',
'iconPicker.colorPicker.orange': 'Orange',
'iconPicker.colorPicker.green': 'Green',
'iconPicker.colorPicker.darkGreen': 'Dark Green',
'iconPicker.colorPicker.gray': 'Gray',
'iconPicker.category.all': 'All',
'iconPicker.iconSection.accessibility': 'Accessibility',
'iconPicker.iconSection.account': 'Accounts & Access',
'iconPicker.iconSection.animals': 'Animals',
'iconPicker.iconSection.arrows': 'Arrows',
'iconPicker.iconSection.brands': 'Brands',
'iconPicker.iconSection.buildings': 'Buildings',
'iconPicker.iconSection.charts': 'Charts',
'iconPicker.iconSection.communication': 'Communication',
'iconPicker.iconSection.connectivity': 'Connectivity',
'iconPicker.iconSection.cursors': 'Cursors',
'iconPicker.iconSection.design': 'Design',
'iconPicker.iconSection.development': 'Coding & Development',
'iconPicker.iconSection.devices': 'Devices',
'iconPicker.iconSection.emoji': 'Emoji',
'iconPicker.iconSection.files': 'File Icons',
'iconPicker.iconSection.finance': 'Finance',
'iconPicker.iconSection.foodBeverage': 'Food & Beverage',
'iconPicker.iconSection.gaming': 'Gaming',
'iconPicker.iconSection.home': 'Home',
'iconPicker.iconSection.layout': 'Layout',
'iconPicker.iconSection.mail': 'Mail',
'iconPicker.iconSection.math': 'Mathematics',
'iconPicker.iconSection.medical': 'Medical',
'iconPicker.iconSection.multimedia': 'Multimedia',
'iconPicker.iconSection.nature': 'Nature',
'iconPicker.iconSection.navigation': 'Navigation, Maps & POIs',
'iconPicker.iconSection.notifications': 'Notifications',
'iconPicker.iconSection.people': 'People',
'iconPicker.iconSection.photography': 'Photography',
'iconPicker.iconSection.science': 'Science',
'iconPicker.iconSection.seasons': 'Seasons',
'iconPicker.iconSection.security': 'Security',
'iconPicker.iconSection.shapes': 'Shapes',
'iconPicker.iconSection.shopping': 'Shopping',
'iconPicker.iconSection.social': 'Social',
'iconPicker.iconSection.sports': 'Sports',
'iconPicker.iconSection.sustainability': 'Sustainability',
'iconPicker.iconSection.text': 'Text Formatting',
'iconPicker.iconSection.time': 'Time & Calendar',
'iconPicker.iconSection.tools': 'Tools',
'iconPicker.iconSection.transportation': 'Transportation',
'iconPicker.iconSection.travel': 'Travel',
'iconPicker.iconSection.weather': 'Weather',
'iconPicker.iconSection.other': 'Other',
'iconPicker.emojiSection.people': 'Smileys & People',
'iconPicker.emojiSection.animalsNature': 'Animals & Nature',
'iconPicker.emojiSection.foodDrink': 'Food & Drink',
'iconPicker.emojiSection.activity': 'Activity',
'iconPicker.emojiSection.travelPlaces': 'Travel & Places',
'iconPicker.emojiSection.objects': 'Objects',
'iconPicker.emojiSection.symbols': 'Symbols',
'iconPicker.emojiSection.flags': 'Flags',
'actionDropdown.activator': 'Actions',
'askAssistantChat.close': 'Close',
'sendStopButton.stop': 'Stop',
@@ -10,8 +10,9 @@ export type IMenuItem = {
label: string;
icon?:
| IconName
| { type: 'icon'; value: IconName; color?: IconColor }
| { type: 'emoji'; value: string; color?: IconColor };
| (string & {})
| { type: 'icon'; value: IconName | (string & {}); color?: IconColor | (string & {}) }
| { type: 'emoji'; value: string; color?: IconColor | (string & {}) };
secondaryIcon?: {
name: IconName;
size?: 'xsmall' | 'small' | 'medium' | 'large';
@@ -4,6 +4,7 @@ import { defineConfig, mergeConfig } from 'vite';
import icons from 'unplugin-icons/vite';
import { vitestConfig } from '@n8n/vitest-config/frontend';
import svgLoader from 'vite-svg-loader';
import { lucideIconsPlugin } from './src/icons/lucide/vite';
const packagesDir = resolve(__dirname, '..', '..', '..');
@@ -11,6 +12,7 @@ export default mergeConfig(
defineConfig({
plugins: [
vue(),
lucideIconsPlugin(),
svgLoader({
svgoConfig: {
plugins: [
@@ -6,7 +6,8 @@ import lang from 'element-plus/dist/locale/en.mjs';
import { createPinia } from 'pinia';
import { createMemoryHistory, createRouter } from 'vue-router';
import { N8nPlugin } from '@n8n/design-system';
import { IconBodyLoaderKey, N8nPlugin } from '@n8n/design-system';
import { loadLucideIconBody } from '@n8n/design-system/icons/lucide';
import { i18nInstance } from '@n8n/i18n';
import './storybook.scss';
@@ -14,6 +15,8 @@ import { allModes } from './modes';
// import '../src/css/tailwind/index.css';
setup((app) => {
app.provide(IconBodyLoaderKey, loadLucideIconBody);
const pinia = createPinia();
app.use(pinia);
app.use(i18nInstance);
@@ -3,11 +3,13 @@ import vue from '@vitejs/plugin-vue';
import icons from 'unplugin-icons/vite';
import svgLoader from 'vite-svg-loader';
import path from 'path';
import { lucideIconsPlugin } from '../design-system/src/icons/lucide/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
lucideIconsPlugin(),
icons({
compiler: 'vue3',
autoInstall: true,
@@ -29,7 +29,7 @@ const props = withDefaults(defineProps<Props>(), {
v-if="icon.type === 'icon'"
:icon="icon.value"
:class="$style.icon"
:color="color"
:color="icon.color ?? color"
></N8nIcon>
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
{{ icon.value }}
@@ -92,7 +92,7 @@ const shared = computed<IMenuItem>(() => ({
const getProjectMenuItem = (project: ProjectListItem): IMenuItem => ({
id: project.id,
label: project.name ?? '',
icon: (project.icon as IMenuItem['icon']) ?? DEFAULT_PROJECT_ICON,
icon: (project.icon ?? DEFAULT_PROJECT_ICON) as IMenuItem['icon'],
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,
@@ -47,7 +47,7 @@ export function useFavoriteNavItems() {
menuItem: {
id: f.resourceId,
label: f.resourceName,
icon: (project?.icon as IMenuItem['icon']) ?? DEFAULT_PROJECT_ICON,
icon: (project?.icon ?? DEFAULT_PROJECT_ICON) as IMenuItem['icon'],
route: { to: { name: VIEWS.PROJECTS_WORKFLOWS, params: { projectId: f.resourceId } } },
},
resourceId: f.resourceId,
@@ -615,6 +615,7 @@ onMounted(async () => {
<N8nIconPicker
v-model="projectIcon"
:button-tooltip="i18n.baseText('projects.settings.iconPicker.button.tooltip')"
show-color-picker
@update:model-value="onIconUpdated"
/>
<N8nFormInput
+4
View File
@@ -18,6 +18,8 @@ import '@/app/dev/i18nHmr';
import App from '@/app/App.vue';
import router from '@/app/router';
import { IconBodyLoaderKey } from '@n8n/design-system';
import { loadLucideIconBody } from '@n8n/design-system/icons/lucide';
import { i18nInstance } from '@n8n/i18n';
import { TelemetryPlugin } from '@/app/plugins/telemetry';
@@ -39,6 +41,8 @@ const pinia = createPinia();
const app = createApp(App);
app.provide(IconBodyLoaderKey, loadLucideIconBody);
app.use(SentryPlugin);
// Register module routes
@@ -9,6 +9,7 @@ import { codecovVitePlugin } from '@codecov/vite-plugin';
import { vitestConfig } from '@n8n/vitest-config/frontend';
import icons from 'unplugin-icons/vite';
import { lucideIconsPlugin } from '../@n8n/design-system/src/icons/lucide/vite';
import browserslistToEsbuild from 'browserslist-to-esbuild';
import legacy from '@vitejs/plugin-legacy';
import browserslist from 'browserslist';
@@ -90,6 +91,7 @@ const { RELEASE: release } = process.env;
const plugins: UserConfig['plugins'] = [
nodePopularityPlugin(),
lucideIconsPlugin(),
icons({
compiler: 'vue3',
autoInstall: NODE_ENV === 'development',
+7 -4
View File
@@ -3969,9 +3969,6 @@ importers:
element-plus:
specifier: catalog:frontend
version: 2.4.3(patch_hash=fbab57fe3750e430abd5d5e7c04cbf1b6a8f9f1c9676b14c73b77d3e06ba9eee)(vue@3.5.26(typescript@6.0.2))
emojibase-data:
specifier: ^17.0.0
version: 17.0.0(emojibase@17.0.0)
is-emoji-supported:
specifier: ^0.0.5
version: 0.0.5
@@ -4012,6 +4009,9 @@ importers:
specifier: 'catalog:'
version: 1.0.15
devDependencies:
'@iconify/json':
specifier: ^2.2.349
version: 2.2.354
'@n8n/eslint-config':
specifier: workspace:*
version: link:../../../@n8n/eslint-config
@@ -4066,6 +4066,9 @@ importers:
autoprefixer:
specifier: ^10.4.19
version: 10.4.19(postcss@8.5.10)
emojibase-data:
specifier: ^17.0.0
version: 17.0.0(emojibase@17.0.0)
postcss:
specifier: 8.5.10
version: 8.5.10
@@ -21173,7 +21176,7 @@ packages:
engines: {node: '>=18'}
xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz:
resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz}
resolution: {integrity: sha512-+nKZ39+nvK7Qq6i0PvWWRA4j/EkfWOtkP/YhMtupm+lJIiHxUrgTr1CcKv1nBk1rHtkRRQ3O2+Ih/q/sA+FXZA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz}
version: 0.20.2
engines: {node: '>=0.8'}
hasBin: true
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env node
/**
* Generates emojiData.ts from emojibase-data compact English dataset.
*
* Usage: node scripts/generate-emoji-data.mjs
*
* Output: packages/frontend/@n8n/design-system/src/components/N8nIconPicker/emojiData.ts
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const require = createRequire(import.meta.url);
const OUTPUT_PATH = resolve(
ROOT,
'packages/frontend/@n8n/design-system/src/components/N8nIconPicker/emojiData.ts',
);
// Emojibase group IDs to section keys and i18n label keys
const GROUP_MAP = {
0: { key: 'people', labelKey: 'iconPicker.emojiSection.people' },
1: { key: 'people', labelKey: 'iconPicker.emojiSection.people' }, // People & Body merged into People
3: { key: 'animalsNature', labelKey: 'iconPicker.emojiSection.animalsNature' },
4: { key: 'foodDrink', labelKey: 'iconPicker.emojiSection.foodDrink' },
5: { key: 'travelPlaces', labelKey: 'iconPicker.emojiSection.travelPlaces' },
6: { key: 'activity', labelKey: 'iconPicker.emojiSection.activity' },
7: { key: 'objects', labelKey: 'iconPicker.emojiSection.objects' },
8: { key: 'symbols', labelKey: 'iconPicker.emojiSection.symbols' },
9: { key: 'flags', labelKey: 'iconPicker.emojiSection.flags' },
};
// Ordered section keys for output
const SECTION_ORDER = [
'people',
'animalsNature',
'foodDrink',
'activity',
'travelPlaces',
'objects',
'symbols',
'flags',
];
function buildKeywords(emoji) {
const words = new Set();
// Add label words
if (emoji.label) {
emoji.label
.toLowerCase()
.split(/[\s\-:,]+/)
.filter((w) => w.length > 0)
.forEach((w) => words.add(w));
}
// Add tags
if (emoji.tags) {
emoji.tags.forEach((t) => words.add(t.toLowerCase()));
}
return [...words];
}
/** Title-case an emoji label: "grinning face" → "Grinning Face" */
function titleCase(str) {
return str.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Whether a skin tone represents a uniform variation.
*
* Single-person emojis use a scalar tone (15). Multi-person emojis use one tone
* per person: emojibase collapses uniform combinations to a scalar but could also
* represent them as an array whose entries are all equal (e.g. [3, 3]). Mixed
* combinations such as [1, 2] are not uniform.
*/
function isUniformTone(tone) {
if (typeof tone === 'number') {
return true;
}
if (Array.isArray(tone) && tone.length > 0) {
return tone.every((value) => value === tone[0]);
}
return false;
}
/** Build a hexcode → tone lookup from the full dataset's skin variations. */
function buildToneByHexcode(fullData) {
const toneByHexcode = new Map();
for (const emoji of fullData) {
if (Array.isArray(emoji.skins)) {
for (const skin of emoji.skins) {
toneByHexcode.set(skin.hexcode, skin.tone);
}
}
}
return toneByHexcode;
}
function extractSkinTones(emoji, toneByHexcode) {
if (!emoji.skins || !Array.isArray(emoji.skins) || emoji.skins.length === 0) {
return undefined;
}
// Single-person emojis expose exactly five uniform tones. Multi-person emojis
// expose every tone combination, ordered with the first person fixed at the
// light tone — so the first five would be mixed pairs (light+light,
// light+medium-light, …). Keep only the uniform variations so the five swatches
// map cleanly onto [light, medium-light, medium, medium-dark, dark].
const tones = [];
for (const skin of emoji.skins) {
if (tones.length >= 5) break;
if (skin.unicode && isUniformTone(toneByHexcode.get(skin.hexcode))) {
tones.push(skin.unicode);
}
}
return tones.length === 5 ? tones : undefined;
}
function main() {
console.log('Reading emojibase-data compact English dataset...');
// Resolve from the design-system package where it's installed
const compactDataPath = require.resolve('emojibase-data/en/compact.json');
const rawData = JSON.parse(readFileSync(compactDataPath, 'utf-8'));
console.log(`Loaded ${rawData.length} emoji entries`);
// The compact dataset omits skin `tone` metadata, so load the full dataset to
// tell uniform skin-tone variations apart from mixed multi-person combinations.
const fullDataPath = require.resolve('emojibase-data/en/data.json');
const fullData = JSON.parse(readFileSync(fullDataPath, 'utf-8'));
const toneByHexcode = buildToneByHexcode(fullData);
// Group emojis into sections
const sections = {};
for (const sectionKey of SECTION_ORDER) {
sections[sectionKey] = [];
}
let skipped = 0;
let totalWithSkins = 0;
for (const emoji of rawData) {
// Skip entries without a group (e.g. component characters, regional indicators without group)
if (emoji.group === undefined || emoji.group === null) {
skipped++;
continue;
}
const groupInfo = GROUP_MAP[emoji.group];
if (!groupInfo) {
skipped++;
continue;
}
const keywords = buildKeywords(emoji);
const skins = extractSkinTones(emoji, toneByHexcode);
if (skins) totalWithSkins++;
const entry = {
u: emoji.unicode,
l: titleCase(emoji.label || ''),
k: keywords,
};
if (skins) {
entry.s = skins;
}
sections[groupInfo.key].push(entry);
}
// Build output
let totalEmojis = 0;
const sectionOutputs = [];
for (const sectionKey of SECTION_ORDER) {
const emojis = sections[sectionKey];
if (emojis.length === 0) continue;
totalEmojis += emojis.length;
const groupInfo = Object.values(GROUP_MAP).find((g) => g.key === sectionKey);
sectionOutputs.push({
key: sectionKey,
labelKey: groupInfo.labelKey,
emojis,
});
}
console.log(`Processed ${totalEmojis} emojis into ${sectionOutputs.length} sections (skipped ${skipped})`);
console.log(`${totalWithSkins} emojis have skin tone variants`);
// Generate TypeScript
let output = `// AUTO-GENERATED by scripts/generate-emoji-data.mjs — DO NOT EDIT
// Source: emojibase-data/en/compact.json
// Emojis: ${totalEmojis} | Sections: ${sectionOutputs.length} | With skin tones: ${totalWithSkins}
export interface EmojiEntry {
\t/** Emoji unicode character */
\tu: string;
\t/** Human-readable CLDR label (e.g. "Grinning Face") */
\tl: string;
\t/** Searchable keywords (label words + tags, lowercased) */
\tk: string[];
\t/** Skin tone variants [light, medium-light, medium, medium-dark, dark] */
\ts?: [string, string, string, string, string];
}
export interface EmojiSection {
\tkey: string;
\tlabelKey: string;
\temojis: EmojiEntry[];
}
export const emojiSections: EmojiSection[] = [\n`;
for (const section of sectionOutputs) {
output += `\t{\n`;
output += `\t\tkey: '${section.key}',\n`;
output += `\t\tlabelKey: '${section.labelKey}',\n`;
output += `\t\temojis: [\n`;
for (const emoji of section.emojis) {
const l = JSON.stringify(emoji.l);
const k = JSON.stringify(emoji.k);
if (emoji.s) {
const s = JSON.stringify(emoji.s);
output += `\t\t\t{ u: '${emoji.u}', l: ${l}, k: ${k}, s: ${s} },\n`;
} else {
output += `\t\t\t{ u: '${emoji.u}', l: ${l}, k: ${k} },\n`;
}
}
output += `\t\t],\n`;
output += `\t},\n`;
}
output += `];\n`;
writeFileSync(OUTPUT_PATH, output);
const sizeKB = Math.round(Buffer.byteLength(output) / 1024);
console.log(`\nDone! Written to: ${OUTPUT_PATH}`);
console.log(`File size: ${sizeKB} KB`);
}
main();
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Generates lucideIconData.ts with search metadata (keywords + categories) for Lucide icons.
* SVG bodies are NOT included they are loaded via generated chunks at runtime by lucideIconsPlugin
* (packages/frontend/@n8n/design-system/src/icons/lucide/vite.ts).
*
* Usage: node scripts/generate-lucide-icon-data.mjs
*
* Output: packages/frontend/@n8n/design-system/src/components/N8nIconPicker/lucideIconData.ts
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const COMPONENTS_ROOT = resolve(
ROOT,
'packages/frontend/@n8n/design-system/src/components',
);
const LUCIDE_JSON_PATH = resolve(ROOT, 'node_modules/@iconify/json/json/lucide.json');
const OUTPUT_PATH = resolve(COMPONENTS_ROOT, 'N8nIconPicker/lucideIconData.ts');
const CACHE_PATH = resolve(ROOT, 'scripts/.lucide-tags-cache.json');
// Lucide GitHub raw URL for per-icon metadata
const LUCIDE_GITHUB_BASE =
'https://raw.githubusercontent.com/lucide-icons/lucide/main/icons';
async function fetchIconMeta(iconName) {
const url = `${LUCIDE_GITHUB_BASE}/${iconName}.json`;
try {
const res = await fetch(url);
if (!res.ok) return { tags: [], categories: [] };
const data = await res.json();
return {
tags: data.tags ?? [],
categories: data.categories ?? [],
};
} catch {
return { tags: [], categories: [] };
}
}
async function main() {
console.log('Reading @iconify/json lucide data...');
const lucideJson = JSON.parse(readFileSync(LUCIDE_JSON_PATH, 'utf-8'));
const icons = lucideJson.icons;
const iconNames = Object.keys(icons).sort();
console.log(`Found ${iconNames.length} Lucide icons`);
// Load or initialize cache
let cache = {};
if (existsSync(CACHE_PATH)) {
try {
cache = JSON.parse(readFileSync(CACHE_PATH, 'utf-8'));
console.log(`Loaded ${Object.keys(cache).length} cached icon metadata entries`);
} catch {
cache = {};
}
}
// Fetch metadata for uncached icons
const uncachedNames = iconNames.filter((name) => !cache[name]);
if (uncachedNames.length > 0) {
console.log(`Fetching metadata for ${uncachedNames.length} icons from Lucide GitHub...`);
const BATCH_SIZE = 50;
for (let i = 0; i < uncachedNames.length; i += BATCH_SIZE) {
const batch = uncachedNames.slice(i, i + BATCH_SIZE);
const results = await Promise.all(batch.map(fetchIconMeta));
for (let j = 0; j < batch.length; j++) {
cache[batch[j]] = results[j];
}
const progress = Math.min(i + BATCH_SIZE, uncachedNames.length);
process.stdout.write(`\r Fetched ${progress}/${uncachedNames.length}`);
}
console.log('\nSaving cache...');
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
}
// Build the output data
const allCategories = new Set();
const entries = [];
for (const name of iconNames) {
const meta = cache[name] ?? { tags: [], categories: [] };
const body = icons[name]?.body;
if (!body) continue;
// Keywords: icon name parts + tags
const nameParts = name.split('-').filter((p) => p.length > 0);
const keywords = [...new Set([...nameParts, ...meta.tags])];
const categories = meta.categories ?? [];
categories.forEach((c) => allCategories.add(c));
entries.push({ name, keywords, categories });
}
const sortedCategories = [...allCategories].sort();
console.log(`Generating TypeScript file with ${entries.length} icons and ${sortedCategories.length} categories...`);
// Generate TypeScript output — metadata only, no SVG bodies
let output = `// AUTO-GENERATED by scripts/generate-lucide-icon-data.mjs — DO NOT EDIT
// Source: Lucide GitHub (tags/categories). SVG bodies are loaded via generated chunks at runtime.
// Icons: ${entries.length} | Categories: ${sortedCategories.length}
export interface LucideIconMeta {
\t/** Searchable keywords: icon name parts + Lucide tags */
\tkeywords: string[];
\t/** Lucide categories this icon belongs to */
\tcategories: string[];
}
export const lucideIcons: Record<string, LucideIconMeta> = {\n`;
for (const entry of entries) {
const kw = JSON.stringify(entry.keywords);
const cats = JSON.stringify(entry.categories);
output += `\t'${entry.name}': { keywords: ${kw}, categories: ${cats} },\n`;
}
output += `};\n\n`;
output += `/** All unique Lucide icon categories, sorted alphabetically */\nexport const lucideCategories: string[] = ${JSON.stringify(sortedCategories, null, '\t')};\n`;
writeFileSync(OUTPUT_PATH, output);
const sizeKB = Math.round(Buffer.byteLength(output) / 1024);
console.log(`\nDone! Written to: ${OUTPUT_PATH}`);
console.log(`File size: ${sizeKB} KB`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});