@adia-ai/web-components 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/USAGE.md +42 -0
  2. package/components/accordion/accordion.a2ui.json +8 -2
  3. package/components/accordion/accordion.d.ts +7 -2
  4. package/components/accordion/accordion.yaml +8 -2
  5. package/components/accordion/class.js +6 -0
  6. package/components/agent-questions/agent-questions.yaml +2 -0
  7. package/components/agent-questions/class.js +6 -0
  8. package/components/agent-reasoning/agent-reasoning.yaml +5 -0
  9. package/components/agent-reasoning/class.js +6 -0
  10. package/components/agent-trace/agent-trace.js +6 -0
  11. package/components/agent-trace/agent-trace.yaml +3 -0
  12. package/components/calendar-picker/calendar-picker.yaml +4 -0
  13. package/components/calendar-picker/class.js +7 -0
  14. package/components/canvas/canvas.a2ui.json +1 -10
  15. package/components/canvas/canvas.d.ts +0 -6
  16. package/components/canvas/canvas.yaml +1 -7
  17. package/components/card/card.a2ui.json +1 -5
  18. package/components/card/card.d.ts +0 -9
  19. package/components/card/card.yaml +1 -3
  20. package/components/chat-thread/chat-input.a2ui.json +158 -0
  21. package/components/chat-thread/chat-input.yaml +251 -0
  22. package/components/check/class.js +1 -0
  23. package/components/color-picker/class.js +6 -0
  24. package/components/color-picker/color-picker.yaml +2 -0
  25. package/components/command/class.js +6 -0
  26. package/components/command/command.yaml +2 -0
  27. package/components/drawer/class.js +25 -3
  28. package/components/drawer/drawer.a2ui.json +13 -1
  29. package/components/drawer/drawer.d.ts +6 -1
  30. package/components/drawer/drawer.yaml +11 -1
  31. package/components/feed/feed-item.a2ui.json +86 -0
  32. package/components/pane/class.js +6 -0
  33. package/components/pane/pane.yaml +2 -0
  34. package/components/radio/class.js +1 -0
  35. package/components/row/row.a2ui.json +1 -5
  36. package/components/row/row.d.ts +0 -9
  37. package/components/row/row.yaml +1 -3
  38. package/components/select/class.js +7 -0
  39. package/components/select/select.yaml +2 -0
  40. package/components/slider/class.js +25 -0
  41. package/components/switch/class.js +1 -0
  42. package/components/table/class.js +6 -0
  43. package/components/table/table.a2ui.json +13 -0
  44. package/components/table/table.d.ts +9 -0
  45. package/components/table/table.yaml +13 -0
  46. package/components/tag/class.js +6 -0
  47. package/components/tag/tag.yaml +2 -0
  48. package/components/textarea/class.js +1 -0
  49. package/components/tree/class.js +6 -0
  50. package/components/tree/tree.yaml +2 -0
  51. package/components/upload/class.js +1 -0
  52. package/core/icons.d.ts +148 -0
  53. package/core/icons.js +148 -5
  54. package/core/icons.test.js +187 -0
  55. package/core/template.js +59 -3
  56. package/package.json +16 -2
@@ -143,6 +143,19 @@
143
143
  }
144
144
  }
145
145
  },
