tosijs-ui 1.16.18 → 1.16.20

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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.20
4
+
5
+ - **A registered icon with style suffixes wins over a composition rule.** `checkCircle90r`
6
+ drew the `check` rule over a rotated `circle`; it is now `checkCircle` turned 90°, in the
7
+ renderer and in `iconSvg`. A rule still applies where the whole name is not a registered
8
+ icon with suffixes (`checkLock90r`). closes board #3295
9
+ - **`defineIcons` warns when a new name it defines is also what a composition rule would
10
+ draw** ("icon checkFoo is also a composition rule's name; your definition wins"). Only a
11
+ name that reads as the rule's warns (`checkFoo`, not `checkbox` or `unlink`), and
12
+ overriding a name that already exists, a built-in such as `unlock` included, never does.
13
+ - **A redirect to a missing icon names what was asked for.** `defineIcons({ myChevron:
14
+ 'chevronDwon' })` warned "icon chevronDwon does not exist", naming only the target; it
15
+ now adds "(requested as myChevron)", through stacks too. closes board #3294
16
+ - **Six live examples on the doc site showed nothing; a Playwright spec now fails on any
17
+ that does.**
18
+ - The table's Column Width section rendered an empty `<tosi-table>` (an `html` fence
19
+ meant as syntax). It is now a live before/after with a test.
20
+ - The router page's examples shared the page's one router, so the last to run blanked
21
+ the others. Basic Usage holds it on load and has links; Named Views routes when you
22
+ press its button, and a test checks the first example shows its route.
23
+ - Code meant to be read no longer runs as an empty example: the token-palette `css` on
24
+ highlight-block and highlight-styles, liveTheme's programmatic-control snippet, and the
25
+ migrating guide's setSchemaValidator lines are `:static`.
26
+
27
+ ## 1.16.19
28
+
29
+ - **`iconSvg()` resolves the built-in icons that rotate or flip another.** 27 built-in
30
+ names are redirects with a rotation or flip (`chevronDown` is `chevronRight90r`; also
31
+ `chevronLeft`/`Up`, `arrowDown`/`Left`/`Up`, `cornerDownLeft`, `rotateCcw`, `thumbsDown`
32
+ and others), and `iconSvg()` returned `undefined` for every one of them, silently. They
33
+ now return markup with the transform applied about the centre of the viewBox. A rotation
34
+ or flip written inline (`iconSvg('chevronRight90r')`) and other inline names returned
35
+ undefined silently too: a rotation or flip now resolves, and anything else throws. A Playwright
36
+ test draws each one both ways, through `iconSvg` and through `icons.<name>()`, and
37
+ finds no pixel that differs by more than antialiasing, in all three browsers.
38
+ - **`iconSvg()` throws for a name it cannot turn into one SVG.** A stack
39
+ (`cloudSync: 'spin120Loader40s_30x$cloud'`, or `tosi$hat` written inline), a rule
40
+ (`unLock`, `spin90Loader`), a style suffix other than rotation or flip (`check50o`), a
41
+ redirect to a name that is not an icon, or a redirect cycle all returned `undefined`,
42
+ the same answer as a name that does not exist, so a build script rendered nothing and
43
+ said nothing. Each now throws, saying which it is and, where the icon renderer would draw
44
+ it, that `icons.<name>()` does so where there is a DOM. **Code that relied on `undefined`
45
+ for those names now gets an exception;** a name nothing would draw still returns
46
+ `undefined`, as documented. This is the follow-up to #224, for the DOM-free path.
47
+ - `iconSvg()` reads suffixes with the same grammar as the icon renderer, now shared
48
+ between them: `edit2_90r` is `edit2` turned 90°, not -90°.
49
+
3
50
  ## 1.16.18
4
51
 
