diff --git a/.gitignore b/.gitignore index be6cec7fdff..43083c72e32 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts index 69352e2a01a..d1a3144a1c2 100644 --- a/packages/@n8n/api-types/src/schemas/project.schema.ts +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -9,6 +9,7 @@ export type ProjectType = z.infer; export const projectIconSchema = z.object({ type: z.enum(['emoji', 'icon']), value: z.string().min(1), + color: z.string().optional(), }); export type ProjectIcon = z.infer; diff --git a/packages/frontend/@n8n/design-system/biome.jsonc b/packages/frontend/@n8n/design-system/biome.jsonc index 10254c3c13c..5e7983b6d50 100644 --- a/packages/frontend/@n8n/design-system/biome.jsonc +++ b/packages/frontend/@n8n/design-system/biome.jsonc @@ -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" + ] } } diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index a5fe0c36b0d..890b716cef7 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -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", diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue index bd5f6653c5e..d286b93a7d9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.vue @@ -110,7 +110,7 @@ const handleClick = (event: MouseEvent) => {
- + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue b/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue index 74b2bbbb5f0..0243af5141a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDropdownMenu/DropdownMenu.vue @@ -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 }); + iconName === 'app-window-mac' ? '' : 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( + ' -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 = { - 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 = {}; - 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(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(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 }, ); @@ -121,12 +140,33 @@ const resolvedComponent = computed( :width="size.width" :data-icon="props.icon" :style="styles" + /> diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.test.ts index 6d62ebf8d84..40f57fc1969 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.test.ts @@ -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 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(); }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.virtualization.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.virtualization.test.ts new file mode 100644 index 00000000000..740cfd23c14 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.virtualization.test.ts @@ -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: '
' }, + }, + ], +}); + +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, + ); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue index 723d440fd32..7c84f8859bc 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nIconPicker/IconPicker.vue @@ -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 ->(); - -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 | Array>; /** Additional CSS class(es) for the trigger button */ @@ -52,23 +59,52 @@ const { t } = useI18n(); const props = withDefaults(defineProps(), { buttonSize: 'large', + showColorPicker: false, }); const model = defineModel({ 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 | null>(null); +const rawEmojiSections = ref([]); +const dataLoaded = ref(false); +const dataLoading = ref(false); + +// Filter emoji sections for browser support (cached) +const supportedEmojiSections = computed(() => { + 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 | 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(tabs[0].value); const searchQuery = ref(''); +const selectedCategory = ref(null); +const selectedColor = ref( + props.showColorPicker && model.value.type === 'icon' ? model.value.color : undefined, +); +const buttonIconName = computed(() => + model.value.type === 'icon' ? (model.value.value as IconName) : 'smile', +); +const selectedSkinTone = ref( + parseInt(localStorage.getItem(SKIN_TONE_STORAGE_KEY) ?? '0', 10) || 0, +); const container = ref(); -const searchInput = ref>(); - -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((value) => ({ type: 'icon', value })), - ...filteredEmojis.value.map((value) => ({ type: 'emoji', value })), -]); +const searchInputRef = ref>(); +const colorPickerRef = ref>(); +const skinTonePickerRef = ref>(); 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(() => + isSearching.value + ? buildIconSearchRows(filteredIcons.value) + : buildIconBrowseRows(filteredIconSections.value), +); +const emojiRows = computed(() => + 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()); } @@ -178,25 +225,27 @@ async function loadEmojiMetadataMap() { }, containerClass, ]" - :aria-expanded="popupVisible" - role="button" - aria-haspopup="true" > -
- +
+
-
- - - - {{ - t('iconPicker.random') - }} -
-
+
-
-