ionbase-ui 0.16.0 → 0.18.0

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 (53) hide show
  1. package/README.md +71 -7
  2. package/dist/meta/Alert.json +269 -0
  3. package/dist/meta/Avatar.json +97 -0
  4. package/dist/meta/AvatarGroup.json +82 -0
  5. package/dist/meta/Badge.json +83 -0
  6. package/dist/meta/Button.json +556 -0
  7. package/dist/meta/Checkbox.json +113 -0
  8. package/dist/meta/Divider.json +30 -0
  9. package/dist/meta/FullCard.json +97 -0
  10. package/dist/meta/Header.json +91 -0
  11. package/dist/meta/Icon.json +35 -0
  12. package/dist/meta/Input.json +558 -0
  13. package/dist/meta/Link.json +239 -0
  14. package/dist/meta/Logo.json +46 -0
  15. package/dist/meta/LogoMark.json +41 -0
  16. package/dist/meta/Menu.json +50 -0
  17. package/dist/meta/MenuItem.json +75 -0
  18. package/dist/meta/Modal.json +241 -0
  19. package/dist/meta/NavItem.json +76 -0
  20. package/dist/meta/PhoneInput.json +472 -0
  21. package/dist/meta/Popover.json +124 -0
  22. package/dist/meta/Radio.json +104 -0
  23. package/dist/meta/RadioGroup.json +126 -0
  24. package/dist/meta/ScrollProgress.json +63 -0
  25. package/dist/meta/Select.json +195 -0
  26. package/dist/meta/TabItem.json +15 -0
  27. package/dist/meta/Table.json +135 -0
  28. package/dist/meta/TableBody.json +43 -0
  29. package/dist/meta/TableCell.json +105 -0
  30. package/dist/meta/TableHead.json +43 -0
  31. package/dist/meta/TableRow.json +56 -0
  32. package/dist/meta/Tabs.json +186 -0
  33. package/dist/meta/Toast.json +105 -0
  34. package/dist/meta/ToastProvider.json +75 -0
  35. package/dist/meta/Toggle.json +102 -0
  36. package/dist/meta/Tooltip.json +78 -0
  37. package/dist/meta/components.json +4816 -0
  38. package/dist/meta/contrast.json +3608 -0
  39. package/dist/meta/index.json +479 -0
  40. package/dist/styles/alert.css +10 -5
  41. package/dist/styles/tokens/base.css +25 -25
  42. package/dist/styles/tokens/theme-dark.css +11 -11
  43. package/dist/tokens/index.d.ts +8 -8
  44. package/dist/tokens/index.js +8 -8
  45. package/eslint-plugin/index.js +64 -0
  46. package/eslint-plugin/meta-data.js +66 -0
  47. package/eslint-plugin/rules/needs-accessible-name.js +88 -0
  48. package/eslint-plugin/rules/no-deprecated-props.js +74 -0
  49. package/eslint-plugin/rules/no-known-contrast-failure.js +75 -0
  50. package/eslint-plugin/rules/no-raw-style-values.js +107 -0
  51. package/eslint-plugin/rules/one-primary-action.js +128 -0
  52. package/package.json +33 -7
  53. package/stylelint-config.js +144 -0
@@ -1092,7 +1092,7 @@ export const tokenValues = {
1092
1092
  "Value": "#401604"
1093
1093
  },
1094
1094
  "color/green/50": {
1095
- "Value": "#effbf6"
1095
+ "Value": "#f8fefc"
1096
1096
  },