5
52
  - **A `defineIcons` name for a stacked composition draws the stack.** `defineIcons({
@@ -948,10 +948,38 @@ by adding the `noreorder` attribute to the `<tosi-table>`.
948
948
 
949
949
  Columns are laid out at the widths you give them, so a table whose columns add up to less
950
950
  than its container leaves a strip of blank space on the right. Add the `full-width-header`
951
- attribute and the leftover space goes to the last **unpinned** column instead:
951
+ attribute (`fullWidthHeader: true` from the creator) and the leftover space goes to the last
952
+ **unpinned** column instead. The same columns, without and with it:
952
953
 
953
- ```html
954
- <tosi-table full-width-header></tosi-table>
954
+ ```js
955
+ import { tosiTable } from 'tosijs-ui'
956
+
957
+ const array = [
958
+ { id: 1, name: 'Ada', role: 'Analyst' },
959
+ { id: 2, name: 'Grace', role: 'Admiral' },
960
+ { id: 3, name: 'Margaret', role: 'Director' },
961
+ ]
962
+ const columns = () => [
963
+ { prop: 'id', name: 'ID', width: 60 },
964
+ { prop: 'name', name: 'Name', width: 120 },
965
+ { prop: 'role', name: 'Role', width: 120 },
966
+ ]
967
+ const style = { display: 'block', height: '150px' }
968
+ preview.append(
969
+ tosiTable({ array, columns: columns(), style }),
970
+ tosiTable({ array, columns: columns(), style, fullWidthHeader: true })
971
+ )
972
+ ```
973
+ ```test
974
+ const roleWidth = (table) =>
975
+ [...table.querySelectorAll('.thead .th')].at(-1).getBoundingClientRect().width
976
+ test('only the full-width table stretches its last column', async () => {
977
+ const [plain, full] = preview.querySelectorAll('tosi-table')
978
+ for (let i = 0; i < 100 && !full.querySelector('.thead .th'); i++) await waitMs(20)
979
+ expect(full.hasAttribute('full-width-header')).toBe(true)
980
+ expect(Math.round(roleWidth(plain))).toBe(120)
981
+ expect(roleWidth(full)).toBeGreaterThan(120)
982
+ })
955
983
  ```
956
984
 
957
985
  Right-pinned columns are skipped because they sit against the right edge by definition —
@@ -23,7 +23,7 @@ ensureHighlightStyles()
23
23
  Every colour is a `varDefault`, so retheme any single token type — in either mode —
24
24
  by setting its variable:
25
25
 
26
- ```css
26
+ ```css:static
27
27
  :root { --token-keyword: rebeccapurple; }
28
28
  ```
29
29
 
@@ -43,7 +43,7 @@ shipped failing WCAG AA on every token type.
43
43
 
44
44
  Every colour is a `varDefault`, so retheme one token type without touching the rest:
45
45
 
46
- ```css
46
+ ```css:static
47
47
  :root { --token-keyword: rebeccapurple; }
48
48
  ```
49
49
 
