tosijs-ui 1.5.6 → 1.5.7

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.
package/dist/form.d.ts CHANGED
@@ -96,6 +96,7 @@ export declare class TosiForm extends XinComponent {
96
96
  }, isValid: boolean) => void;
97
97
  connectedCallback(): void;
98
98
  private handleElementChange;
99
+ private syncFieldValues;
99
100
  private initializeNamedElements;
100
101
  }
101
102
  /** @deprecated Use TosiField instead */
package/dist/form.js CHANGED
@@ -669,7 +669,8 @@ export class TosiForm extends XinComponent {
669
669
  handleSubmit = (event) => {
670
670
  event.preventDefault();
671
671
  event.stopPropagation();
672
- // Access fields to ensure value is parsed from JSON string if needed
672
+ // Sync field values before submitting
673
+ this.syncFieldValues();
673
674
  const value = this.fields;
674
675
  this.submitCallback(value, this.isValid);
675
676
  };
@@ -696,13 +697,30 @@ export class TosiForm extends XinComponent {
696
697
  this.fields[name] = target.value;
697
698
  }
698
699
  };
700
+ syncFieldValues() {
701
+ const formValue = this.fields;
702
+ const namedElements = this.querySelectorAll('[name], [key]');
703
+ for (const el of namedElements) {
704
+ const key = el.getAttribute('name') || el.getAttribute('key');
705
+ if (!key)
706
+ continue;
707
+ if (formValue[key] === undefined) {
708
+ const val = el.value ?? el.getAttribute('value');
709
+ if (val != null) {
710
+ formValue[key] = val;
711
+ }
712
+ }
713
+ }
714
+ }
699
715
  initializeNamedElements() {
700
716
  const formValue = this.fields;
701
717
  // Handle both 'name' (formAssociated) and 'key' (tosi-field) attributes
702
718
  const namedElements = this.querySelectorAll('[name], [key]');
703
719
  for (const el of namedElements) {
704
720
  const key = el.getAttribute('name') || el.getAttribute('key');
705
- if (key && formValue[key] !== undefined) {
721
+ if (!key)
722
+ continue;
723
+ if (formValue[key] !== undefined) {
706
724
  ;
707
725
  el.value = formValue[key];
708
726
  }
package/dist/icon-data.js CHANGED
@@ -68,7 +68,7 @@ export default {
68
68
  zapOff: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><polyline points=\"12.41 6.75 13 2 10.57 4.92\"></polyline><polyline points=\"18.57 12.91 21 10 15.66 10\"></polyline><polyline points=\"8 8 3 14 12 14 11 22 16 16\"></polyline><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line></svg>",
69
69
  x: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"></line><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"></line></svg>",
70
70
  barChart: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"10\"></line><line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"4\"></line><line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"16\"></line></svg>",
71
- lock: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"></path></svg>",
71
+ lock: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"></path></svg> ",
72
72
  logIn: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\"></path><polyline points=\"10 17 15 12 10 7\"></polyline><line x1=\"15\" y1=\"12\" x2=\"3\" y2=\"12\"></line></svg>",
73
73
  shoppingBag: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z\"></path><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line><path d=\"M16 10a4 4 0 0 1-8 0\"></path></svg>",
74
74
  divide: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"6\" r=\"2\"></circle><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line><circle cx=\"12\" cy=\"18\" r=\"2\"></circle></svg>",
@@ -142,7 +142,7 @@ export default {
142
142
  rss: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M4 11a9 9 0 0 1 9 9\"></path><path d=\"M4 4a16 16 0 0 1 16 16\"></path><circle cx=\"5\" cy=\"19\" r=\"1\"></circle></svg>",
143
143
  wifi: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M5 12.55a11 11 0 0 1 14.08 0\"></path><path d=\"M1.42 9a16 16 0 0 1 21.16 0\"></path><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"></path><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"></line></svg>",
144
144
  watch: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"7\"></circle><polyline points=\"12 9 12 12 13.5 13.5\"></polyline><path d=\"M16.51 17.35l-.35 3.83a2 2 0 0 1-2 1.82H9.83a2 2 0 0 1-2-1.82l-.35-3.83m.01-10.7l.35-3.83A2 2 0 0 1 9.83 1h4.35a2 2 0 0 1 2 1.82l.35 3.83\"></path></svg>",
145
- info: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12.01\" y2=\"8\"></line></svg>",
145
+ info: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"7.75\"></line></svg>",
146
146
  userX: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"18\" y1=\"8\" x2=\"23\" y2=\"13\"></line><line x1=\"23\" y1=\"8\" x2=\"18\" y2=\"13\"></line></svg>",
147
147
  loader: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"6\"></line><line x1=\"12\" y1=\"18\" x2=\"12\" y2=\"22\"></line><line x1=\"4.93\" y1=\"4.93\" x2=\"7.76\" y2=\"7.76\"></line><line x1=\"16.24\" y1=\"16.24\" x2=\"19.07\" y2=\"19.07\"></line><line x1=\"2\" y1=\"12\" x2=\"6\" y2=\"12\"></line><line x1=\"18\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"4.93\" y1=\"19.07\" x2=\"7.76\" y2=\"16.24\"></line><line x1=\"16.24\" y1=\"7.76\" x2=\"19.07\" y2=\"4.93\"></line></svg>",
148
148
  folderPlus: "<svg class=\"stroked\" viewBox=\"0 0 24 24\"><path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path><line x1=\"12\" y1=\"11\" x2=\"12\" y2=\"17\"></line><line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\"></line></svg>",
package/dist/icons.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare const defineIcons: (newIcons: {
6
6
  export declare const svg2DataUrl: (icon: Element, fill?: string, stroke?: string, strokeWidth?: number) => string;
7
7
  export interface IconRule {
8
8
  prefix: string | RegExp;
9
- apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | null;
9
+ apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
10
10
  }
11
11
  export declare const iconRules: IconRule[];
12
12
  export declare const icons: SVGIconMap;
@@ -20,6 +20,7 @@ export declare class SvgIcon extends WebComponent {
20
20
  '--tosi-icon-stroke-linecap': string;
21
21
  '--tosi-icon-fill': string;
22
22
  display: string;
23
+ verticalAlign: string;
23
24
  stroke: string;
24
25
  strokeWidth: string;
25
26
  strokeLinejoin: string;
package/dist/icons.js CHANGED
@@ -347,105 +347,125 @@ that, for example, treat all colored icons inside buttons the same way.
347
347
 
348
348
  ## Icon Composition & Math
349
349
 
350
- <tosi-icon icon="tosi$map50o" size=128></tosi-icon>
351
- <tosi-icon icon="lock50s75o_10y$shield" size=128></tosi-icon>
352
- <tosi-icon icon="unLock" size=128></tosi-icon>
350
+ <tosi-icon icon="tosi$map50o_brandColorS" size=128></tosi-icon>
351
+ <tosi-icon icon="lock50s75o_10y$shield_brandColorS" size=128></tosi-icon>
352
+ <tosi-icon icon="unLock_brandColorS" size=128></tosi-icon>
353
353
  <tosi-icon icon="checkFile" size=128></tosi-icon>
354
- <tosi-icon icon="spin120Loader40s_20x$cloud" size=128></tosi-icon>
354
+ <tosi-icon icon="spin120Loader40s_30x$cloud" size=128></tosi-icon>
355
355
 
356
- If you request an icon that doesn't exist, the system tries to compose one
357
- from a base icon and a prefix:
356
+ ### Why?
358
357
 
359
- ### Prefix rules
360
-
361
- - `spin<dps><Icon>` — continuous rotation at N degrees/second, e.g. `spin360Loader` (1 rev/s)
362
- - `spin_<dps><Icon>` counter-clockwise, e.g. `spin_180Star`
358
+ I needed a pin icon for column pinning in the data table. The only pin
359
+ in the feather set is a map pin, so I created a push-pin icon
360
+ <tosi-icon icon="pin_brandColorS" size=24></tosi-icon>.
361
+ But immediately I also needed unpin, pin-left, and pin-right — a lot
362
+ of new icons for one feature. Of course I could flip the pin with CSS, but
363
+ this is a problem *everywhere, all the time*: every directional icon
364
+ needs 2–4 variants, every action needs a negation, every status needs
365
+ an overlay.
363
366
 
364
- ### Modifier overlays
367
+ Why not fix it once and also eliminate the need to maintain trivial
368
+ variations on every icon?
365
369
 
366
- - `un<Icon>` — red slash overlay (e.g. `unPin`, `unLock`)
367
- - `check<Icon>` — green check overlay
368
- - `cancel<Icon>` — red x overlay
369
- - `search<Icon>` — magnifier overlay
370
+ <tosi-icon icon="pin_brandColorS" size=64></tosi-icon>
371
+ <tosi-icon icon="pin0f_brandColorS" size=64></tosi-icon>
372
+ <tosi-icon icon="unPin_brandColorS" size=64></tosi-icon>
370
373
 
371
- Modifier overlays render the base icon at reduced opacity/scale with the
372
- overlay icon centered on top. **Overlay icons should have a square viewBox** —
373
- a non-square overlay on a non-square base will produce unexpected results.
374
+ ### Icon modifier suffixes
374
375
 
375
- ### Icon redirects
376
+ The suffix system is inspired by tosijs's CSS variable math, where
377
+ `borderRadius50` becomes `calc(var(--border-radius) * 0.5)` and
378
+ `someColor50o` adjusts opacity to 50%. The same `value + letter`
379
+ convention works for icons:
376
380
 
377
- Icon definitions that don't start with `<svg` are treated as redirects
378
- to another icon name (which can include composition prefixes):
379
-
380
- defineIcons({
381
- chevronDown: 'rot90ChevronRight',
382
- sidebarRight: 'flipHSidebar',
383
- })
381
+ - `NNo` opacity N% (e.g. `lock50o` = 50% opacity)
382
+ - `NNs` scale N% (e.g. `star75s` = 75% scale)
383
+ - `NNr` — rotate N° (e.g. `chevronRight90r` = chevron pointing down)
384
+ - `_NNr` — rotate -N° (e.g. `arrow_45r`)
385
+ - `0f` — flip horizontally (e.g. `sidebar0f`)
386
+ - `1f` — flip vertically
387
+ - `NNx` — translateX N% (e.g. `plus20x` = shift right 20%)
388
+ - `NNy` — translateY N% (e.g. `plus_20y` = shift up 20%)
389
+ - `_<HEX>F` — fill color (e.g. `star_FF0000F` = red fill; use uppercase hex)
390
+ - `_<HEX>S` — stroke color (e.g. `lock_00FS` = blue stroke)
391
+ - `_<camelCase>F` — fill CSS variable (e.g. `star_brandColorF` = `var(--brand-color)`)
392
+ - `_<camelCase>S` — stroke CSS variable (e.g. `lock_accentS` = `var(--accent)`)
393
+ - CSS color math works too: `star_brandColor40oF` = brand color at 40% opacity
394
+ - `NW` — stroke width (e.g. `lock4W` = stroke-width 4)
384
395
 
385
- ### Custom rules
396
+ Suffixes combine freely: `plus50o60s25x25y_f00F` = plus at 50% opacity,
397
+ 60% scale, shifted 25% right and down, filled red.
386
398
 
387
- `iconRules` is a mutable array of modifier rules. Add your own prefixes,
388
- modify existing ones, or replace them entirely:
399
+ ### Stacking icons
389
400
 
390
- // Add a new prefix
391
- iconRules.push({
392
- prefix: 'add',
393
- overlay: 'plus',
394
- overlayStyle: { color: 'blue', opacity: '0.75' },
395
- baseStyle: { opacity: '0.5', transform: 'scale(0.75)', transformOrigin: '50% 50%' },
396
- })
401
+ Use `$` to stack icons: `overlay$base`, or `top$middle$bottom` for
402
+ multiple layers. The last segment is the base (sets the size), everything
403
+ before it is overlaid on top. Each segment is resolved independently —
404
+ suffixes, redirects, and rules all work:
397
405
 
398
- // Override the built-in 'un' rule
399
- iconRules[0] = { ...iconRules[0], overlayStyle: { color: 'orange', opacity: '0.9' } }
406
+ icons['tosi$map50o']() // tosi logo on a 50% opacity map
407
+ icons['star45r$circle']() // rotated star on a circle
408
+ icons['lock50s75o_10y$shield']() // translucent lock on a shield
409
+ icons['star25o$lock50o$shield']() // three layers
400
410
 
401
- // Replace all rules
402
- iconRules.length = 0
403
- iconRules.push(...myCustomRules)
411
+ ### Icon redirects
404
412
 
405
- ### Stacking icons
413
+ Icon definitions that don't start with `<svg` are treated as redirects.
414
+ This is how we eliminate redundant SVG files — `chevronDown` doesn't
415
+ need its own SVG:
406
416
 
407
- Use `$` to stack one icon on top of another: `overlay$base`. Combine
408
- with opacity suffixes and transforms for layered compositions:
417
+ defineIcons({
418
+ chevronDown: 'chevronRight90r',
419
+ chevronLeft: 'chevronRight180r',
420
+ chevronUp: 'chevronRight270r',
421
+ userAdd: 'plus50o60s25x25y$user',
422
+ })
409
423
 
410
- icons['tosi$map50o']() // tosi logo on a 50% opacity map
411
- icons['star45r$circle']() // rotated star on a circle
412
- icons['lock50s75o_10y$shield']() // small translucent lock on a shield
424
+ ### Prefix rules
413
425
 
414
- Each side of the `$` is resolved independently, so redirects, transforms,
415
- and modifiers all work on either side.
426
+ Rules apply named prefixes to icons. Built-in rules use string rewrites
427
+ that feed back into the resolution pipeline:
416
428
 
417
- ### Style suffixes
429
+ - `un<Icon>` — translucent slash overlay (e.g. `unPin`, `unLock`)
430
+ - `check<Icon>` — green check overlay
431
+ - `cancel<Icon>` — red x overlay
432
+ - `search<Icon>` — magnifier overlay
433
+ - `spin<dps><Icon>` — continuous rotation at N°/second (e.g. `spin360Loader`)
434
+ - `spin_<dps><Icon>` — counter-clockwise (e.g. `spin_180Star`)
418
435
 
419
- Append two-digit codes to any icon name to apply transforms and opacity.
420
- Each suffix is a number followed by a letter:
436
+ The overlay rules are just string rewrites for example, `unFoo`
437
+ becomes `slash25o$foo75s75o`. **Overlay icons should have a square
438
+ viewBox** for best results on non-square base icons.
421
439
 
422
- - `NNo` — opacity N% (e.g. `lock50o` = 50% opacity)
423
- - `NNs` — scale N% (e.g. `star75s` = 75% scale)
424
- - `NNx` — translateX N% (e.g. `plus20x` = shift right 20%)
425
- - `NNy` — translateY N% (e.g. `plus_20y` = shift up 20%)
426
- - `NNr` — rotate N° (e.g. `chevronRight90r` = chevron pointing down)
427
- - `_NNr` — rotate -N° (e.g. `arrow_45r`)
428
- - `0f` — flip horizontally (e.g. `sidebar0f`)
429
- - `1f` — flip vertically
430
- - `_<hex>F` — fill color (e.g. `star_ff0000F` = red fill, `star_f00F` = shorthand)
431
- - `_<hex>S` — stroke color (e.g. `lock_00fS` = blue stroke)
432
- - `NW` — stroke width (e.g. `lock4W` = stroke-width 4)
440
+ ### Custom rules
433
441
 
434
- Suffixes can be combined: `plus50o60s25x25y_f00F` = plus at 50% opacity,
435
- 60% scale, shifted 25% right and down, filled red.
442
+ `iconRules` is a mutable array. Each rule has a `prefix` (string or
443
+ RegExp) and an `apply` function that returns a **string** (resolved
444
+ through the full pipeline), an **Element** (used directly), or **null**
445
+ (skip to next rule):
436
446
 
437
- This is especially powerful with stacking:
447
+ // String rewrite: addFoo plus75o_0000ffS$foo75s50o
448
+ iconRules.push({
449
+ prefix: 'add',
450
+ apply: (baseName) => `plus75o_0000ffS$${baseName}75s50o`,
451
+ })
438
452
 
439
- defineIcons({
440
- userAdd: 'plus50o60s25x25y$user',
453
+ // Function rule with side effects (like spin)
454
+ iconRules.push({
455
+ prefix: /^glow(\d+)/,
456
+ apply: (baseName, match, parts) => {
457
+ const icon = resolveIcon(baseName, parts)
458
+ icon.style.filter = `brightness(${match[1]}%)`
459
+ return icon
460
+ },
441
461
  })
442
462
 
443
463
  ### Composites and `svg2DataUrl`
444
464
 
445
- Composed icons (modifiers) are wrapped in a `<span>` container, not a
446
- single SVG. `svg2DataUrl()` will render only the base icon and log a
447
- console error. Transforms (rotation/flip suffixes) and plain icons work
448
- normally with `svg2DataUrl`.
465
+ Composed icons (stacked, overlay rules) are wrapped in a `<span>`, not
466
+ a single SVG. `svg2DataUrl()` will render only the base icon and log a
467
+ console error. Simple suffix transforms and plain icons work normally
468
+ with `svg2DataUrl`.
449
469
 
450
470
 
451
471
  ## Missing Icons
@@ -492,7 +512,7 @@ organizations themselves. It's up to you to use them correctly.
492
512
 
493
513
  The remaining icons I have created myself using the excellent but sometimes flawed
494
514
  [Amadine](https://apps.apple.com/us/app/amadine-vector-design-art/id1339198386?mt=12)
495
- and generally reliable [Graphic](https://apps.apple.com/us/app/graphic/id404705039?mt=12).
515
+ and before that [Graphic](https://apps.apple.com/us/app/graphic/id404705039?mt=12).
496
516
 
497
517
  ### Feather Icons Copyright Notice
498
518
 
@@ -553,26 +573,6 @@ export const svg2DataUrl = (icon, fill, stroke, strokeWidth) => {
553
573
  const text = encodeURIComponent(svg.outerHTML);
554
574
  return `url(data:image/svg+xml;charset=UTF-8,${text})`;
555
575
  };
556
- // Helper for overlay-style rules
557
- function overlayRule(prefix, overlay, overlayStyle, baseStyle) {
558
- return {
559
- prefix,
560
- apply(baseName, _match, parts) {
561
- const base = resolveIcon(baseName, []);
562
- const over = resolveIcon(overlay, []);
563
- Object.assign(base.style, baseStyle);
564
- Object.assign(over.style, {
565
- position: 'absolute',
566
- inset: '0',
567
- width: '100%',
568
- height: '100%',
569
- ...overlayStyle,
570
- });
571
- return wrapIcon(baseName, parts, base, over);
572
- },
573
- };
574
- }
575
- const spinKeyframesInjected = { done: false };
576
576
  export const iconRules = [
577
577
  {
578
578
  prefix: /^spin(_?\d+)/,
@@ -580,27 +580,41 @@ export const iconRules = [
580
580
  const dps = match[1].replace('_', '-');
581
581
  const duration = 360 / Math.abs(parseFloat(dps));
582
582
  const direction = dps.startsWith('-') ? 'reverse' : 'normal';
583
- if (!spinKeyframesInjected.done) {
584
- const style = document.createElement('style');
585
- style.textContent =
586
- '@keyframes tosi-spin { to { transform: rotate(360deg) } }';
587
- document.head.appendChild(style);
588
- spinKeyframesInjected.done = true;
583
+ // Strip suffixes — apply them to the wrapper, not the inner icon
584
+ const parsed = parseStyleSuffixes(baseName);
585
+ const iconName = parsed ? parsed.baseName : baseName;
586
+ const icon = resolveIcon(iconName, []);
587
+ const el = icon;
588
+ if (el.animate) {
589
+ el.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
590
+ duration: duration * 1000,
591
+ iterations: Infinity,
592
+ direction,
593
+ });
594
+ }
595
+ const wrapper = wrapIcon(baseName, parts, icon);
596
+ if (parsed) {
597
+ Object.assign(wrapper.style, parsed.style);
589
598
  }
590
- const icon = resolveIcon(baseName, []);
591
- icon.style.animation =
592
- `tosi-spin ${duration}s linear infinite ${direction}`;
593
- return wrapIcon(baseName, parts, icon);
599
+ return wrapper;
594
600
  },
595
601
  },
596
- overlayRule('un', 'slash', { opacity: '0.25' }, {
597
- opacity: '0.75',
598
- transform: 'scale(0.75)',
599
- transformOrigin: '50% 50%',
600
- }),
601
- overlayRule('check', 'check', { color: 'green', opacity: '0.75' }, { opacity: '0.5', transform: 'scale(0.75)', transformOrigin: '50% 50%' }),
602
- overlayRule('cancel', 'x', { color: 'red', opacity: '0.75' }, { opacity: '0.5', transform: 'scale(0.75)', transformOrigin: '50% 50%' }),
603
- overlayRule('search', 'search', { transform: 'scale(0.8) translate(30%, 30%)', transformOrigin: '50% 50%' }, { opacity: '0.5' }),
602
+ {
603
+ prefix: 'un',
604
+ apply: (baseName) => `slash25o$${baseName}75s75o`,
605
+ },
606
+ {
607
+ prefix: 'check',
608
+ apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
609
+ },
610
+ {
611
+ prefix: 'cancel',
612
+ apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
613
+ },
614
+ {
615
+ prefix: 'search',
616
+ apply: (baseName) => `search80s30x30y$${baseName}50o`,
617
+ },
604
618
  ];
605
619
  function makeIcon(spec, parts) {
606
620
  const div = elements.div();
@@ -652,6 +666,13 @@ function wrapIcon(prop, parts, ...children) {
652
666
  }
653
667
  return wrapper;
654
668
  }
669
+ function canResolve(name) {
670
+ const data = iconData;
671
+ if (data[name])
672
+ return true;
673
+ const parsed = parseStyleSuffixes(name);
674
+ return parsed != null && !!data[parsed.baseName];
675
+ }
655
676
  function composeIcon(prop, parts) {
656
677
  for (const rule of iconRules) {
657
678
  let baseName;
@@ -673,7 +694,12 @@ function composeIcon(prop, parts) {
673
694
  prop.slice(rule.prefix.length + 1);
674
695
  match = rule.prefix;
675
696
  }
697
+ // Only apply if baseName can actually resolve to an icon
698
+ if (!canResolve(baseName))
699
+ continue;
676
700
  const result = rule.apply(baseName, match, parts);
701
+ if (typeof result === 'string')
702
+ return resolveIcon(result, parts);
677
703
  if (result)
678
704
  return result;
679
705
  }
@@ -683,8 +709,9 @@ const MAX_REDIRECTS = 10;
683
709
  // Style suffixes — always value then letter code:
684
710
  // 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
685
711
  // 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
686
- // _ff0000F (fill), _f00S (stroke), 3W (stroke-width)
687
- const SUFFIX_RE = /(_?\d{2,3}[osxyr]|[01]f|_[0-9a-fA-F]{3,8}[FS]|\d{1,3}W)+$/;
712
+ // _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
713
+ // _brandColorF (fill var), _accentS (stroke var)
714
+ const SUFFIX_RE = /(_?\d{2,3}[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d{1,3}W)+$/;
688
715
  function parseStyleSuffixes(name) {
689
716
  const match = name.match(SUFFIX_RE);
690
717
  if (!match)
@@ -693,7 +720,7 @@ function parseStyleSuffixes(name) {
693
720
  if (!baseName)
694
721
  return null;
695
722
  const style = {};
696
- const suffixes = match[0].match(/_?\d{2,3}[osxyr]|[01]f|_[0-9a-fA-F]{3,8}[FS]|\d{1,3}W/g);
723
+ const suffixes = match[0].match(/_?\d{2,3}[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d{1,3}W/g);
697
724
  let tx = '';
698
725
  let ty = '';
699
726
  let scale = '';
@@ -701,11 +728,16 @@ function parseStyleSuffixes(name) {
701
728
  let flip = '';
702
729
  for (const s of suffixes) {
703
730
  const code = s[s.length - 1];
704
- if (code === 'F') {
705
- style.fill = '#' + s.slice(1, -1);
706
- }
707
- else if (code === 'S') {
708
- style.stroke = '#' + s.slice(1, -1);
731
+ if (code === 'F' || code === 'S') {
732
+ const raw = s.slice(1, -1);
733
+ const isHex = /^[0-9a-fA-F]{3,8}$/.test(raw);
734
+ const value = isHex ? '#' + raw : vars[raw];
735
+ if (code === 'F') {
736
+ style.fill = value;
737
+ }
738
+ else {
739
+ style.stroke = value;
740
+ }
709
741
  }
710
742
  else if (code === 'W') {
711
743
  style.strokeWidth = s.slice(0, -1);
@@ -764,30 +796,34 @@ function resolveIcon(prop, parts) {
764
796
  return icon;
765
797
  }
766
798
  }
767
- // Stack: foo50o$bar → overlay foo at 50% opacity on bar
799
+ // Stack: foo$bar$bazbaz on bottom, bar on top, foo on top
768
800
  if (prop.includes('$')) {
769
- const [overlayName, baseName] = prop.split('$', 2);
770
- const baseIcon = resolveIcon(baseName, []);
771
- const overlayIcon = resolveIcon(overlayName, []);
772
- Object.assign(overlayIcon.style, {
773
- position: 'absolute',
774
- inset: '0',
775
- width: '100%',
776
- height: '100%',
801
+ const segments = prop.split('$');
802
+ // Last segment is the base (sets the size), rest are overlays
803
+ const base = resolveIcon(segments[segments.length - 1], []);
804
+ const overlays = segments.slice(0, -1).map((name) => {
805
+ const icon = resolveIcon(name, []);
806
+ Object.assign(icon.style, {
807
+ position: 'absolute',
808
+ inset: '0',
809
+ width: '100%',
810
+ height: '100%',
811
+ });
812
+ return icon;
777
813
  });
778
- return wrapIcon(prop, parts, baseIcon, overlayIcon);
814
+ return wrapIcon(prop, parts, base, ...overlays);
779
815
  }
780
- // Style suffixes first — strip them, resolve the base, apply after
816
+ // Try composition (spin, un, check, etc.)
817
+ const composed = composeIcon(prop, parts);
818
+ if (composed)
819
+ return composed;
820
+ // Style suffixes — strip them, resolve the base, apply after
781
821
  const parsed = parseStyleSuffixes(prop);
782
822
  if (parsed) {
783
823
  const icon = resolveIcon(parsed.baseName, parts);
784
824
  Object.assign(icon.style, parsed.style);
785
825
  return icon;
786
826
  }
787
- // Try composition (spin, un, check, etc.)
788
- const composed = composeIcon(prop, parts);
789
- if (composed)
790
- return composed;
791
827
  if (prop) {
792
828
  console.warn(`icon ${prop} does not exist`);
793
829
  }
@@ -809,6 +845,7 @@ export class SvgIcon extends WebComponent {
809
845
  '--tosi-icon-stroke-linecap': 'var(--icon-stroke-linecap, round)',
810
846
  '--tosi-icon-fill': 'var(--xin-icon-fill, var(--icon-fill, none))',
811
847
  display: 'inline-flex',
848
+ verticalAlign: 'text-bottom',
812
849
  stroke: 'currentColor',
813
850
  strokeWidth: varDefault.tosiIconStrokeWidth('2px'),
814
851
  strokeLinejoin: varDefault.tosiIconStrokeLinejoin('round'),