mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
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:
@@ -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>
|
||||
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
+182
@@ -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>
|
||||
+418
-129
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+106
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+388
-197
@@ -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)"
|
||||
>
|
||||
{{ iconOrEmoji.value }}
|
||||
</span>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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 })"
|
||||
</N8nInput>
|
||||
<N8nTooltip
|
||||
v-if="selectedTab === 'icons' && showColorPicker"
|
||||
placement="top"
|
||||
:disabled="colorPickerRef?.isOpen"
|
||||
:teleported="false"
|
||||
>
|
||||
<template #content>
|
||||
{{ t('iconPicker.colorPicker.selectColor') }}
|
||||
</template>
|
||||
<IconColorPicker
|
||||
ref="colorPickerRef"
|
||||
v-model="selectedColor"
|
||||
data-test-id="icon-color-picker"
|
||||
/>
|
||||
</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 })"
|
||||
</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()"
|
||||
>
|
||||
{{ emoji }}
|
||||
</span>
|
||||
</template>
|
||||
<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,23 +425,21 @@ async function loadEmojiMetadataMap() {
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
.icon-button {
|
||||
svg {
|
||||
width: var(--spacing--md);
|
||||
height: var(--spacing--md);
|
||||
stroke-width: 1.5;
|
||||
|
||||
.small & {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.xlarge & {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.xlarge & {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.xxlarge & {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
.xxlarge & {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
+32
@@ -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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
+123
@@ -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',
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+131
@@ -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 };
|
||||
}
|
||||
+104
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+105
@@ -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,
|
||||
|
||||
+1
-1
@@ -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 }}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+7
-4
@@ -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
|
||||
|
||||
@@ -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 (1–5). 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();
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user