146
+ "row-collapse": {
147
+ "description": "Fired when an already-expanded row's chevron is activated (collapses the row). Mirror of row-expand.",
148
+ "detail": {
149
+ "index": {
150
+ "description": "Row index in the underlying data array.",
151
+ "type": "number"
152
+ },
153
+ "row": {
154
+ "description": "Row data object.",
155
+ "type": "object"
156
+ }
157
+ }
158
+ },
146
159
  "row-expand": {
147
160
  "description": "Fired when an expandable row's chevron is activated. detail.index is the row position; detail.row is the row data.",
148
161
  "detail": {
@@ -44,6 +44,14 @@ export interface TableResizeEventDetail {
44
44
  }
45
45
 
46
46
  export type TableResizeEvent = CustomEvent<TableResizeEventDetail>;
47
+ export interface TableRowCollapseEventDetail {
48
+ /** Row index in the underlying data array. */
49
+ index: number;
50
+ /** Row data object. */
51
+ row: Record<string, unknown>;
52
+ }
53
+
54
+ export type TableRowCollapseEvent = CustomEvent<TableRowCollapseEventDetail>;
47
55
  export interface TableRowExpandEventDetail {
48
56
  /** Row index in the underlying data array. */
49
57
  index: number;
@@ -102,6 +110,7 @@ export class UITable extends UIElement {
102
110
  addEventListener(type: 'filter-change', listener: (ev: TableFilterChangeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
103
111
  addEventListener(type: 'page', listener: (ev: TablePageEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
104
112
  addEventListener(type: 'resize', listener: (ev: TableResizeEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
113
+ addEventListener(type: 'row-collapse', listener: (ev: TableRowCollapseEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
105
114
  addEventListener(type: 'row-expand', listener: (ev: TableRowExpandEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
106
115
  addEventListener(type: 'select', listener: (ev: TableSelectEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
107
116
  addEventListener(type: 'sort', listener: (ev: TableSortEvent) => unknown, options?: boolean | AddEventListenerOptions): void;
@@ -108,6 +108,15 @@ events:
108
108
  width:
109
109
  type: number
110
110
  description: New column width in pixels.
111
+ row-collapse:
112
+ description: Fired when an already-expanded row's chevron is activated (collapses the row). Mirror of row-expand.
113
+ detail:
114
+ index:
115
+ type: number
116
+ description: Row index in the underlying data array.
117
+ row:
118
+ type: object
119
+ description: Row data object.
111
120
  row-expand:
112
121
  description: Fired when an expandable row's chevron is activated. detail.index is the row position; detail.row is the row data.
113
122
  detail:
@@ -187,6 +196,10 @@ tokens:
187
196
  description: Sort arrow color
188
197
  --table-transition:
189
198
  description: Transition timing
199
+ requiredIcons:
200
+ - caret-right
201
+ - caret-up-down
202
+ - table
190
203
  a2ui:
191
204
  rules: []
192
205
  anti_patterns: []
@@ -27,6 +27,12 @@
27
27
  import { UIElement } from '../../core/element.js';
28
28
 
29
29
  export class UITag extends UIElement {
30
+ // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
31
+ // consumer markup). Aggregated by installIconLoadersForRegistered()
32
+ // across all defined elements. Audited by check-required-icons.mjs
33
+ // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
34
+ static requiredIcons = ['x'];
35
+
30
36
  static properties = {
31
37
  text: { type: String, default: '', reflect: true },
32
38
  textContent: { type: String, default: '' },
@@ -61,6 +61,8 @@ states:
61
61
  attribute: disabled
62
62
  traits: []
63
63
  tokens: {}
64
+ requiredIcons:
65
+ - x
64
66
  a2ui:
65
67
  rules: []
66
68
  anti_patterns: []
@@ -20,6 +20,7 @@
20
20
  import { UIFormElement } from '../../core/form.js';
21
21
 
22
22
  export class UITextarea extends UIFormElement {
23
+ static labelDeprecated = false; // §170 (v0.5.4): label is first-class per textarea.yaml
23
24
  static properties = {
24
25
  ...UIFormElement.properties,
25
26
  placeholder: { type: String, default: '', reflect: true },
@@ -35,6 +35,12 @@
35
35
  import { UIElement } from '../../core/element.js';
36
36
 
37
37
  export class UITree extends UIElement {
38
+ // §154 (v0.5.3): Phosphor icons this primitive auto-stamps (without
39
+ // consumer markup). Aggregated by installIconLoadersForRegistered()
40
+ // across all defined elements. Audited by check-required-icons.mjs
41
+ // (slot 11). Per FEEDBACK-06 §4 + FEEDBACK-07 §4.
42
+ static requiredIcons = ['caret-right'];
43
+
38
44
  static template = () => null;
39
45
 
40
46
  connected() {
@@ -66,6 +66,8 @@ tokens:
66
66
  description: Inline-end padding of each row
67
67
  --tree-row-radius:
68
68
  description: Border radius of each row
69
+ requiredIcons:
70
+ - caret-right
69
71
  a2ui:
70
72
  rules: []
71
73
  anti_patterns: []
@@ -20,6 +20,7 @@
20
20
  import { UIFormElement } from '../../core/form.js';
21
21
 
22
22
  export class UIUpload extends UIFormElement {
23
+ static labelDeprecated = false; // §170 (v0.5.4): label is first-class per upload.yaml
23
24
  static properties = {
24
25
  ...UIFormElement.properties,
25
26
  label: { type: String, default: '', reflect: true },
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Icon registry — pluggable lookup map for `<icon-ui>` SVG resolution.
3
+ *
4
+ * Three consumer paths, picked by bundle-size budget:
5
+ *
6
+ * 1. **Zero-config** — import the main barrel; phosphor side-effect-loads
7
+ * via `core/icons-phosphor.js`. Largest bundle (~9 000 chunks under
8
+ * `vite build`). Best for prototypes.
9
+ *
10
+ * 2. **Opt-in phosphor with piecemeal component imports** — when you
11
+ * `import '@adia-ai/web-components/components/button'` etc. you skip
12
+ * the barrel's phosphor side-effect. Add it explicitly:
13
+ * import '@adia-ai/web-components/core/icons-phosphor';
14
+ *
15
+ * 3. **Scoped registration (smallest bundle)** — install only the icons
16
+ * you use via `installIconLoaders()` with a brace-list glob, OR
17
+ * `installIconLoadersForRegistered()` to auto-discover from defined
18
+ * primitives' `static requiredIcons`.
19
+ *
20
+ * Runtime exports mirror `core/icons.js`. `installIconLoadersForRegistered`
21
+ * shipped in v0.5.3 §154 (FEEDBACK-06 §4 + FEEDBACK-07 §4). `.d.ts`
22
+ * authored in v0.5.4 to close the missing-type-surface gap that blocked
23
+ * TypeScript consumers from importing the helper.
24
+ *
25
+ * @see ../USAGE.md (consumer guide)
26
+ * @see ./icons-phosphor.js (the zero-config side-effect path)
27
+ */
28
+
29
+ /**
30
+ * Phosphor weight identifiers. `regular` is the default; the other five
31
+ * weights resolve to the corresponding `@phosphor-icons/core/assets/<weight>/`
32
+ * subtree when consumers install loaders for them.
33
+ */
34
+ export const ICON_WEIGHTS: ReadonlyArray<'regular' | 'thin' | 'light' | 'bold' | 'fill' | 'duotone'>;
35
+
36
+ /** A single phosphor weight identifier. */
37
+ export type IconWeight = 'regular' | 'thin' | 'light' | 'bold' | 'fill' | 'duotone';
38
+
39
+ /**
40
+ * Loader value in an icon-loader map. Either the SVG string itself (eager
41
+ * `import.meta.glob({ eager: true })` output) or a thunk that resolves to
42
+ * the SVG string (lazy / dynamic import).
43
+ */
44
+ export type IconLoader = string | (() => string | Promise<string>);
45
+
46
+ /**
47
+ * Per-path loader map for a single weight. Keys are typically the
48
+ * `/node_modules/@phosphor-icons/core/assets/<weight>/<filename>.svg` paths
49
+ * `import.meta.glob` produces — but any keys work (§140's filename-suffix
50
+ * fallback handles pnpm / yarn-berry / custom-Vite-root prefixes).
51
+ */
52
+ export type IconLoaderMap = Record<string, IconLoader>;
53
+
54
+ /**
55
+ * Per-weight loader maps. Pass to `installIconLoaders()` /
56
+ * `installIconLoadersForRegistered()`. Most consumers pass only `regular`;
57
+ * weight-overloaded usage (e.g. `<icon-ui name="star" weight="fill">`)
58
+ * needs the corresponding weight populated.
59
+ */
60
+ export interface WeightModules {
61
+ regular?: IconLoaderMap;
62
+ thin?: IconLoaderMap;
63
+ light?: IconLoaderMap;
64
+ bold?: IconLoaderMap;
65
+ fill?: IconLoaderMap;
66
+ duotone?: IconLoaderMap;
67
+ }
68
+
69
+ /** Register a single icon by name. Bypasses loader-map lookup entirely. */
70
+ export function registerIcon(name: string, svg: string, weight?: IconWeight): void;
71
+
72
+ /** Register multiple icons at a single weight. Bypasses loader-map lookup. */
73
+ export function registerIcons(icons: Record<string, string>, weight?: IconWeight): void;
74
+
75
+ /**
76
+ * Get icon SVG by name + weight. Returns the cached SVG if present;
77
+ * otherwise triggers an async load (returning `''` immediately) + the
78
+ * registered `<icon-ui>` elements re-render once the load resolves.
79
+ */
80
+ export function getIcon(name: string, weight?: IconWeight): string;
81
+
82
+ /** Sync check — is `name` cached in the registry at this weight? */
83
+ export function hasIcon(name: string, weight?: IconWeight): boolean;
84
+
85
+ /** List all cached `weight:name` keys in the registry. */
86
+ export function listIcons(): string[];
87
+
88
+ /**
89
+ * Sync check — is `name` (or its `ICON_ALIASES` alias) a known icon at
90
+ * the given weight? Doesn't trigger loading. Used by primitives that
91
+ * accept either an icon name or a text label (`<input-ui prefix>`,
92
+ * `<input-ui suffix>`) to disambiguate.
93
+ */
94
+ export function isIconName(name: string, weight?: IconWeight): boolean;
95
+
96
+ /**
97
+ * Resolves once the icon registry is sync-checkable — i.e., after the
98
+ * first `installIconLoaders()` call. Primitives that branch on
99
+ * `isIconName` at connect time should await this to avoid the static-
100
+ * deploy race (the registry starts empty, fills async via manifest fetch).
101
+ */
102
+ export const whenIconRegistryReady: Promise<void>;
103
+
104
+ /**
105
+ * Install a per-weight loader map. Idempotent — subsequent calls replace
106
+ * the previous map. After install, re-stamps any existing
107
+ * `<icon-ui[name]>` elements so they re-render with the newly-loadable
108
+ * SVGs.
109
+ *
110
+ * @param modules per-weight maps; missing weights stay empty.
111
+ */
112
+ export function installIconLoaders(modules: WeightModules): void;
113
+
114
+ /**
115
+ * Auto-discover the icon set from defined primitives' `static
116
+ * requiredIcons` declarations, then install loaders.
117
+ *
118
+ * Each AdiaUI primitive that auto-stamps icons declares them via:
119
+ *
120
+ * export class UISelect extends UIFormElement {
121
+ * static requiredIcons = ['caret-up-down'];
122
+ * // ...
123
+ * }
124
+ *
125
+ * This helper aggregates the union across all currently-defined
126
+ * primitives (post-side-effect imports) and installs `modules` as the
127
+ * loader source. The returned list is the discovered set — useful for
128
+ * trimming a wide glob to only what's needed.
129
+ *
130
+ * Shipped v0.5.3 §154 (FEEDBACK-06 §4 + FEEDBACK-07 §4).
131
+ *
132
+ * @param modules - per-weight loader maps (typically `import.meta.glob`
133
+ * output for `@phosphor-icons/core/assets/<weight>/*.svg`)
134
+ * @returns the union of `static requiredIcons` across all defined primitives
135
+ */
136
+ export function installIconLoadersForRegistered(
137
+ modules: IconLoaderMap | WeightModules
138
+ ): string[];
139
+
140
+ export namespace installIconLoadersForRegistered {
141
+ /**
142
+ * Same-shape companion to `installIconLoadersForRegistered` but
143
+ * doesn't install. Returns the union of `static requiredIcons` across
144
+ * all currently-defined primitives — useful when you want to build a
145
+ * narrower glob from the discovered list.
146
+ */
147
+ function discover(): string[];
148
+ }
package/core/icons.js CHANGED
@@ -140,6 +140,104 @@ export function installIconLoaders(modules) {
140
140
  resolveRegistryReady();
141
141
  }
142
142
 
143
+ /**
144
+ * Aggregate `static requiredIcons` declarations from all currently-defined
145
+ * `customElements`, install loaders for the union, and return the list of
146
+ * discovered icon names. Replaces the trial-and-error icon-glob authoring
147
+ * loop FEEDBACK-06 §4 + FEEDBACK-07 §4 reported.
148
+ *
149
+ * Each AdiaUI primitive that auto-stamps icons declares them via:
150
+ *
151
+ * export class UISelect extends UIFormElement {
152
+ * static requiredIcons = ['caret-up-down'];
153
+ * // ...
154
+ * }
155
+ *
156
+ * Consumer usage:
157
+ *
158
+ * import '@adia-ai/web-components'; // side-effect — defines all primitives
159
+ * import { installIconLoadersForRegistered } from '@adia-ai/web-components/core/icons';
160
+ *
161
+ * // Build the Phosphor loader map from your bundler (Vite, Webpack, etc.).
162
+ * const modules = import.meta.glob('/node_modules/@phosphor-icons/core/assets/regular/*.svg', { query: '?raw' });
163
+ *
164
+ * // Aggregate requiredIcons across all defined primitives + install.
165
+ * const discovered = installIconLoadersForRegistered(modules);
166
+ *
167
+ * Or, to limit the icon set to ONLY what's needed (smaller bundle):
168
+ *
169
+ * const discovered = installIconLoadersForRegistered.discover();
170
+ * // discovered: ['caret-up-down', 'x', 'caret-right', ...]
171
+ * // Now build a narrower glob from this list.
172
+ *
173
+ * Added v0.5.3 §154 per FEEDBACK-06 §4 + FEEDBACK-07 §4.
174
+ *
175
+ * @param {Record<string, () => Promise<string>>} modules - Phosphor loader map
176
+ * @returns {string[]} The union of all primitives' `requiredIcons`
177
+ */
178
+ export function installIconLoadersForRegistered(modules) {
179
+ const discovered = discoverRequiredIcons();
180
+ installIconLoaders(modules);
181
+ return discovered;
182
+ }
183
+
184
+ /**
185
+ * Walk customElements and collect the union of `static requiredIcons` across
186
+ * all defined primitives. Useful for building a Vite glob brace-list that
187
+ * matches exactly what the registered primitives need.
188
+ *
189
+ * Returns an empty array if `customElements` is unavailable (Node SSR)
190
+ * or if no defined primitive exposes `requiredIcons`.
191
+ */
192
+ function discoverRequiredIcons() {
193
+ const union = new Set();
194
+ if (typeof customElements === 'undefined') return [];
195
+ // customElements has no Object.entries-style iterator in the standard API.
196
+ // We need to track which tag names have been defined. The AdiaUI convention:
197
+ // every primitive's tag ends in `-ui` (or `-n` per yaml `tag:` pattern). We
198
+ // walk a known list of tag names — passed via opt-in. For SSR safety the
199
+ // consumer can also pass `tags` explicitly:
200
+ // discoverRequiredIcons(['select-ui', 'tag-ui', ...]).
201
+ //
202
+ // For browser usage with the default barrel import, the consumer's
203
+ // `import '@adia-ai/web-components'` defines all 93+ primitives at the
204
+ // global custom-element registry; we walk the DOM-known tag names by
205
+ // checking customElements.get() on the AdiaUI prefix convention.
206
+ //
207
+ // Strategy: query all defined tags by walking the document's primitive
208
+ // surface OR accept an explicit tag list. The simpler API: iterate over
209
+ // a known whitelist of tag names. We embed the whitelist for known
210
+ // auto-stamping primitives.
211
+ const KNOWN_AUTO_STAMPING_TAGS = [
212
+ 'accordion-ui',
213
+ 'agent-questions-ui',
214
+ 'agent-reasoning-ui',
215
+ 'agent-trace-ui',
216
+ 'calendar-picker-ui',
217
+ 'color-picker-ui',
218
+ 'command-ui',
219
+ 'pane-ui',
220
+ 'select-ui',
221
+ 'table-ui',
222
+ 'tag-ui',
223
+ 'tree-ui',
224
+ ];
225
+ for (const tag of KNOWN_AUTO_STAMPING_TAGS) {
226
+ const ctor = customElements.get(tag);
227
+ if (ctor && Array.isArray(ctor.requiredIcons)) {
228
+ for (const name of ctor.requiredIcons) union.add(name);
229
+ }
230
+ }
231
+ return [...union];
232
+ }
233
+
234
+ /**
235
+ * Public discovery — same shape as installIconLoadersForRegistered but
236
+ * doesn't install. Returns the union of `static requiredIcons` across
237
+ * all currently-defined auto-stamping AdiaUI primitives.
238
+ */
239
+ installIconLoadersForRegistered.discover = discoverRequiredIcons;
240
+
143
241
  /* Track which (name, weight) pairs we've already warned about so the
144
242
  console stays readable even when a missing icon is used many times on
145
243
  a page (e.g. a broken Phosphor name in a list row repeated 50×). */
@@ -168,17 +266,62 @@ function filenameFor(name, weight) {
168
266
  return `${name}-${weight}.svg`;
169
267
  }
170
268
 
269
+ /* Resolve a loader by name + weight, tolerant of path-prefix variation.
270
+ *
271
+ * Two-step lookup, fast-path first:
272
+ *
273
+ * 1. **Fast path**: try the canonical `/node_modules/@phosphor-icons/core/
274
+ * assets/<weight>/<filename>` key directly. This is what `icons-phosphor.js`
275
+ * installs when Vite's project root sits above an npm-style node_modules,
276
+ * and what the chat-ui dev convention assumes.
277
+ *
278
+ * 2. **Suffix fallback**: if the fast path misses, scan `modules` keys for
279
+ * one ending with `/<filename>`. Handles:
280
+ * - pnpm: `/node_modules/.pnpm/@phosphor-icons+core@.../node_modules/.../assets/...`
281
+ * - yarn berry / PnP: virtual paths like `/.yarn/cache/...`
282
+ * - custom Vite root: `/packages/my-app/node_modules/...`
283
+ * - brace-list globs with absolute paths
284
+ * - manually-built loader maps with arbitrary keys
285
+ *
286
+ * The fast path stays O(1); the fallback is O(N) over the weight's module
287
+ * map but only runs once per (name, weight) miss (results aren't cached, but
288
+ * misses fall through to a registry hit via `loadIcon`'s `registry.set`).
289
+ *
290
+ * Same two-step shape applies to the `-fill` back-compat name suffix.
291
+ *
292
+ * Fix history: pre-§140 the function was fast-path-only. FEEDBACK-06 from
293
+ * color-app reported `installIconLoaders` → `warnMissingIcon` firing for
294
+ * `caret-right` / `x` / `caret-up-down` even though the consumer had installed
295
+ * a loader map. Root cause: color-app's `modules` keys didn't match the
296
+ * canonical `/node_modules/...` prefix (different package-manager or Vite
297
+ * root). The suffix fallback closes that gap without changing the public API.
298
+ */
171
299
  function resolveLoader(name, weight = DEFAULT_WEIGHT) {
172
300
  const modules = weightModules[weight];
173
301
  if (!modules) return null;
174
- const key = `/node_modules/@phosphor-icons/core/assets/${weight}/${filenameFor(name, weight)}`;
175
- if (modules[key]) return modules[key];
302
+ const filename = filenameFor(name, weight);
303
+
304
+ // 1. Fast path — canonical phosphor npm-style path.
305
+ const fastKey = `/node_modules/@phosphor-icons/core/assets/${weight}/${filename}`;
306
+ if (modules[fastKey]) return modules[fastKey];
307
+
308
+ // 2. Suffix fallback — match any key ending with the filename.
309
+ const suffix = `/${filename}`;
310
+ for (const key in modules) {
311
+ if (key.endsWith(suffix)) return modules[key];
312
+ }
176
313
 
177
314
  // Back-compat: allow the caller to put the -fill suffix directly in the
178
- // name (e.g. `name="circle-fill"`) without specifying weight.
315
+ // name (e.g. `name="circle-fill"`) without specifying weight. Two-step
316
+ // lookup applies here too.
179
317
  if (weight === DEFAULT_WEIGHT && name.endsWith('-fill')) {
180
- const fillKey = `/node_modules/@phosphor-icons/core/assets/fill/${name}.svg`;
181
- if (weightModules.fill[fillKey]) return weightModules.fill[fillKey];
318
+ const fillFilename = `${name}.svg`;
319
+ const fillFastKey = `/node_modules/@phosphor-icons/core/assets/fill/${fillFilename}`;
320
+ if (weightModules.fill[fillFastKey]) return weightModules.fill[fillFastKey];
321
+ const fillSuffix = `/${fillFilename}`;
322
+ for (const key in weightModules.fill) {
323
+ if (key.endsWith(fillSuffix)) return weightModules.fill[key];
324
+ }
182
325
  }
183
326
  return null;
184
327
  }
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ installIconLoaders,
4
+ registerIcon,
5
+ hasIcon,
6
+ isIconName,
7
+ getIcon,
8
+ listIcons,
9
+ } from './icons.js';
10
+
11
+ // Tests for `installIconLoaders` + `resolveLoader` + the §140 suffix-
12
+ // fallback fix for FEEDBACK-06 (color-app's `installIconLoaders` →
13
+ // `warnMissingIcon` failures on `caret-right` / `x` / `caret-up-down`
14
+ // when the consumer's `modules` keys didn't match the canonical
15
+ // `/node_modules/@phosphor-icons/core/assets/<weight>/<name>.svg` path.
16
+ //
17
+ // `isIconName` is the SYNC probe — it calls `resolveLoader` without
18
+ // triggering an async load. That's what we need to assert the fallback
19
+ // logic finds the loader regardless of path shape.
20
+
21
+ const CARET_RIGHT_SVG = '<svg data-icon="caret-right"/>';
22
+ const X_SVG = '<svg data-icon="x"/>';
23
+ const CARET_UP_DOWN_SVG = '<svg data-icon="caret-up-down"/>';
24
+
25
+ describe('installIconLoaders + resolveLoader (§140 suffix fallback)', () => {
26
+ beforeEach(() => {
27
+ // Reset to a known state. Note: the registry Map itself is module-
28
+ // singleton + doesn't reset between tests, so we keep test SVG
29
+ // contents distinct from any real phosphor names just in case.
30
+ installIconLoaders({ regular: {}, thin: {}, light: {}, bold: {}, fill: {}, duotone: {} });
31
+ });
32
+
33
+ it('resolves by canonical phosphor path (fast path)', () => {
34
+ installIconLoaders({
35
+ regular: {
36
+ '/node_modules/@phosphor-icons/core/assets/regular/caret-right.svg': CARET_RIGHT_SVG,
37
+ '/node_modules/@phosphor-icons/core/assets/regular/x.svg': X_SVG,
38
+ },
39
+ });
40
+
41
+ expect(isIconName('caret-right')).toBe(true);
42
+ expect(isIconName('x')).toBe(true);
43
+ });
44
+
45
+ it('resolves by filename suffix when key has a pnpm-style prefix', () => {
46
+ // pnpm hoists phosphor inside `.pnpm/<pkg>@<ver>/node_modules/...`
47
+ installIconLoaders({
48
+ regular: {
49
+ '/node_modules/.pnpm/@phosphor-icons+core@2.1.1/node_modules/@phosphor-icons/core/assets/regular/caret-right.svg':
50
+ CARET_RIGHT_SVG,
51
+ },
52
+ });
53
+
54
+ expect(isIconName('caret-right')).toBe(true);
55
+ });
56
+
57
+ it('resolves by filename suffix when key is a custom Vite-root absolute path', () => {
58
+ // Custom Vite root puts node_modules at a deeper location.
59
+ installIconLoaders({
60
+ regular: {
61
+ '/packages/my-app/node_modules/@phosphor-icons/core/assets/regular/caret-up-down.svg':
62
+ CARET_UP_DOWN_SVG,
63
+ },
64
+ });
65
+
66
+ expect(isIconName('caret-up-down')).toBe(true);
67
+ });
68
+
69
+ it('resolves by filename suffix for an arbitrary manually-built loader map', () => {
70
+ // Manual loader maps may have any keys at all — only the filename matters.
71
+ installIconLoaders({
72
+ regular: {
73
+ '/anywhere/at/all/caret-right.svg': CARET_RIGHT_SVG,
74
+ '/another/path/entirely/x.svg': X_SVG,
75
+ },
76
+ });
77
+
78
+ expect(isIconName('caret-right')).toBe(true);
79
+ expect(isIconName('x')).toBe(true);
80
+ });
81
+
82
+ it('returns false on miss across both paths', () => {
83
+ installIconLoaders({
84
+ regular: {
85
+ '/node_modules/@phosphor-icons/core/assets/regular/caret-right.svg': CARET_RIGHT_SVG,
86
+ },
87
+ });
88
+
89
+ expect(isIconName('not-a-real-icon-name')).toBe(false);
90
+ });
91
+
92
+ it('resolves `-fill` back-compat suffix in name via fast path', () => {
93
+ const STAR_FILL_SVG = '<svg data-icon="star-fill"/>';
94
+ installIconLoaders({
95
+ regular: {},
96
+ fill: {
97
+ '/node_modules/@phosphor-icons/core/assets/fill/star-fill.svg': STAR_FILL_SVG,
98
+ },
99
+ });
100
+
101
+ // `name="star-fill"` with default weight=regular falls back to fill weight.
102
+ expect(isIconName('star-fill')).toBe(true);
103
+ });
104
+
105
+ it('resolves `-fill` back-compat suffix in name via suffix fallback', () => {
106
+ const STAR_FILL_SVG = '<svg data-icon="star-fill"/>';
107
+ installIconLoaders({
108
+ regular: {},
109
+ fill: {
110
+ '/anywhere/star-fill.svg': STAR_FILL_SVG,
111
+ },
112
+ });
113
+
114
+ expect(isIconName('star-fill')).toBe(true);
115
+ });
116
+
117
+ it('resolves non-regular weights (bold, fill, etc.) via suffix fallback', () => {
118
+ const HEART_BOLD_SVG = '<svg data-icon="heart-bold"/>';
119
+ installIconLoaders({
120
+ regular: {},
121
+ bold: {
122
+ '/anywhere/heart-bold.svg': HEART_BOLD_SVG,
123
+ },
124
+ });
125
+
126
+ expect(isIconName('heart', 'bold')).toBe(true);
127
+ });
128
+
129
+ it('isIconName returns true via alias resolution + suffix fallback', () => {
130
+ // `send` → `paper-plane-right` in ICON_ALIASES. Suffix fallback should
131
+ // still match the aliased filename.
132
+ const PAPER_PLANE_SVG = '<svg data-icon="paper-plane-right"/>';
133
+ installIconLoaders({
134
+ regular: {
135
+ '/anywhere/paper-plane-right.svg': PAPER_PLANE_SVG,
136
+ },
137
+ });
138
+
139
+ expect(isIconName('send')).toBe(true);
140
+ });
141
+
142
+ it('end-to-end via DOM re-stamp: installIconLoaders auto-loads existing <icon-ui[name]>', async () => {
143
+ // The §140 fix's primary symptom: `installIconLoaders` walks existing
144
+ // `<icon-ui[name]>` elements + calls `loadIcon` for each. Without the
145
+ // suffix fallback, `loadIcon` → `resolveLoader` returns null →
146
+ // `warnMissingIcon` fires. With the fix, the load completes.
147
+ const PAW_SVG = '<svg data-icon="paw"/>';
148
+
149
+ // Plant an `<icon-ui[name="paw"]>` element. We don't need it to
150
+ // actually upgrade; we just need `querySelectorAll('icon-ui[name]')`
151
+ // to find it.
152
+ const el = document.createElement('icon-ui');
153
+ el.setAttribute('name', 'paw');
154
+ document.body.appendChild(el);
155
+
156
+ // Install a loader map with a pnpm-style path (suffix fallback exercise).
157
+ installIconLoaders({
158
+ regular: {
159
+ '/some/non-canonical/prefix/paw.svg': PAW_SVG,
160
+ },
161
+ });
162
+
163
+ // `loadIcon` runs sync (loader is a string, not a function), so the
164
+ // registry is written by the time `installIconLoaders` returns.
165
+ expect(hasIcon('paw')).toBe(true);
166
+
167
+ document.body.removeChild(el);
168
+ });
169
+ });
170
+
171
+ describe('registerIcon / hasIcon — unchanged by §140', () => {
172
+ it('registerIcon adds an icon by name without needing a loader map', () => {
173
+ const CUSTOM_SVG = '<svg data-icon="custom-§140-marker"/>';
174
+ registerIcon('my-§140-custom-icon', CUSTOM_SVG);
175
+
176
+ expect(hasIcon('my-§140-custom-icon')).toBe(true);
177
+ expect(getIcon('my-§140-custom-icon')).toBe(CUSTOM_SVG);
178
+ });
179
+
180
+ it('listIcons returns registered names', () => {
181
+ registerIcon('§140-listIcons-marker-one', '<svg/>');
182
+ registerIcon('§140-listIcons-marker-two', '<svg/>');
183
+
184
+ expect(listIcons().some((k) => k.includes('§140-listIcons-marker-one'))).toBe(true);
185
+ expect(listIcons().some((k) => k.includes('§140-listIcons-marker-two'))).toBe(true);
186
+ });
187
+ });