1097
1097
  "color/green/100": {
1098
1098
  "Value": "#e4f7f3"
@@ -1101,25 +1101,25 @@ export const tokenValues = {
1101
1101
  "Value": "#c6ebdc"
1102
1102
  },
1103
1103
  "color/green/300": {
1104
- "Value": "#8fdbbb"
1104
+ "Value": "#82d8b4"
1105
1105
  },
1106
1106
  "color/green/400": {
1107
- "Value": "#50c38c"
1107
+ "Value": "#39b378"
1108
1108
  },
1109
1109
  "color/green/500": {
1110
- "Value": "#12a154"
1110
+ "Value": "#14994e"
1111
1111
  },
1112
1112
  "color/green/600": {
1113
- "Value": "#14994e"
1113
+ "Value": "#0d7d38"
1114
1114
  },
1115
1115
  "color/green/700": {
1116
- "Value": "#0d7d38"
1116
+ "Value": "#076426"
1117
1117
  },
1118
1118
  "color/green/800": {
1119
- "Value": "#076426"
1119
+ "Value": "#054d1a"
1120
1120
  },
1121
1121
  "color/green/900": {
1122
- "Value": "#054d1a"
1122
+ "Value": "#023c13"
1123
1123
  },
1124
1124
  "color/blue/50": {
1125
1125
  "Value": "#ebf7ff"
@@ -0,0 +1,64 @@
1
+ /**
2
+ * ionbase-ui/eslint-plugin — the design system's rules, shipped.
3
+ *
4
+ * // eslint.config.js
5
+ * import ionbase from 'ionbase-ui/eslint-plugin';
6
+ * export default [ionbase.configs.recommended];
7
+ *
8
+ * The premise of IonBase is that the code consuming it may be written by an
9
+ * agent with no developer reviewing the output. Guidance in a README does not
10
+ * survive that; a lint error naming the fix does.
11
+ *
12
+ * Every rule's data comes from dist/meta/components.json — deprecations,
13
+ * measured contrast failures, which components require an accessible name, the
14
+ * spacing scale, even Button's default variant. Nothing is hardcoded here, so
15
+ * when a defect is fixed in Figma the rule stops firing without anyone editing
16
+ * this package.
17
+ *
18
+ * Two rules from the plan are deliberately absent. `destructive-needs-confirm`
19
+ * wants to know whether a Modal wraps the action, which is almost always in
20
+ * another file — ESLint's per-file model would make it a false-positive
21
+ * generator. `no-nested-interactive` needs the same. Both belong in a checker
22
+ * with whole-tree visibility, or in the eval harness.
23
+ */
24
+ import noDeprecatedProps from './rules/no-deprecated-props.js';
25
+ import noKnownContrastFailure from './rules/no-known-contrast-failure.js';
26
+ import noRawStyleValues from './rules/no-raw-style-values.js';
27
+ import needsAccessibleName from './rules/needs-accessible-name.js';
28
+ import onePrimaryAction from './rules/one-primary-action.js';
29
+
30
+ const rules = {
31
+ 'no-deprecated-props': noDeprecatedProps,
32
+ 'no-known-contrast-failure': noKnownContrastFailure,
33
+ 'no-raw-style-values': noRawStyleValues,
34
+ 'needs-accessible-name': needsAccessibleName,
35
+ 'one-primary-action': onePrimaryAction,
36
+ };
37
+
38
+ const plugin = { meta: { name: 'ionbase-ui' }, rules };
39
+
40
+ plugin.configs = {
41
+ /** Everything, as errors. The defaults are what the system considers wrong. */
42
+ recommended: {
43
+ name: 'ionbase-ui/recommended',
44
+ plugins: { 'ionbase-ui': plugin },
45
+ rules: {
46
+ 'ionbase-ui/no-deprecated-props': 'error',
47
+ 'ionbase-ui/no-known-contrast-failure': 'error',
48
+ 'ionbase-ui/no-raw-style-values': 'error',
49
+ 'ionbase-ui/needs-accessible-name': 'error',
50
+ 'ionbase-ui/one-primary-action': 'error',
51
+ },
52
+ },
53
+ /** Same rules, as warnings — for adopting the system in an existing app. */
54
+ warn: {
55
+ name: 'ionbase-ui/warn',
56
+ plugins: { 'ionbase-ui': plugin },
57
+ rules: Object.fromEntries(
58
+ Object.keys(rules).map((r) => [`ionbase-ui/${r}`, 'warn']),
59
+ ),
60
+ },
61
+ };
62
+
63
+ export default plugin;
64
+ export { rules };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Rule data, read from the shipped component contracts.
3
+ *
4
+ * Nothing in this plugin hardcodes a component name, a prop, a deprecation or a
5
+ * contrast ratio. All of it comes from dist/meta/components.json, which is
6
+ * generated from the source and the stylesheets and gated by verify-meta.mjs and
7
+ * verify-contrast.mjs. A second hand-maintained copy here would drift, and a
8
+ * drifted lint rule is worse than none — it teaches the wrong thing confidently.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+
16
+ let doc;
17
+ try {
18
+ doc = JSON.parse(
19
+ readFileSync(join(HERE, '..', 'dist', 'meta', 'components.json'), 'utf8'),
20
+ );
21
+ } catch {
22
+ // A source checkout before its first build. Rules degrade to no-ops rather
23
+ // than crashing the consumer's lint run.
24
+ doc = { components: {} };
25
+ }
26
+
27
+ export const components = doc.components ?? {};
28
+ export const isComponent = (name) => Object.hasOwn(components, name);
29
+
30
+ /** { Button: [{ prop, replacement, note }] } — only where the source really says @deprecated. */
31
+ export const deprecations = Object.fromEntries(
32
+ Object.entries(components)
33
+ .filter(([, c]) => (c.deprecated ?? []).length)
34
+ .map(([n, c]) => [n, c.deprecated]),
35
+ );
36
+
37
+ /** Components whose contract says an accessible name is required. */
38
+ export const needsAccessibleName = Object.entries(components)
39
+ .filter(([, c]) =>
40
+ (c.a11y?.requires ?? []).some((r) => /aria-label|accessible name/i.test(r)),
41
+ )
42
+ .map(([n]) => n);
43
+
44
+ /** Measured contrast defects, with the prop combination that reaches them. */
45
+ export const contrastIssues = Object.entries(components).flatMap(([n, c]) =>
46
+ (c.a11y?.knownIssues ?? [])
47
+ .filter((i) => i.appliesTo)
48
+ .map((i) => ({ component: n, ...i })),
49
+ );
50
+
51
+ /** The spacing rungs, for suggesting a token in place of a raw pixel value. */
52
+ export const spacingScale = (() => {
53
+ try {
54
+ const css = readFileSync(
55
+ join(HERE, '..', 'dist', 'styles', 'tokens', 'base.css'),
56
+ 'utf8',
57
+ );
58
+ const out = {};
59
+ for (const m of css.matchAll(/(--spacing-(\d+))\s*:\s*([\d.]+)px/g)) {
60
+ out[Number(m[3])] = m[1];
61
+ }
62
+ return out;
63
+ } catch {
64
+ return {};
65
+ }
66
+ })();
@@ -0,0 +1,88 @@
1
+ import { needsAccessibleName, isComponent } from '../meta-data.js';
2
+
3
+ /**
4
+ * An icon-only control with no `aria-label` is announced as just "button".
5
+ *
6
+ * Which components require a name is not hardcoded — it is every component whose
7
+ * contract's `a11y.requires` says so. Adding that line to a new component's
8
+ * intent file is what turns this rule on for it.
9
+ *
10
+ * Only fires when the element demonstrably has no text: an expression child
11
+ * could be anything, so those are left alone rather than guessed at.
12
+ */
13
+ export default {
14
+ meta: {
15
+ type: 'problem',
16
+ docs: {
17
+ description: 'Require an accessible name on icon-only IonBase controls',
18
+ },
19
+ schema: [],
20
+ messages: {
21
+ unnamed:
22
+ '{{component}} has no text and no accessible name, so it is announced as just "{{role}}". Add `aria-label`, or give it visible text.',
23
+ },
24
+ },
25
+ create(context) {
26
+ const named = new Set(needsAccessibleName);
27
+
28
+ const hasText = (children) =>
29
+ children.some((c) => {
30
+ if (c.type === 'JSXText') return c.value.trim().length > 0;
31
+ // An expression child might render text — assume it does rather than
32
+ // report something we cannot see.
33
+ if (c.type === 'JSXExpressionContainer') return true;
34
+ return false;
35
+ });
36
+
37
+ /** A child element that is itself an IonBase component is not text. */
38
+ const onlyIcons = (children) =>
39
+ children
40
+ .filter((c) => c.type === 'JSXElement')
41
+ .every((c) => {
42
+ const n = c.openingElement.name;
43
+ return (
44
+ n.type === 'JSXIdentifier' &&
45
+ (n.name === 'Icon' || isComponent(n.name) === false)
46
+ );
47
+ });
48
+
49
+ return {
50
+ JSXElement(node) {
51
+ const open = node.openingElement;
52
+ if (open.name.type !== 'JSXIdentifier') return;
53
+ const name = open.name.name;
54
+ if (!named.has(name)) return;
55
+
56
+ const attrs = open.attributes.filter((a) => a.type === 'JSXAttribute');
57
+ // A spread could carry aria-label; do not second-guess it.
58
+ if (open.attributes.some((a) => a.type === 'JSXSpreadAttribute'))
59
+ return;
60
+
61
+ const attrName = (a) =>
62
+ a.name.type === 'JSXIdentifier' ? a.name.name : '';
63
+ if (attrs.some((a) => /^aria-(label|labelledby)$/.test(attrName(a))))
64
+ return;
65
+ // `title` on Modal, `label` on Input/Select — the contract's own naming.
66
+ if (attrs.some((a) => ['title', 'label'].includes(attrName(a)))) return;
67
+
68
+ if (hasText(node.children)) return;
69
+ if (!node.children.length) {
70
+ // Self-closing with no children and no name at all.
71
+ context.report({
72
+ node: open,
73
+ messageId: 'unnamed',
74
+ data: { component: name, role: name.toLowerCase() },
75
+ });
76
+ return;
77
+ }
78
+ if (onlyIcons(node.children)) {
79
+ context.report({
80
+ node: open,
81
+ messageId: 'unnamed',
82
+ data: { component: name, role: name.toLowerCase() },
83
+ });
84
+ }
85
+ },
86
+ };
87
+ },
88
+ };
@@ -0,0 +1,74 @@
1
+ import { deprecations } from '../meta-data.js';
2
+
3
+ /**
4
+ * `<Button disabled>` still type-checks — that is exactly why it needs a lint
5
+ * rule. A deprecation the compiler accepts is one an agent will keep emitting
6
+ * from whatever it learned before the rename.
7
+ */
8
+ export default {
9
+ meta: {
10
+ type: 'problem',
11
+ docs: {
12
+ description: 'Disallow props marked @deprecated in the IonBase contracts',
13
+ },
14
+ fixable: 'code',
15
+ schema: [],
16
+ messages: {
17
+ deprecated:
18
+ '`{{prop}}` is deprecated on `{{component}}`. Use `{{replacement}}`.',
19
+ deprecatedNoFix: '`{{prop}}` is deprecated on `{{component}}`.{{note}}',
20
+ },
21
+ },
22
+ create(context) {
23
+ return {
24
+ JSXOpeningElement(node) {
25
+ if (node.name.type !== 'JSXIdentifier') return;
26
+ const list = deprecations[node.name.name];
27
+ if (!list) return;
28
+
29
+ for (const attr of node.attributes) {
30
+ if (
31
+ attr.type !== 'JSXAttribute' ||
32
+ attr.name.type !== 'JSXIdentifier'
33
+ )
34
+ continue;
35
+ const hit = list.find((d) => d.prop === attr.name.name);
36
+ if (!hit) continue;
37
+
38
+ if (hit.replacement) {
39
+ const already = node.attributes.some(
40
+ (a) =>
41
+ a.type === 'JSXAttribute' &&
42
+ a.name.type === 'JSXIdentifier' &&
43
+ a.name.name === hit.replacement,
44
+ );
45
+ context.report({
46
+ node: attr,
47
+ messageId: 'deprecated',
48
+ data: {
49
+ prop: hit.prop,
50
+ component: node.name.name,
51
+ replacement: hit.replacement,
52
+ },
53
+ // Only autofix when the replacement is not already present —
54
+ // renaming into a duplicate would change which value wins.
55
+ fix: already
56
+ ? undefined
57
+ : (fixer) => fixer.replaceText(attr.name, hit.replacement),
58
+ });
59
+ } else {
60
+ context.report({
61
+ node: attr,
62
+ messageId: 'deprecatedNoFix',
63
+ data: {
64
+ prop: hit.prop,
65
+ component: node.name.name,
66
+ note: hit.note ? ` ${hit.note}` : '',
67
+ },
68
+ });
69
+ }
70
+ }
71
+ },
72
+ };
73
+ },
74
+ };
@@ -0,0 +1,75 @@
1
+ import { contrastIssues } from '../meta-data.js';
2
+
3
+ /**
4
+ * Warn on prop combinations measured to fail WCAG AA.
5
+ *
6
+ * The data is whatever verify-contrast.mjs found this build — no list is
7
+ * maintained here. When a defect is fixed in Figma the measurement changes, the
8
+ * contract loses the entry, and this rule stops firing on its own.
9
+ */
10
+ export default {
11
+ meta: {
12
+ type: 'problem',
13
+ docs: {
14
+ description:
15
+ 'Disallow IonBase prop combinations with measured contrast failures',
16
+ },
17
+ schema: [],
18
+ messages: {
19
+ fails:
20
+ '{{component}} {{combo}} measures {{ratio}}:1 against a {{required}}:1 requirement in {{mode}} mode (WCAG {{sc}}{{states}}). Known and not yet fixed.',
21
+ },
22
+ },
23
+ create(context) {
24
+ const literal = (attr) => {
25
+ if (!attr.value) return true; // bare prop = true
26
+ if (attr.value.type === 'Literal') return attr.value.value;
27
+ if (
28
+ attr.value.type === 'JSXExpressionContainer' &&
29
+ attr.value.expression.type === 'Literal'
30
+ ) {
31
+ return attr.value.expression.value;
32
+ }
33
+ return undefined; // dynamic — cannot judge, so do not
34
+ };
35
+
36
+ return {
37
+ JSXOpeningElement(node) {
38
+ if (node.name.type !== 'JSXIdentifier') return;
39
+ const name = node.name.name;
40
+
41
+ for (const issue of contrastIssues) {
42
+ if (issue.appliesTo.component !== name) continue;
43
+
44
+ const props = new Map();
45
+ for (const a of node.attributes) {
46
+ if (a.type !== 'JSXAttribute' || a.name.type !== 'JSXIdentifier')
47
+ continue;
48
+ props.set(a.name.name, literal(a));
49
+ }
50
+
51
+ const wanted = Object.entries(issue.appliesTo.props ?? {});
52
+ const matches = wanted.every(([k, v]) => props.get(k) === v);
53
+ if (!matches) continue;
54
+
55
+ context.report({
56
+ node,
57
+ messageId: 'fails',
58
+ data: {
59
+ component: name,
60
+ combo: wanted.map(([k, v]) => `${k}="${v}"`).join(' '),
61
+ ratio: String(issue.ratio),
62
+ required: String(issue.required),
63
+ mode: issue.mode,
64
+ sc: issue.sc,
65
+ states:
66
+ issue.states && !issue.states.includes('default')
67
+ ? `, ${issue.states.join('/')} state`
68
+ : '',
69
+ },
70
+ });
71
+ }
72
+ },
73
+ };
74
+ },
75
+ };
@@ -0,0 +1,107 @@
1
+ import { spacingScale } from '../meta-data.js';
2
+
3
+ const COLOUR_PROPS =
4
+ /^(color|backgroundColor|borderColor|border.*Color|outlineColor|fill|stroke)$/;
5
+ const SPACE_PROPS =
6
+ /^(padding|margin|gap|rowGap|columnGap|(padding|margin)(Top|Right|Bottom|Left|Block|Inline)|borderRadius|top|right|bottom|left)$/;
7
+ const RAW_COLOUR = /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\()/i;
8
+
9
+ /**
10
+ * An inline `style={{ color: '#1a73e8' }}` is the single most common way a
11
+ * generated UI drifts from the system: it type-checks, it renders, and it is
12
+ * invisible to every other check. It is also the thing a model reaches for when
13
+ * it cannot recall a token name.
14
+ *
15
+ * Spacing suggestions name the exact rung, because "use a token" is advice and
16
+ * "use var(--spacing-16)" is a fix.
17
+ */
18
+ export default {
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description:
23
+ 'Disallow raw colour and spacing values in inline style props',
24
+ },
25
+ schema: [],
26
+ messages: {
27
+ colour:
28
+ 'Raw colour `{{value}}` in an inline style. Use an IonBase token — `var(--text-default)`, `var(--surface-default)` and the rest are listed in `ionbase-ui/tokens`.',
29
+ spacingExact:
30
+ 'Raw spacing `{{value}}` in an inline style. That is `var({{token}})`.',
31
+ spacingOff:
32
+ 'Raw spacing `{{value}}` in an inline style, and it is not on the scale. Nearest rungs are `var({{lower}})` and `var({{upper}})`.',
33
+ },
34
+ },
35
+ create(context) {
36
+ const rungs = Object.keys(spacingScale)
37
+ .map(Number)
38
+ .sort((a, b) => a - b);
39
+
40
+ const check = (prop) => {
41
+ if (prop.type !== 'Property' || prop.computed) return;
42
+ const key =
43
+ prop.key.type === 'Identifier'
44
+ ? prop.key.name
45
+ : prop.key.type === 'Literal'
46
+ ? String(prop.key.value)
47
+ : null;
48
+ if (!key) return;
49
+ const v = prop.value;
50
+ if (v.type !== 'Literal') return;
51
+
52
+ if (
53
+ COLOUR_PROPS.test(key) &&
54
+ typeof v.value === 'string' &&
55
+ RAW_COLOUR.test(v.value)
56
+ ) {
57
+ context.report({
58
+ node: v,
59
+ messageId: 'colour',
60
+ data: { value: v.value },
61
+ });
62
+ return;
63
+ }
64
+
65
+ if (SPACE_PROPS.test(key)) {
66
+ let px = null;
67
+ if (typeof v.value === 'number' && v.value !== 0) px = v.value;
68
+ else if (typeof v.value === 'string') {
69
+ const m = v.value.match(/^(\d+(?:\.\d+)?)px$/);
70
+ if (m && Number(m[1]) !== 0) px = Number(m[1]);
71
+ }
72
+ if (px === null || !rungs.length) return;
73
+
74
+ if (spacingScale[px]) {
75
+ context.report({
76
+ node: v,
77
+ messageId: 'spacingExact',
78
+ data: { value: String(v.value), token: spacingScale[px] },
79
+ });
80
+ } else {
81
+ const lower = [...rungs].reverse().find((r) => r < px) ?? rungs[0];
82
+ const upper = rungs.find((r) => r > px) ?? rungs[rungs.length - 1];
83
+ context.report({
84
+ node: v,
85
+ messageId: 'spacingOff',
86
+ data: {
87
+ value: String(v.value),
88
+ lower: spacingScale[lower],
89
+ upper: spacingScale[upper],
90
+ },
91
+ });
92
+ }
93
+ }
94
+ };
95
+
96
+ return {
97
+ JSXAttribute(node) {
98
+ if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'style')
99
+ return;
100
+ if (node.value?.type !== 'JSXExpressionContainer') return;
101
+ const expr = node.value.expression;
102
+ if (expr.type !== 'ObjectExpression') return;
103
+ for (const p of expr.properties) check(p);
104
+ },
105
+ };
106
+ },
107
+ };
@@ -0,0 +1,128 @@
1
+ import { components } from '../meta-data.js';
2
+
3
+ /**
4
+ * Two primary buttons in one dialog is the same as none — nothing indicates the
5
+ * expected action. Button's own contract says `limit: one per view or dialog`;
6
+ * this enforces it where it is checkable, which is inside a single Modal in a
7
+ * single file.
8
+ *
9
+ * The default variant counts. `<Button>Save</Button>` IS primary-brand, and that
10
+ * is exactly the case a reviewer misses, because neither button says "primary".
11
+ */
12
+ export default {
13
+ meta: {
14
+ type: 'problem',
15
+ docs: { description: 'Allow at most one primary Button inside a Modal' },
16
+ schema: [],
17
+ messages: {
18
+ twoPrimaries:
19
+ 'This Modal has {{count}} primary Buttons. Exactly one action should be primary — make the others `secondary` or `tertiary`.{{implicit}}',
20
+ },
21
+ },
22
+ create(context) {
23
+ // Read the default from the contract rather than assuming it.
24
+ const primary =
25
+ components.Button?.props?.variant?.default ?? 'primary-brand';
26
+
27
+ const variantOf = (open) => {
28
+ for (const a of open.attributes) {
29
+ if (a.type === 'JSXSpreadAttribute') return null; // unknowable
30
+ if (a.type !== 'JSXAttribute' || a.name.type !== 'JSXIdentifier')
31
+ continue;
32
+ if (a.name.name !== 'variant') continue;
33
+ if (a.value?.type === 'Literal') return a.value.value;
34
+ if (
35
+ a.value?.type === 'JSXExpressionContainer' &&
36
+ a.value.expression.type === 'Literal'
37
+ ) {
38
+ return a.value.expression.value;
39
+ }
40
+ return null; // dynamic
41
+ }
42
+ return primary; // omitted → the default, which is primary
43
+ };
44
+
45
+ /**
46
+ * Walk every JSX descendant, including through expression containers —
47
+ * `{isEditing && <Button/>}` and `{items.map(...)}` both render buttons.
48
+ * A hand-rolled walk rather than a serialiser: the AST carries circular
49
+ * `parent` references, so anything generic has to be told about them.
50
+ */
51
+ const collect = (node, found, seen = new Set()) => {
52
+ if (!node || typeof node !== 'object' || seen.has(node)) return;
53
+ seen.add(node);
54
+
55
+ if (node.type === 'JSXElement') {
56
+ const n = node.openingElement.name;
57
+ if (n.type === 'JSXIdentifier' && n.name === 'Button') {
58
+ const v = variantOf(node.openingElement);
59
+ if (v === primary) {
60
+ found.push({
61
+ node,
62
+ implicit: !node.openingElement.attributes.some(
63
+ (a) =>
64
+ a.type === 'JSXAttribute' &&
65
+ a.name.type === 'JSXIdentifier' &&
66
+ a.name.name === 'variant',
67
+ ),
68
+ });
69
+ }
70
+ }
71
+ // Do not descend into a nested Modal — its buttons are its own problem.
72
+ if (n.type === 'JSXIdentifier' && n.name === 'Modal' && found.length)
73
+ return;
74
+ }
75
+
76
+ for (const [key, value] of Object.entries(node)) {
77
+ if (key === 'parent') continue;
78
+ if (Array.isArray(value))
79
+ for (const v of value) collect(v, found, seen);
80
+ else if (
81
+ value &&
82
+ typeof value === 'object' &&
83
+ typeof value.type === 'string'
84
+ ) {
85
+ collect(value, found, seen);
86
+ }
87
+ }
88
+ };
89
+
90
+ return {
91
+ JSXElement(node) {
92
+ const n = node.openingElement.name;
93
+ if (n.type !== 'JSXIdentifier' || n.name !== 'Modal') return;
94
+ const found = [];
95
+ // Every child, not just JSXElement ones — `{isEditing && <Button/>}` is
96
+ // a JSXExpressionContainer and renders a button just the same. Skipping
97
+ // those was the whole bug: the conditional action is exactly the one a
98
+ // reviewer does not count.
99
+ const seen = new Set();
100
+ for (const child of node.children) collect(child, found, seen);
101
+ // `footer={<>...</>}` is where Modal actions actually go.
102
+ for (const a of node.openingElement.attributes) {
103
+ if (
104
+ a.type === 'JSXAttribute' &&
105
+ a.value?.type === 'JSXExpressionContainer' &&
106
+ (a.value.expression.type === 'JSXElement' ||
107
+ a.value.expression.type === 'JSXFragment')
108
+ ) {
109
+ collect(a.value.expression, found, seen);
110
+ }
111
+ }
112
+ if (found.length > 1) {
113
+ const implicit = found.filter((f) => f.implicit).length;
114
+ context.report({
115
+ node: found[found.length - 1].node.openingElement,
116
+ messageId: 'twoPrimaries',
117
+ data: {
118
+ count: String(found.length),
119
+ implicit: implicit
120
+ ? ` ${implicit} of them has no \`variant\`, which defaults to \`${primary}\`.`
121
+ : '',
122
+ },
123
+ });
124
+ }
125
+ },
126
+ };
127
+ },
128
+ };