@nadicodeai/ui 12.1.54 → 12.1.56

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 (43) hide show
  1. package/dist/eslint/index.js +27 -69
  2. package/dist/eslint/rules/jsx-class-utility.js +2 -56
  3. package/dist/eslint/rules/no-choice-control-wrapper.js +95 -0
  4. package/dist/eslint/rules/no-comment.js +44 -0
  5. package/dist/eslint/rules/no-subject-derived-expectation.js +55 -1
  6. package/dist/eslint/strict-design-system.js +224 -0
  7. package/dist/standalone/index.d.ts +6 -0
  8. package/dist/standalone/index.js +3 -0
  9. package/dist/standalone/orb-page.d.ts +2 -0
  10. package/dist/standalone/orb-page.d.ts.map +1 -0
  11. package/dist/standalone/orb-page.js +11 -0
  12. package/dist/styles/components.css +1 -1
  13. package/package.json +7 -2
  14. package/dist/.build-kind +0 -1
  15. package/dist/eslint/rules/card-row-owner.js +0 -167
  16. package/dist/eslint/rules/confirm-destructive-action.js +0 -177
  17. package/dist/eslint/rules/drawer-owns-its-edges.js +0 -66
  18. package/dist/eslint/rules/expect-chain.js +0 -60
  19. package/dist/eslint/rules/icon-control-accessible-name.js +0 -156
  20. package/dist/eslint/rules/menu-command-owner.js +0 -45
  21. package/dist/eslint/rules/no-bare-page-navigation.js +0 -46
  22. package/dist/eslint/rules/no-elevated-edge.js +0 -141
  23. package/dist/eslint/rules/no-existence-only-assertion.js +0 -89
  24. package/dist/eslint/rules/no-invalid-token-utility.js +0 -46
  25. package/dist/eslint/rules/no-mono-title-register.js +0 -73
  26. package/dist/eslint/rules/no-native-visible-control.js +0 -92
  27. package/dist/eslint/rules/no-nested-overlay.js +0 -56
  28. package/dist/eslint/rules/no-off-grid-spacing.js +0 -115
  29. package/dist/eslint/rules/no-paired-size-utility.js +0 -66
  30. package/dist/eslint/rules/no-raw-shadow.js +0 -70
  31. package/dist/eslint/rules/no-repainted-focus.js +0 -75
  32. package/dist/eslint/rules/no-resting-settings-control.js +0 -89
  33. package/dist/eslint/rules/no-route-width-override.js +0 -135
  34. package/dist/eslint/rules/no-store-cast.js +0 -46
  35. package/dist/eslint/rules/no-token-category-bypass.js +0 -53
  36. package/dist/eslint/rules/no-unpinned-vercel-functions-import.js +0 -104
  37. package/dist/eslint/rules/one-control-height.js +0 -163
  38. package/dist/eslint/rules/overlay-body-owns-scroll.js +0 -62
  39. package/dist/eslint/rules/overlay-mounts-closed.js +0 -102
  40. package/dist/eslint/rules/prefer-radio-for-few-static-options.js +0 -77
  41. package/dist/eslint/rules/settings-section-description.js +0 -45
  42. package/dist/eslint/rules/shadcn-composition.js +0 -197
  43. package/dist/eslint/rules/table-column-wraps-by-meta.js +0 -55