@@ -0,0 +1,20 @@
1
+ import type { ElementPart } from 'tosijs';
2
+ export interface IconRule {
3
+ prefix: string | RegExp;
4
+ apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
5
+ }
6
+ /** A registered icon, or style suffixes on one (`chevronRight50o`). */
7
+ export declare function canResolve(name: string): boolean;
8
+ /**
9
+ * `spinN<Icon>` animates, so it is drawn by the DOM renderer, which installs `apply` when
10
+ * it loads. Its prefix is here so that every module knows the rule exists.
11
+ */
12
+ export declare const spinRule: IconRule;
13
+ export declare const iconRules: IconRule[];
14
+ /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
15
+ export declare function matchRule(rule: IconRule, name: string): {
16
+ baseName: string;
17
+ match: RegExpMatchArray | string;
18
+ } | null;
19
+ /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
20
+ export declare const ruleDraws: (name: string) => boolean;
@@ -0,0 +1,72 @@
1
+ /*
2
+ Composition rules (`spinNLoader`, `unLock`, `checkFile`, …) and how a name is matched to
3
+ one, in ONE place. `icons.ts` (the DOM renderer) draws through them; `icon-svg.ts` (markup
4
+ without a DOM) asks the same question, "would the renderer draw this name?", so it can tell
5
+ a rule it cannot render from a name nothing would draw. It once guessed rules from the shape
6
+ of a name instead, and threw for `redLock`. DOM-free, so `icon-svg.ts` can import it.
7
+
8
+ Rules an app adds with `iconRules.push()` are seen by both, as long as they are pushed
9
+ before the name is asked for.
10
+ */
11
+ import iconData from './icon-data.js';
12
+ import { splitSuffixes } from './icon-suffixes.js';
13
+ /** A registered icon, or style suffixes on one (`chevronRight50o`). */
14
+ export function canResolve(name) {
15
+ const data = iconData;
16
+ if (data[name])
17
+ return true;
18
+ const split = splitSuffixes(name);
19
+ return split != null && !!data[split.baseName];
20
+ }
21
+ /**
22
+ * `spinN<Icon>` animates, so it is drawn by the DOM renderer, which installs `apply` when
23
+ * it loads. Its prefix is here so that every module knows the rule exists.
24
+ */
25
+ export const spinRule = {
26
+ prefix: /^spin(_?\d+)/,
27
+ apply() {
28
+ throw new Error('spin icons are drawn by the icon renderer (tosijs-ui/icons)');
29
+ },
30
+ };
31
+ export const iconRules = [
32
+ spinRule,
33
+ {
34
+ prefix: 'un',
35
+ apply: (baseName) => `slash25o$${baseName}75s75o`,
36
+ },
37
+ {
38
+ prefix: 'check',
39
+ apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
40
+ },
41
+ {
42
+ prefix: 'cancel',
43
+ apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
44
+ },
45
+ {
46
+ prefix: 'search',
47
+ apply: (baseName) => `search80s30x30y$${baseName}50o`,
48
+ },
49
+ ];
50
+ /** The icon a rule would apply to in `name`, or null if the rule does not match it. */
51
+ export function matchRule(rule, name) {
52
+ if (rule.prefix instanceof RegExp) {
53
+ const m = name.match(new RegExp(rule.prefix.source + '(.+)$'));
54
+ if (!m)
55
+ return null;
56
+ const tail = m[m.length - 1];
57
+ return { baseName: tail[0].toLowerCase() + tail.slice(1), match: m };
58
+ }
59
+ if (!name.startsWith(rule.prefix) || name.length <= rule.prefix.length) {
60
+ return null;
61
+ }
62
+ return {
63
+ baseName: name[rule.prefix.length].toLowerCase() +
64
+ name.slice(rule.prefix.length + 1),
65
+ match: rule.prefix,
66
+ };
67
+ }
68
+ /** Whether some rule would draw `name`: it matches, and the icon it applies to exists. */
69
+ export const ruleDraws = (name) => iconRules.some((rule) => {
70
+ const matched = matchRule(rule, name);
71
+ return matched != null && canResolve(matched.baseName);
72
+ });
@@ -0,0 +1,8 @@
1
+ export declare const SUFFIX_RE: RegExp;
2
+ /** A name split into its base and its suffix tokens, in order, or null if it has none. */
3
+ export declare function splitSuffixes(name: string): {
4
+ baseName: string;
5
+ tokens: string[];
6
+ } | null;
7
+ /** The signed number in a numeric token: `_45r` is -45. */
8
+ export declare const suffixNumber: (token: string) => number;
@@ -0,0 +1,31 @@
1
+ /*
2
+ The icon style-suffix grammar, in ONE place. `icons.ts` (the DOM renderer) and
3
+ `icon-svg.ts` (markup without a DOM) both read names through it. `icon-svg.ts` kept its own
4
+ copy for one release candidate, and the two disagreed within a day: it read the `_` in
5
+ `edit2_90r` as a minus sign and rotated the wrong way. DOM-free, so `icon-svg.ts` can
6
+ import it.
7
+
8
+ Suffixes are a value then a letter code:
9
+ 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
10
+ 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
11
+ _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
12
+ _brandColorF (fill var), _accentS (stroke var)
13
+ Icon names ending in digits need a _ separator: edit2_50o
14
+ */
15
+ export const SUFFIX_RE = /(?:(?<=[a-zA-Z_])(?:_?\d+[osxyr]|[01]f|\d+W)|_[a-zA-Z0-9]+[FS])+$/;
16
+ const SUFFIX_TOKEN_RE = /_?\d+[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d+W/g;
17
+ /** A name split into its base and its suffix tokens, in order, or null if it has none. */
18
+ export function splitSuffixes(name) {
19
+ const match = name.match(SUFFIX_RE);
20
+ if (!match)
21
+ return null;
22
+ let baseName = name.slice(0, match.index);
23
+ // Strip the trailing _ separator (for digit-ending names: edit2_50o)
24
+ if (baseName.endsWith('_'))
25
+ baseName = baseName.slice(0, -1);
26
+ if (!baseName)
27
+ return null;
28
+ return { baseName, tokens: match[0].match(SUFFIX_TOKEN_RE) };
29
+ }
30
+ /** The signed number in a numeric token: `_45r` is -45. */
31
+ export const suffixNumber = (token) => parseInt(token.replace('_', '-'));
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Raw SVG markup for an icon, or `undefined` when nothing by that name would draw.
3
+ *
4
+ * Throws when the name would draw but cannot be one SVG string: a stack (`a$b`), a rule
5
+ * (`spin…`, `un…`), a style suffix other than rotation or flip, a redirect to a name that
6
+ * is not an icon, or a redirect cycle. Those need `icons.<name>()` where there is a DOM.
7
+ */
1
8
  export declare function iconSvg(name: string): string | undefined;
2
9
  /** Every icon name currently registered, including any added via defineIcons. */
3
10
  export declare function iconNames(): string[];
package/dist/icon-svg.js CHANGED
@@ -5,7 +5,7 @@
5
5
  ```js
6
6
  import { iconSvg, iconNames } from 'tosijs-ui'
7
7
 
8
- const markup = iconSvg('tosi') // '<svg …>…</svg>' | undefined
8
+ const markup = iconSvg('tosi') // '<svg …>…</svg>', or undefined for an unknown name
9
9
  preview.innerHTML =
10
10
  `<p>${iconNames().length} icons registered.</p>` +
11
11
  `<div style="width:48px">${markup}</div>`
@@ -19,6 +19,14 @@ what the live-example runner can rewrite.)
19
19
  is registered. Neither touches the DOM, so both work in a build script, a server-rendered
20
20
  template, or an ePub pass.
21
21
 
22
+ Redirects are followed, including the built-in ones that rotate or flip another icon
23
+ (`chevronDown` is `chevronRight90r`), and a rotation or flip written inline works too
24
+ (`iconSvg('chevronRight90r')`). A name the icon renderer would draw but that cannot be one
25
+ SVG string **throws**, saying why: a stack (`a$b`), a rule (`spin90Loader`, `unLock`), any
26
+ other style suffix (`check50o`), a redirect to a name that is not an icon, or a redirect
27
+ cycle. Render those with `icons.<name>()` where there is a DOM; the suffixes and rules are
28
+ described on the [[icons]] page. Only a name that nothing would draw returns `undefined`.
29
+
22
30
  Deliberately a separate module from `icons.ts`: that one imports tosijs to build
23
31
  elements, so importing it drags in code that needs `HTMLElement` and throws in a build
24
32
  script — the exact context that wanted the markup in the first place. This module
@@ -32,18 +40,79 @@ resorted to when putting its own logo on a generated page. There is nothing to p
32
40
  the map ships in the bundle either way.
33
41
  */
34
42
  import iconData from './icon-data.js';
43
+ import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
44
+ import { canResolve, ruleDraws } from './icon-rules.js';
45
+ const data = iconData;
46
+ // Rotation and flip, the only suffixes the built-in redirects use (`chevronDown` is
47
+ // `chevronRight90r`). Anything else needs the DOM renderer.
48
+ const isGeometry = (token) => /^(_?\d+r|[01]f)$/.test(token);
49
+ const stripEscape = (name) => name.endsWith('_') ? name.slice(0, -1) : name;
50
+ // Whether the icon renderer would draw something for a name: a registered icon, suffixes on
51
+ // one (`chevronRight50o`), a stack (`a$b`), or a rule applied to one (`spin90Loader`,
52
+ // `unLock`). The rules and suffixes are the renderer's own (icon-rules, icon-suffixes).
53
+ const isComposition = (name) => name.includes('$') || canResolve(name) || ruleDraws(name);
54
+ /**
55
+ * Raw SVG markup for an icon, or `undefined` when nothing by that name would draw.
56
+ *
57
+ * Throws when the name would draw but cannot be one SVG string: a stack (`a$b`), a rule
58
+ * (`spin…`, `un…`), a style suffix other than rotation or flip, a redirect to a name that
59
+ * is not an icon, or a redirect cycle. Those need `icons.<name>()` where there is a DOM.
60
+ */
35
61
  export function iconSvg(name) {
36
- const data = iconData;
37
- let key = name.endsWith('_') ? name.slice(0, -1) : name;
38
- for (let i = 0; i < 10; i++) {
39
- const value = data[key];
40
- if (value === undefined)
41
- return undefined;
62
+ const key = stripEscape(name);
63
+ if (!isComposition(key))
64
+ return undefined;
65
+ return resolve(name, key, new Set());
66
+ }
67
+ function resolve(asked, name, seen) {
68
+ const key = stripEscape(name);
69
+ const value = data[key];
70
+ if (value !== undefined) {
42
71
  if (value.startsWith('<'))
43
72
  return value;
44
- key = value; // a redirect to another icon's name
73
+ if (seen.has(key)) {
74
+ throw new Error(`iconSvg("${asked}"): the icon redirects in a cycle`);
75
+ }
76
+ seen.add(key);
77
+ return resolve(asked, value, seen);
78
+ }
79
+ // The renderer's order for a name that is not registered: a stack, then suffixes on a
80
+ // registered icon (they win over a rule, #3295), then a rule.
81
+ const split = key.includes('$') ? null : splitSuffixes(key);
82
+ if (split &&
83
+ data[split.baseName] !== undefined &&
84
+ split.tokens.every(isGeometry)) {
85
+ return transformed(asked, resolve(asked, split.baseName, seen), split.tokens);
86
+ }
87
+ const what = key === stripEscape(asked) ? `"${key}"` : `it resolves to "${key}", which`;
88
+ if (isComposition(key)) {
89
+ throw new Error(`iconSvg("${asked}"): ${what} is not a single SVG (a stack, a rule, or a style ` +
90
+ `suffix other than rotation or flip). Render it with icons.${stripEscape(asked)}() ` +
91
+ `where there is a DOM.`);
92
+ }
93
+ throw new Error(`iconSvg("${asked}"): it redirects to "${key}", which is not a registered icon`);
94
+ }
95
+ // Apply rotate/flip suffixes about the centre of the viewBox, as the DOM renderer does
96
+ // with `transform-origin: 50% 50%`.
97
+ function transformed(asked, svg, tokens) {
98
+ const parts = svg.match(/^(\s*<svg\b[^>]*>)([\s\S]*)(<\/svg>\s*)$/);
99
+ const viewBox = parts?.[1]
100
+ .match(/viewBox="([^"]+)"/)?.[1]
101
+ .split(/[\s,]+/)
102
+ .map(Number);
103
+ if (!parts || !viewBox || viewBox.length !== 4 || viewBox.some(isNaN)) {
104
+ throw new Error(`iconSvg("${asked}"): cannot transform an icon without a viewBox`);
45
105
  }
46
- return undefined; // redirect loop — treat as missing rather than hang
106
+ const [x, y, width, height] = viewBox;
107
+ const cx = x + width / 2;
108
+ const cy = y + height / 2;
109
+ const ops = tokens.map((token) => token.endsWith('r')
110
+ ? `rotate(${suffixNumber(token)})`
111
+ : token === '0f'
112
+ ? 'scale(-1 1)'
113
+ : 'scale(1 -1)');
114
+ const transform = `translate(${cx} ${cy}) ${ops.join(' ')} translate(${-cx} ${-cy})`;
115
+ return `${parts[1]}<g transform="${transform}">${parts[2]}</g>${parts[3]}`;
47
116
  }
48
117
  /** Every icon name currently registered, including any added via defineIcons. */
49
118
  export function iconNames() {
package/dist/icons.d.ts CHANGED
@@ -1,16 +1,12 @@
1
1
  import { ElementCreator, ElementPart } from 'tosijs';
2
2
  import { SVGIconMap } from './icon-types.js';
3
+ export { iconRules, type IconRule } from './icon-rules.js';
3
4
  export declare const defineIcons: (newIcons: {
4
5
  [key: string]: string;
5
6
  }) => void;
6
7
  export { iconSvg, iconNames } from './icon-svg.js';
7
8
  export type { IconElement, IconName, SVGIconMap } from './icon-types.js';
8
9
  export declare const svg2DataUrl: (icon: Element, fill?: string, stroke?: string, strokeWidth?: number) => string;
9
- export interface IconRule {
10
- prefix: string | RegExp;
11
- apply: (baseName: string, match: RegExpMatchArray | string, parts: ElementPart[]) => Element | string | null;
12
- }
13
- export declare const iconRules: IconRule[];
14
10
  /** Wrap icon elements in a composite container (for custom function rules) */
15
11
  export declare function wrapIcon(prop: string, parts: ElementPart[], ...children: Element[]): HTMLSpanElement;
16
12
  /** Resolve an icon name through the full pipeline (redirects, suffixes, rules, stacking) */
package/dist/icons.js CHANGED
@@ -564,8 +564,34 @@ copies or substantial portions of the Software.
564
564
  */
565
565
  import { elements, svgElements, Color, vars, varDefault, withAttributes, } from 'tosijs';
566
566
  import iconData from './icon-data.js';
567
+ import { splitSuffixes, suffixNumber } from './icon-suffixes.js';
568
+ import { iconRules, spinRule, matchRule, canResolve } from './icon-rules.js';
569
+ export { iconRules } from './icon-rules.js';
567
570
  export const defineIcons = (newIcons) => {
571
+ const data = iconData;
572
+ const isNew = Object.keys(newIcons).filter((name) => !data[name]);
568
573
  Object.assign(iconData, newIcons);
574
+ /*
575
+ A defined icon always wins over a composition rule that would produce the same name, but
576
+ the author may not know the rule exists. Say so once, here, where the name was chosen, and
577
+ only for a NEW name that reads as the rule's: `checkTestShadow`, not `checkbox` or `unlink`
578
+ (a lowercase run is a word, not a prefix plus an icon, so icon packs stay quiet).
579
+ Overriding a name that already existed, a built-in such as `unlock` or `checkCircle`
580
+ included, is a deliberate choice of that name and never warns, so neither does defining a
581
+ name a second time.
582
+ */
583
+ for (const name of isNew) {
584
+ const readsAsRule = iconRules.some((rule) => {
585
+ const matched = matchRule(rule, name);
586
+ if (matched == null || !canResolve(matched.baseName))
587
+ return false;
588
+ return (typeof rule.prefix !== 'string' ||
589
+ /[A-Z]/.test(name[rule.prefix.length]));
590
+ });
591
+ if (readsAsRule) {
592
+ console.warn(`icon ${name} is also a composition rule's name; your definition wins`);
593
+ }
594
+ }
569
595
  };