@@ -1,92 +0,0 @@
1
- import { packageOwnedPrimitivesSchema, skipsPackageOwnedPrimitive } from "./jsx-class-utility.js";
2
-
3
- const NATIVE_CONTROLS = new Set(["button", "details", "input", "select", "textarea"]);
4
-
5
- function staticValue(node) {
6
- if (node?.type === "Literal") {
7
- return node.value;
8
- }
9
- if (node?.type === "JSXExpressionContainer") {
10
- return staticValue(node.expression);
11
- }
12
- if (node?.type === "TemplateLiteral" && node.expressions.length === 0) {
13
- return node.quasis[0]?.value.cooked;
14
- }
15
- return undefined;
16
- }
17
-
18
- function hiddenInput(node) {
19
- const typeAttribute = node.attributes.find(
20
- (attribute) => attribute.type === "JSXAttribute" && attribute.name.name === "type",
21
- );
22
- return staticValue(typeAttribute?.value) === "hidden";
23
- }
24
-
25
- function isPrimitiveRenderElement(node, packagePrimitiveNames) {
26
- const expression = node.parent;
27
- const attribute = expression?.parent;
28
- const openingElement = attribute?.parent;
29
- return (
30
- expression?.type === "JSXExpressionContainer" &&
31
- attribute?.type === "JSXAttribute" &&
32
- attribute.name.name === "render" &&
33
- openingElement?.type === "JSXOpeningElement" &&
34
- openingElement.name.type === "JSXIdentifier" &&
35
- packagePrimitiveNames.has(openingElement.name.name)
36
- );
37
- }
38
-
39
- export const noNativeVisibleControl = {
40
- meta: {
41
- type: "problem",
42
- docs: {
43
- description: "Disallow visible native controls in app source; use @nadicodeai/ui primitives",
44
- },
45
- // Default "exempt": the package's own source is where the primitives that
46
- // replace a native control are built, so a native <button> there is the
47
- // implementation, not a bypass.
48
- schema: [packageOwnedPrimitivesSchema],
49
- messages: {
50
- nativeVisibleControl:
51
- "Use the @nadicodeai/ui primitive instead of native visible <{{element}}>{{hint}}.",
52
- },
53
- },
54
- create(context) {
55
- if (skipsPackageOwnedPrimitive(context)) {
56
- return {};
57
- }
58
- const packagePrimitiveNames = new Set();
59
- return {
60
- ImportDeclaration(node) {
61
- if (
62
- typeof node.source.value !== "string" ||
63
- !node.source.value.startsWith("@nadicodeai/ui")
64
- ) {
65
- return;
66
- }
67
- for (const specifier of node.specifiers) {
68
- packagePrimitiveNames.add(specifier.local.name);
69
- }
70
- },
71
- JSXOpeningElement(node) {
72
- if (node.name.type !== "JSXIdentifier" || !NATIVE_CONTROLS.has(node.name.name)) {
73
- return;
74
- }
75
- if (node.name.name === "input" && hiddenInput(node)) {
76
- return;
77
- }
78
- if (isPrimitiveRenderElement(node.parent, packagePrimitiveNames)) {
79
- return;
80
- }
81
- context.report({
82
- node: node.name,
83
- messageId: "nativeVisibleControl",
84
- data: {
85
- element: node.name.name,
86
- hint: node.name.name === "details" ? ": Disclosure draws the one disclosure" : "",
87
- },
88
- });
89
- },
90
- };
91
- },
92
- };
@@ -1,56 +0,0 @@
1
- /*
2
- * One overlay at a time.
3
- *
4
- * `DialogContent`, `AlertDialogContent`, `SheetContent` and `DrawerContent` each
5
- * mount a focus trap, a scrim and a layer of their own. Writing one inside
6
- * another leaves both traps armed on the same tree: focus cannot leave the inner
7
- * surface to reach the outer one's controls, Escape resolves against whichever
8
- * trap won the race, and the two scrims composite into a second dim over content
9
- * that is already dimmed. Nothing about this is visible while the inner overlay
10
- * is closed, which is why it survives review and reaches the keyboard user.
11
- *
12
- * Standard tool checked first: `no-restricted-syntax` selects one node by its
13
- * shape and cannot ask what encloses it. "An overlay inside an overlay" is a
14
- * relationship between two elements, so it is read here, on the shared
15
- * component-name reader that also resolves renamed imports.
16
- */
17
- import { jsxComponentName } from "./jsx-class-utility.js";
18
-
19
- const OVERLAY_CONTENT = new Set([
20
- "AlertDialogContent",
21
- "DialogContent",
22
- "DrawerContent",
23
- "SheetContent",
24
- ]);
25
-
26
- export const noNestedOverlay = {
27
- meta: {
28
- type: "problem",
29
- docs: {
30
- description: "Disallow an overlay surface rendered inside another overlay surface",
31
- },
32
- schema: [],
33
- messages: {
34
- nestedOverlay:
35
- "<{{inner}}> is rendered inside <{{outer}}>. Two overlays on one tree keep both focus traps armed, so the keyboard cannot reach the outer surface and Escape closes the wrong layer, while the scrims stack. Render the second overlay as a sibling driven by state, or replace it with an inline disclosure inside the first.",
36
- },
37
- },
38
- create(context) {
39
- return {
40
- JSXOpeningElement(node) {
41
- const inner = jsxComponentName(node, context);
42
- if (!inner || !OVERLAY_CONTENT.has(inner)) return;
43
-
44
- for (const ancestor of context.sourceCode.getAncestors(node)) {
45
- // The element this opening tag belongs to is an ancestor of it, and it
46
- // is not nested inside itself.
47
- if (ancestor.type !== "JSXElement" || ancestor.openingElement === node) continue;
48
- const outer = jsxComponentName(ancestor.openingElement, context);
49
- if (!outer || !OVERLAY_CONTENT.has(outer)) continue;
50
- context.report({ node, messageId: "nestedOverlay", data: { inner, outer } });
51
- return;
52
- }
53
- },
54
- };
55
- },
56
- };
@@ -1,115 +0,0 @@
1
- /*
2
- * The spacing ladder is closed. DESIGN.md, "Layout > Spacing": "One ladder
3
- * carries every gap, pad, and stack step. A register picks the rungs it needs;
4
- * it never interpolates."
5
- *
6
- * A pixel value typed into a spacing utility — `p-[13px]`, `gap-[7px]` — is that
7
- * interpolation. It reads as deliberate and is usually an eyeballed nudge, it
8
- * cannot move when the ladder does, and every one of them puts a second rhythm
9
- * beside the one the rest of the surface is built on, which is what makes a
10
- * layout look almost aligned.
11
- *
12
- * Only a bare pixel literal is a finding. `p-[var(--nc-cell-padding)]`,
13
- * `px-[clamp(24px,5vw,64px)]` and `top-[calc(var(--titlebar-height)+0.5rem)]` are
14
- * relationships to something the page owns rather than a measurement invented at
15
- * the call site, so they are outside this rule. So is a value that already lands
16
- * on a rung: it is the ladder, spelled the long way.
17
- *
18
- * Standard tool checked first: `better-tailwindcss`'s `no-restricted-classes`
19
- * matches a class against a pattern and could refuse every arbitrary spacing
20
- * value, but its message is the pattern's, one text for every hit. The fix here
21
- * is a different token for every value, and naming the nearest rung is the whole
22
- * point of the message, so the arithmetic lives in a rule.
23
- */
24
- import { tokenUtilityVisitors } from "./jsx-class-utility.js";
25
-
26
- /*
27
- * The ladder as the theme emits it: `--spacing-nadicode-*`, which Tailwind
28
- * spells `p-nadicode-md`, `gap-nadicode-lg` and so on. These are the rungs a
29
- * step may take and the ones a message offers.
30
- */
31
- const LADDER = [
32
- [0, "0"],
33
- [4, "nadicode-xxs"],
34
- [8, "nadicode-xs"],
35
- [12, "nadicode-sm"],
36
- [16, "nadicode-md"],
37
- [24, "nadicode-lg"],
38
- [32, "nadicode-xl"],
39
- [40, "nadicode-2xl"],
40
- [48, "nadicode-3xl"],
41
- [64, "nadicode-4xl"],
42
- [96, "nadicode-5xl"],
43
- [128, "nadicode-6xl"],
44
- ];
45
-
46
- /*
47
- * Control geometry from the same section: the seam weight, the focus outline and
48
- * its offset, a control's inline padding, and the coarse-pointer target. Each
49
- * owns one role rather than the general rhythm, so a value that lands on one is
50
- * legal and none of them is ever offered as the nearest rung for a gap.
51
- */
52
- const CONTROL_GEOMETRY = new Set([1, 2, 10, 44]);
53
-
54
- const SPACING_PREFIX =
55
- "(?:p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y|inset|inset-x|inset-y|top|right|bottom|left|start|end)";
56
- const OFF_GRID = new RegExp(`^-?(${SPACING_PREFIX})-\\[(-?\\d+(?:\\.\\d+)?)px\\]$`);
57
-
58
- /** `md:hover:p-[13px]` → `p-[13px]`, with a colon inside brackets left alone. */
59
- function withoutVariants(token) {
60
- let bracketDepth = 0;
61
- let start = 0;
62
- for (let index = 0; index < token.length; index += 1) {
63
- if (token[index] === "[") bracketDepth += 1;
64
- if (token[index] === "]") bracketDepth = Math.max(0, bracketDepth - 1);
65
- if (token[index] === ":" && bracketDepth === 0) start = index + 1;
66
- }
67
- return token.slice(start);
68
- }
69
-
70
- /** The closest rung, and the larger of the two when a value sits between them. */
71
- function nearestRung(pixels) {
72
- return LADDER.reduce((closest, rung) =>
73
- Math.abs(rung[0] - pixels) <= Math.abs(closest[0] - pixels) ? rung : closest,
74
- );
75
- }
76
-
77
- /*
78
- * A step below zero is written both ways — `-mt-[3px]` puts the sign on the
79
- * utility, `bottom-[-5px]` puts it in the value — and the replacement carries it
80
- * on the utility either way, because a token holds a magnitude.
81
- */
82
- function offGridFinding(utility) {
83
- const unqualified = withoutVariants(utility);
84
- const match = OFF_GRID.exec(unqualified);
85
- if (!match) return undefined;
86
- const [, prefix, value] = match;
87
- const pixels = Math.abs(Number(value));
88
- if (CONTROL_GEOMETRY.has(pixels)) return undefined;
89
- const [rung, token] = nearestRung(pixels);
90
- if (rung === pixels) return undefined;
91
- const sign = unqualified.startsWith("-") || value.startsWith("-") ? "-" : "";
92
- return { value: String(pixels), rung: String(rung), replacement: `${sign}${prefix}-${token}` };
93
- }
94
-
95
- export const noOffGridSpacing = {
96
- meta: {
97
- type: "problem",
98
- docs: {
99
- description:
100
- "Disallow a pixel value typed into a spacing utility off the design-system ladder",
101
- },
102
- schema: [],
103
- messages: {
104
- offGridSpacing:
105
- '"{{utility}}" puts a {{value}}px step beside a layout built on the design-system spacing ladder, so the rhythm reads as almost aligned and the value cannot follow the tokens. The nearest rung is {{rung}}px: use {{replacement}}.',
106
- },
107
- },
108
- create(context) {
109
- return tokenUtilityVisitors(context, (node, utility) => {
110
- const finding = offGridFinding(utility);
111
- if (!finding) return;
112
- context.report({ node, messageId: "offGridSpacing", data: { utility, ...finding } });
113
- });
114
- },
115
- };
@@ -1,66 +0,0 @@
1
- /*
2
- * A square is written once: `size-4`, never `h-4 w-4`.
3
- *
4
- * Standard tool checked first: `better-tailwindcss`'s
5
- * `no-restricted-classes` judges one class at a time, so it can ban `h-4`
6
- * outright but cannot see that a `w-4` is beside it — and the pair is the whole
7
- * point, since `h-4 w-full` is fine. The relationship lives here, on the shared
8
- * className reader.
9
- *
10
- * Replaces the `paired-size` half of the retired @nadicodeai/ui
11
- * composition-conventions scanner (#822). Stock shadcn shapes that cannot be
12
- * written any other way carry an inline disable with the reason at the site.
13
- */
14
- import {
15
- openingElementClassUtilities,
16
- packageOwnedPrimitivesSchema,
17
- skipsPackageOwnedPrimitive,
18
- } from "./jsx-class-utility.js";
19
-
20
- /** `md:h-4` → `{ variants: "md:", axis: "h", value: "4" }`, or undefined. */
21
- function sizeUtility(utility) {
22
- const separator = utility.lastIndexOf(":");
23
- const variants = utility.slice(0, separator + 1);
24
- const base = utility.slice(separator + 1);
25
- const match = /^([hw])-(.+)$/.exec(base);
26
- return match ? { variants, axis: match[1], value: match[2] } : undefined;
27
- }
28
-
29
- export const noPairedSizeUtility = {
30
- meta: {
31
- type: "problem",
32
- docs: {
33
- description: "Disallow paired h-* and w-* utilities of equal value; use size-*",
34
- },
35
- schema: [packageOwnedPrimitivesSchema],
36
- messages: {
37
- pairedSize: "Write the square once: use {{variants}}size-{{value}} instead of a h/w pair.",
38
- },
39
- },
40
- create(context) {
41
- if (skipsPackageOwnedPrimitive(context)) {
42
- return {};
43
- }
44
-
45
- return {
46
- JSXOpeningElement(node) {
47
- const seen = new Map();
48
- for (const utility of openingElementClassUtilities(node, context)) {
49
- const parsed = sizeUtility(utility);
50
- if (!parsed) continue;
51
- const key = `${parsed.variants}${parsed.value}`;
52
- const axes = seen.get(key) ?? new Set();
53
- axes.add(parsed.axis);
54
- seen.set(key, axes);
55
- if (axes.size === 2) {
56
- context.report({
57
- node,
58
- messageId: "pairedSize",
59
- data: { variants: parsed.variants, value: parsed.value },
60
- });
61
- }
62
- }
63
- },
64
- };
65
- },
66
- };
@@ -1,70 +0,0 @@
1
- /*
2
- * Depth comes off the ladder, not out of Tailwind's box.
3
- *
4
- * DESIGN.md, "Elevation & Depth": the four rungs are the four owned utilities,
5
- * each authored per mode and carrying its own ring as the recipe's final layer.
6
- * A stock `shadow-md` is a single light-mode drop shadow: it disappears against
7
- * the true-black dark workspace, it leaves the surface without the ring its
8
- * neighbours have, and it does not move when the tokens do. Two surfaces meant
9
- * to sit at the same physical level then read as different objects.
10
- *
11
- * `shadow-none` stays legal — removing a shadow an inherited shape brought with
12
- * it is the correct thing to write, and it is what `packages/ui` already writes.
13
- *
14
- * The two packages that BUILD the ladder are outside the rule: the design system
15
- * authors the rung values, and `@nadicodeai/ui` composes the utilities onto its
16
- * primitives. Everywhere else a shadow is a caller inventing depth.
17
- *
18
- * Standard tool checked first: `better-tailwindcss/no-unknown-classes` cannot
19
- * see these, because the generated theme clears only the `--color-*` namespace
20
- * and leaves Tailwind's own `--shadow-*` in place, so `shadow-lg` is a real
21
- * class that really paints. `no-restricted-classes` in the Portal and the
22
- * package configs carries `shadow-tooltip|menu|dialog|sheet` — the NAMED shadows
23
- * the ladder replaced — and nothing about the size scale; `no-invalid-token-utility`
24
- * judges `shadow-nc-*` against the generated colour tokens only; and the
25
- * colour-boundary patterns fire on `-nc-` or on an arbitrary colour value. A
26
- * probe file carrying `shadow-lg shadow-md shadow-[0_2px_4px_black]` linted
27
- * clean in `apps/portal` before this rule existed. Holding it as a repo-wide
28
- * restricted class would need the same entry restated in five configs, since a
29
- * later `no-restricted-classes` entry replaces the earlier one rather than
30
- * adding to it, so it is one rule instead.
31
- */
32
- import { tokenUtilityVisitors } from "./jsx-class-utility.js";
33
-
34
- const RAW_SHADOW = /^shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|initial|\[.*\]))?$/;
35
- const PACKAGE_LADDER_PATHS = ["packages/ui/src/", "packages/design-system/"];
36
-
37
- function withoutVariants(token) {
38
- let bracketDepth = 0;
39
- let start = 0;
40
- for (let index = 0; index < token.length; index += 1) {
41
- if (token[index] === "[") bracketDepth += 1;
42
- if (token[index] === "]") bracketDepth = Math.max(0, bracketDepth - 1);
43
- if (token[index] === ":" && bracketDepth === 0) start = index + 1;
44
- }
45
- return token.slice(start);
46
- }
47
-
48
- export const noRawShadow = {
49
- meta: {
50
- type: "problem",
51
- docs: {
52
- description:
53
- "Disallow Tailwind's stock shadow utilities outside the packages that own the material ladder",
54
- },
55
- schema: [],
56
- messages: {
57
- rawShadow:
58
- '"{{utility}}" paints a light-mode drop shadow with no ring, so the surface loses its edge against the dark workspace and cannot follow the tokens. Choose the physical level instead: nc-elevation-resting, nc-elevation-raised, nc-elevation-floating, or nc-elevation-modal.',
59
- },
60
- },
61
- create(context) {
62
- const filename = context.filename.replaceAll("\\", "/");
63
- if (PACKAGE_LADDER_PATHS.some((path) => filename.includes(path))) return {};
64
-
65
- return tokenUtilityVisitors(context, (node, utility) => {
66
- if (!RAW_SHADOW.test(withoutVariants(utility))) return;
67
- context.report({ node, messageId: "rawShadow", data: { utility } });
68
- });
69
- },
70
- };
@@ -1,75 +0,0 @@
1
- import { classUtilities } from "./jsx-class-utility.js";
2
-
3
- /**
4
- * Keyboard focus has one owner: the globals.css base layer draws the offset
5
- * ink outline on every :focus-visible element (DESIGN.md "Focus ring"), and an
6
- * invalid control takes the same geometry in the destructive hue. A component
7
- * that paints focus with a box-shadow ring, a raw outline width, or its own
8
- * hue forks that treatment. Standard tool checked first: no
9
- * upstream Tailwind rule models "focus variant may only suppress or restate
10
- * the canonical outline".
11
- *
12
- * A focus-variant utility may only suppress the outline (outline-none,
13
- * outline-hidden), restate the canonical treatment on a proxy target through
14
- * the one shared `nc-focus-outline` utility plus a ring-role color (as
15
- * input-group does when the group carries the indicator for its inner
16
- * control), or move non-paint properties (z-index, colors of the element
17
- * itself, …). Restating the style, width, and offset as separate utilities is
18
- * a fork of that owner, so the raw outline width belongs to nobody.
19
- */
20
- const RING_BASE = /^-?ring(?:-|$)/;
21
- const ALLOWED_OUTLINE_BASE = new Set(["nc-focus-outline", "outline-none", "outline-hidden"]);
22
- const ALLOWED_OUTLINE_COLOR = /^outline-(?:ring|sidebar-ring)$/;
23
- const OUTLINE_OFFSET = /^-?outline-offset-/;
24
-
25
- function baseOf(utility) {
26
- let depth = 0;
27
- let base = utility;
28
- for (let i = 0; i < utility.length; i += 1) {
29
- const ch = utility[i];
30
- if (ch === "[" || ch === "(") depth += 1;
31
- else if (ch === "]" || ch === ")") depth -= 1;
32
- else if (ch === ":" && depth === 0) base = utility.slice(i + 1);
33
- }
34
- return base;
35
- }
36
-
37
- function repaintedFocus(utility) {
38
- if (!utility.includes("focus")) return undefined;
39
- const base = baseOf(utility);
40
- if (base === utility) return undefined;
41
- if (RING_BASE.test(base)) return "boxShadowFocus";
42
- if (base.startsWith("[outline")) return "customFocusOutline";
43
- if (ALLOWED_OUTLINE_BASE.has(base)) return undefined;
44
- if (!base.startsWith("outline")) return undefined;
45
- if (ALLOWED_OUTLINE_COLOR.test(base) || OUTLINE_OFFSET.test(base)) return undefined;
46
- return "customFocusOutline";
47
- }
48
-
49
- export const noRepaintedFocus = {
50
- meta: {
51
- type: "problem",
52
- docs: {
53
- description:
54
- "Keep keyboard focus on the base-layer outline: no box-shadow ring, raw width, or component hue on a focus variant",
55
- },
56
- schema: [],
57
- messages: {
58
- boxShadowFocus:
59
- "Focus never paints a box-shadow: the base layer draws the offset outline. Drop {{utility}}.",
60
- customFocusOutline:
61
- "{{utility}} repaints focus with its own width or hue. The base layer owns the focus outline; a proxy target takes nc-focus-outline plus a ring-role color.",
62
- },
63
- },
64
- create(context) {
65
- return {
66
- JSXAttribute(node) {
67
- for (const utility of classUtilities(node)) {
68
- const messageId = repaintedFocus(utility);
69
- if (messageId === undefined) continue;
70
- context.report({ node, messageId, data: { utility } });
71
- }
72
- },
73
- };
74
- },
75
- };
@@ -1,89 +0,0 @@
1
- /*
2
- * The settings law, proved on the surfaces that declare they follow it.
3
- *
4
- * A file that renders `SettingsFactsSection` has said its settings are a
5
- * reading surface: current values stated as facts, one affordance opening the
6
- * editor that changes them. The regression this catches is a form control
7
- * rendered beside those facts rather than inside the editor they summon.
8
- *
9
- * "Inside the editor" means inside a `SettingsSection`, an `ActionDialogForm`'s
10
- * `fields` prop, or a `Dialog`/`Drawer` subtree for a surface that owns its own. Being a
11
- * CHILD of `ActionDialogForm` is the opposite: children are the resting surface
12
- * the dialog is opened from, which is exactly where a control must not be.
13
- *
14
- * The scope is the declaring file rather than every page, so a registry
15
- * toolbar's search or a mobile nav select is never a violation — a guard whose
16
- * allow-list carries live violations documents debt instead of preventing it.
17
- *
18
- * Standard tool checked first: `no-restricted-syntax` selects one
19
- * node by shape and cannot ask what else the file renders, nor whether an
20
- * ancestor summoned the subtree. Both are relationships, so the rule lives
21
- * here. Replaces `restingSettingsControlViolations` in the retired portal
22
- * page-grammar analyzers (#821).
23
- */
24
- import { jsxComponentName } from "./jsx-class-utility.js";
25
-
26
- // The controls a person types or picks into. Lowercase `input` is deliberately
27
- // absent: a hidden field carries context a summoned editor already decided, and
28
- // it is not a control anyone rests their eyes on.
29
- const RESTING_CONTROL_TAGS = new Set([
30
- "Checkbox",
31
- "Input",
32
- "InputOTP",
33
- "NativeSelect",
34
- "Select",
35
- "Switch",
36
- "Textarea",
37
- ]);
38
-
39
- const FACTS_SECTION_TAG = "SettingsFactsSection";
40
-
41
- function summons(node, context) {
42
- if (node.type === "JSXAttribute") {
43
- return (
44
- node.name.name === "fields" && jsxComponentName(node.parent, context) === "ActionDialogForm"
45
- );
46
- }
47
- if (node.type !== "JSXElement") return false;
48
- const name = jsxComponentName(node.openingElement, context);
49
- return ["SettingsSection", "Dialog", "DialogContent", "Drawer", "DrawerContent"].includes(name);
50
- }
51
-
52
- export const noRestingSettingsControl = {
53
- meta: {
54
- type: "problem",
55
- docs: {
56
- description:
57
- "Disallow a form control resting beside the facts it should be summoned over, on a settings-facts surface",
58
- },
59
- schema: [],
60
- messages: {
61
- restingControl:
62
- "<{{tag}}> rests beside its facts: use SettingsSection for a form, or put it in the summoned editor (ActionDialogForm `fields`, Dialog, or Drawer).",
63
- },
64
- },
65
- create(context) {
66
- let declaresFacts = false;
67
- const resting = [];
68
-
69
- return {
70
- JSXOpeningElement(node) {
71
- const name = jsxComponentName(node, context);
72
- if (name === FACTS_SECTION_TAG) {
73
- declaresFacts = true;
74
- return;
75
- }
76
- if (!name || !RESTING_CONTROL_TAGS.has(name)) return;
77
- if (context.sourceCode.getAncestors(node).some((ancestor) => summons(ancestor, context)))
78
- return;
79
- resting.push({ node, name });
80
- },
81
- "Program:exit"() {
82
- if (!declaresFacts) return;
83
- for (const { node, name } of resting) {
84
- context.report({ node, messageId: "restingControl", data: { tag: name } });
85
- }
86
- },
87
- };
88
- },
89
- };