570
596
  // Raw-markup accessors live in ./icon-svg (DOM-free) and are re-exported here.
571
597
  export { iconSvg, iconNames } from './icon-svg.js';
@@ -629,47 +655,28 @@ export const svg2DataUrl = (icon, fill, stroke, strokeWidth) => {
629
655
  };
630
656
  // Appends _ to names ending in digits to prevent suffix ambiguity
631
657
  const safeIconSuffix = (name) => /\d$/.test(name) ? name + '_' : name;
632
- export const iconRules = [
633
- {
634
- prefix: /^spin(_?\d+)/,
635
- apply(baseName, match, parts) {
636
- const dps = match[1].replace('_', '-');
637
- const duration = 360 / Math.abs(parseFloat(dps));
638
- const direction = dps.startsWith('-') ? 'reverse' : 'normal';
639
- // Resolve baseName with suffixes intact — they apply to the icon
640
- const icon = resolveIcon(baseName, []);
641
- const el = icon;
642
- if (el.animate) {
643
- const base = el.style.transform || '';
644
- el.animate([
645
- { transform: `${base} rotate(0deg)` },
646
- { transform: `${base} rotate(360deg)` },
647
- ], {
648
- duration: duration * 1000,
649
- iterations: Infinity,
650
- direction,
651
- });
652
- }
653
- return wrapIcon(baseName, parts, icon);
654
- },
655
- },
656
- {
657
- prefix: 'un',
658
- apply: (baseName) => `slash25o$${baseName}75s75o`,
659
- },
660
- {
661
- prefix: 'check',
662
- apply: (baseName) => `check75o_00aa00S$${baseName}75s50o`,
663
- },
664
- {
665
- prefix: 'cancel',
666
- apply: (baseName) => `x75o_cc0000S$${baseName}75s50o`,
667
- },
668
- {
669
- prefix: 'search',
670
- apply: (baseName) => `search80s30x30y$${baseName}50o`,
671
- },
672
- ];
658
+ // The rule list and how names match it live in icon-rules.ts, shared with iconSvg. Spin
659
+ // animates, so its renderer is installed here.
660
+ spinRule.apply = (baseName, match, parts) => {
661
+ const dps = match[1].replace('_', '-');
662
+ const duration = 360 / Math.abs(parseFloat(dps));
663
+ const direction = dps.startsWith('-') ? 'reverse' : 'normal';
664
+ // Resolve baseName with suffixes intact — they apply to the icon
665
+ const icon = resolveIcon(baseName, []);
666
+ const el = icon;
667
+ if (el.animate) {
668
+ const base = el.style.transform || '';
669
+ el.animate([
670
+ { transform: `${base} rotate(0deg)` },
671
+ { transform: `${base} rotate(360deg)` },
672
+ ], {
673
+ duration: duration * 1000,
674
+ iterations: Infinity,
675
+ direction,
676
+ });
677
+ }
678
+ return wrapIcon(baseName, parts, icon);
679
+ };
673
680
  let iconIdCounter = 0;
674
681
  function makeIcon(spec, parts) {
675
682
  const div = elements.div();
@@ -748,38 +755,13 @@ export function wrapIcon(prop, parts, ...children) {
748
755
  }
749
756
  return wrapper;
750
757
  }
751
- function canResolve(name) {
752
- const data = iconData;
753
- if (data[name])
754
- return true;
755
- const parsed = parseStyleSuffixes(name);
756
- return parsed != null && !!data[parsed.baseName];
757
- }
758
758
  function composeIcon(prop, parts) {
759
759
  for (const rule of iconRules) {
760
- let baseName;
761
- let match;
762
- if (rule.prefix instanceof RegExp) {
763
- const re = new RegExp(rule.prefix.source + '(.+)$');
764
- const m = prop.match(re);
765
- if (!m)
766
- continue;
767
- baseName = m[m.length - 1];
768
- baseName = baseName[0].toLowerCase() + baseName.slice(1);
769
- match = m;
770
- }
771
- else {
772
- if (!prop.startsWith(rule.prefix) || prop.length <= rule.prefix.length)
773
- continue;
774
- baseName =
775
- prop[rule.prefix.length].toLowerCase() +
776
- prop.slice(rule.prefix.length + 1);
777
- match = rule.prefix;
778
- }
760
+ const matched = matchRule(rule, prop);
779
761
  // Only apply if baseName can actually resolve to an icon
780
- if (!canResolve(baseName))
762
+ if (!matched || !canResolve(matched.baseName))
781
763
  continue;
782
- const result = rule.apply(safeIconSuffix(baseName), match, parts);
764
+ const result = rule.apply(safeIconSuffix(matched.baseName), matched.match, parts);
783
765
  if (typeof result === 'string')
784
766
  return resolveIcon(result, parts);
785
767
  if (result)
@@ -789,41 +771,20 @@ function composeIcon(prop, parts) {
789
771
  }
790
772
  // Redirect names being resolved right now, so a redirect cycle ends instead of recursing.
791
773
  const expanding = new Set();
792
- // Style suffixes — value then letter code:
793
- // 50o (opacity), 75s (scale), 20x (translateX%), _10y (translateY%)
794
- // 90r (rotate 90°), _45r (rotate -45°), 0f (flipH), 1f (flipV)
795
- // _FF0000F (fill hex), _f00S (stroke hex), 3W (stroke-width)
796
- // _brandColorF (fill var), _accentS (stroke var)
797
- // Icon names ending in digits need _ separator: edit2_50o
798
- const SUFFIX_RE = /(?:(?<=[a-zA-Z_])(?:_?\d+[osxyr]|[01]f|\d+W)|_[a-zA-Z0-9]+[FS])+$/;
774
+ // The suffix grammar lives in icon-suffixes.ts, shared with iconSvg.
799
775
  function parseStyleSuffixes(name) {
800
- const match = name.match(SUFFIX_RE);
801
- if (!match)
802
- return null;
803
- let baseName = name.slice(0, match.index);
804
- if (!baseName)
805
- return null;
806
- // Strip trailing _ separator (for digit-ending names: edit2_50o)
807
- if (baseName.endsWith('_'))
808
- baseName = baseName.slice(0, -1);
809
- if (!baseName)
776
+ const split = splitSuffixes(name);
777
+ if (!split)
810
778
  return null;
779
+ const { baseName } = split;
811
780
  const data = iconData;
812
781
  // Accept if baseName is in iconData, or matches a composition rule prefix
813
782
  if (!data[baseName]) {
814
- const matchesRule = iconRules.some((rule) => {
815
- if (rule.prefix instanceof RegExp) {
816
- return rule.prefix.test(baseName);
817
- }
818
- return (baseName.startsWith(rule.prefix) && baseName.length > rule.prefix.length);
819
- });
820
- if (!matchesRule)
783
+ if (!iconRules.some((rule) => matchRule(rule, baseName)))
821
784
  return null;
822
785
  }
823
786
  const style = {};
824
- const suffixes = match[0].match(/_?\d+[osxyr]|[01]f|_[a-zA-Z0-9]+[FS]|\d+W/g);
825
- if (!suffixes)
826
- return null;
787
+ const suffixes = split.tokens;
827
788
  const transforms = [];
828
789
  for (const s of suffixes) {
829
790
  const code = s[s.length - 1];
@@ -842,7 +803,7 @@ function parseStyleSuffixes(name) {
842
803
  style.strokeWidth = s.slice(0, -1);
843
804
  }
844
805
  else {
845
- const val = parseInt(s.replace('_', '-'));
806
+ const val = suffixNumber(s);
846
807
  switch (code) {
847
808
  case 'o':
848
809
  style.opacity = String(val / 100);
@@ -934,19 +895,28 @@ export function resolveIcon(prop, parts) {
934
895
  });
935
896
  return wrapIcon(prop, parts, base, ...overlays);
936
897
  }
937
- // Try composition (spin, un, check, etc.)
938
- const composed = composeIcon(prop, parts);
939
- if (composed)
940
- return composed;
941
- // Style suffixes — strip them, resolve the base, apply after
898
+ /*
899
+ Style suffixes on a registered icon win over a composition rule (#3295): `checkCircle90r`
900
+ is `checkCircle` turned, not the `check` rule over `circle90r`. A rule still applies
901
+ when its suffixes are on an icon that only the rule makes (`spin90Loader50o`).
902
+ */
942
903
  const parsed = parseStyleSuffixes(prop);
904
+ const native = parsed != null && iconData[parsed.baseName];
905
+ if (!native) {
906
+ const composed = composeIcon(prop, parts);
907
+ if (composed)
908
+ return composed;
909
+ }
943
910
  if (parsed) {
944
911
  const icon = resolveIcon(parsed.baseName, parts);
945
912
  Object.assign(icon.style, parsed.style);
946
913
  return icon;
947
914
  }
948
915
  if (prop) {
949
- console.warn(`icon ${prop} does not exist`);
916
+ // Inside a redirect, also name what the caller asked for: a typo in a defineIcons
917
+ // target is otherwise reported under a name that appears nowhere in their code.
918
+ const via = expanding.size > 0 ? ` (requested as ${[...expanding].join(' → ')})` : '';
919
+ console.warn(`icon ${prop} does not exist${via}`);
950
920
  }
951
921
  return makeIcon(iconData.square, parts);
952
922
  }