@nadicodeai/ui 12.1.53 → 12.1.55

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 (38) 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/styles/components.css +1 -1
  8. package/package.json +3 -3
  9. package/dist/.build-kind +0 -1
  10. package/dist/eslint/rules/card-row-owner.js +0 -167
  11. package/dist/eslint/rules/confirm-destructive-action.js +0 -177
  12. package/dist/eslint/rules/drawer-owns-its-edges.js +0 -66
  13. package/dist/eslint/rules/expect-chain.js +0 -60
  14. package/dist/eslint/rules/icon-control-accessible-name.js +0 -156
  15. package/dist/eslint/rules/menu-command-owner.js +0 -45
  16. package/dist/eslint/rules/no-bare-page-navigation.js +0 -46
  17. package/dist/eslint/rules/no-elevated-edge.js +0 -141
  18. package/dist/eslint/rules/no-existence-only-assertion.js +0 -89
  19. package/dist/eslint/rules/no-invalid-token-utility.js +0 -46
  20. package/dist/eslint/rules/no-mono-title-register.js +0 -73
  21. package/dist/eslint/rules/no-native-visible-control.js +0 -92
  22. package/dist/eslint/rules/no-nested-overlay.js +0 -56
  23. package/dist/eslint/rules/no-off-grid-spacing.js +0 -115
  24. package/dist/eslint/rules/no-paired-size-utility.js +0 -66
  25. package/dist/eslint/rules/no-raw-shadow.js +0 -70
  26. package/dist/eslint/rules/no-repainted-focus.js +0 -75
  27. package/dist/eslint/rules/no-resting-settings-control.js +0 -89
  28. package/dist/eslint/rules/no-route-width-override.js +0 -135
  29. package/dist/eslint/rules/no-store-cast.js +0 -46
  30. package/dist/eslint/rules/no-token-category-bypass.js +0 -53
  31. package/dist/eslint/rules/no-unpinned-vercel-functions-import.js +0 -104
  32. package/dist/eslint/rules/one-control-height.js +0 -163
  33. package/dist/eslint/rules/overlay-body-owns-scroll.js +0 -62
  34. package/dist/eslint/rules/overlay-mounts-closed.js +0 -102
  35. package/dist/eslint/rules/prefer-radio-for-few-static-options.js +0 -77
  36. package/dist/eslint/rules/settings-section-description.js +0 -45
  37. package/dist/eslint/rules/shadcn-composition.js +0 -197
  38. package/dist/eslint/rules/table-column-wraps-by-meta.js +0 -55
@@ -1,45 +0,0 @@
1
- import { jsxComponentName } from "./jsx-class-utility.js";
2
-
3
- export const menuCommandOwner = {
4
- meta: {
5
- type: "problem",
6
- docs: { description: "Keep command labels and icons in the shared menu composition." },
7
- schema: [],
8
- messages: {
9
- command:
10
- "Use DropdownMenuCommand with its required icon; it owns readable labels and trailing icon layout. DropdownMenuItem is only for an iconOnly control here.",
11
- trigger:
12
- "Use ActionMenu for three-dot commands so placement, naming, and popup behavior have one owner.",
13
- },
14
- },
15
- create(context) {
16
- return {
17
- JSXOpeningElement(node) {
18
- const name = jsxComponentName(node, context);
19
- if (
20
- name === "DropdownMenuItem" &&
21
- !node.attributes.some(
22
- (attribute) =>
23
- attribute.type === "JSXAttribute" &&
24
- attribute.name.name === "iconOnly" &&
25
- (attribute.value === null ||
26
- (attribute.value.type === "JSXExpressionContainer" &&
27
- attribute.value.expression.value === true)),
28
- )
29
- )
30
- context.report({ node, messageId: "command" });
31
- if (
32
- [
33
- "Ellipsis",
34
- "EllipsisIcon",
35
- "EllipsisVertical",
36
- "MoreHorizontal",
37
- "MoreVertical",
38
- ].includes(name)
39
- ) {
40
- context.report({ node, messageId: "trigger" });
41
- }
42
- },
43
- };
44
- },
45
- };
@@ -1,46 +0,0 @@
1
- /*
2
- * The Portal's browser suite navigates through `tests/support/hydration.ts`.
3
- * `page.goto` and `page.reload` resolve while React is still hydrating, and the
4
- * interaction a spec dispatches next lands on server markup the shell is about
5
- * to replace: #2173 lost six open menus that way, #2776 clicked "Create team"
6
- * on a page that had not taken over yet, and #2837 read a prefetch as a
7
- * refresh. `back` and `forward` are outside the rule, because they restore a
8
- * tree the client already owns rather than loading a document.
9
- */
10
-
11
- const NAVIGATIONS = new Map([
12
- ["goto", "bareGoto"],
13
- ["reload", "bareReload"],
14
- ]);
15
-
16
- function propertyName(member) {
17
- if (member.computed) return member.property.type === "Literal" ? member.property.value : null;
18
- return member.property.type === "Identifier" ? member.property.name : null;
19
- }
20
-
21
- export const noBarePageNavigation = {
22
- meta: {
23
- type: "problem",
24
- docs: {
25
- description:
26
- "Disallow a bare page.goto or page.reload in the Portal's browser suite; navigate through the helper that waits for the shell to hydrate",
27
- },
28
- schema: [],
29
- messages: {
30
- bareGoto:
31
- "`goto()` resolves before React hydrates the page, so the next click or keypress lands on server markup the shell is about to replace (#2173, #2776, #2837). Open the page with `gotoPortal` from `tests/support/hydration`, which waits for the shell's `data-hydrated`, or with `gotoOutsideShell` for an address the dashboard shell never renders, such as login, signup or an app's consent page.",
32
- bareReload:
33
- "`reload()` resolves before React hydrates the page, so the next click or keypress lands on server markup the shell is about to replace (#2776). Reload with `reloadPortal` from `tests/support/hydration`, which waits for the shell's `data-hydrated`.",
34
- },
35
- },
36
-
37
- create(context) {
38
- return {
39
- CallExpression(node) {
40
- if (node.callee.type !== "MemberExpression") return;
41
- const messageId = NAVIGATIONS.get(propertyName(node.callee));
42
- if (messageId) context.report({ node, messageId });
43
- },
44
- };
45
- },
46
- };
@@ -1,141 +0,0 @@
1
- /*
2
- * An elevated surface draws its own edge. `nc-elevation-*` paints the rung's
3
- * whole recipe, whose last layer is its own optical ring (DESIGN.md,
4
- * "core.elevation"), so a border or a second ring on the same target paints a
5
- * second edge over it: the seam this repository keeps reintroducing when a
6
- * stock shadcn shape is pasted next to an elevation.
7
- *
8
- * Standard tool checked first: `better-tailwindcss`'s
9
- * `no-restricted-classes` judges one class at a time. `border` and `ring-1` are
10
- * legal on their own — they are wrong only beside an elevation, and only when
11
- * both land on the same target, so `*:data-[slot=input]:border-input` under an
12
- * elevated parent stays legal. That relationship is what this rule reads, on
13
- * the shared className reader.
14
- *
15
- * Replaces the @nadicodeai/ui material-surface-contract scanner (#822), which
16
- * built its own TypeScript AST over the package's sources to ask the same
17
- * question. It takes no `packageOwnedPrimitives` option: the material ladder
18
- * holds wherever an elevation is written, inside the package and out.
19
- */
20
- import { nodeClassUtilities } from "./jsx-class-utility.js";
21
-
22
- const ELEVATION = /^nc-elevation-(?:resting|raised|floating|modal)$/;
23
-
24
- /*
25
- * The callees that compose a class list out of several strings. A class written
26
- * in one argument and an elevation in another are one surface, so the call —
27
- * not the string — is the unit this rule reviews.
28
- */
29
- const COMPOSERS = new Set(["cn", "clsx", "cva"]);
30
-
31
- /** `md:hover:border` → `{ modifiers: ["md", "hover"], utility: "border" }`. */
32
- function tailwindToken(token) {
33
- const segments = [];
34
- let bracketDepth = 0;
35
- let start = 0;
36
- for (let index = 0; index < token.length; index += 1) {
37
- const character = token[index];
38
- if (character === "[") bracketDepth += 1;
39
- if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
40
- // A colon inside brackets belongs to an arbitrary value, not to a variant.
41
- if (character === ":" && bracketDepth === 0) {
42
- segments.push(token.slice(start, index));
43
- start = index + 1;
44
- }
45
- }
46
- segments.push(token.slice(start));
47
- return { modifiers: segments.slice(0, -1), utility: segments.at(-1) ?? token };
48
- }
49
-
50
- /**
51
- * What a utility paints on: the element itself, or the descendants a variant
52
- * selects. Two utilities collide only when this matches — an elevation on the
53
- * root and a border on `*:data-[slot=input]` are different surfaces.
54
- */
55
- function targetKey(modifiers) {
56
- const descendants = modifiers.filter(
57
- (modifier) => modifier === "*" || modifier === "**" || /^\[&(?:_|>|\s)/.test(modifier),
58
- );
59
- return descendants.length === 0 ? "self" : descendants.join(":");
60
- }
61
-
62
- /** A width of zero is the absence of an edge, which is what an elevation wants. */
63
- function isZeroWidthUtility(utility) {
64
- return utility === "border-0" || utility === "ring-0";
65
- }
66
-
67
- function edgeRule(utility) {
68
- if (/^border(?:$|-)/.test(utility) && !isZeroWidthUtility(utility)) return "borderWithElevation";
69
- if (/^ring(?:$|-)/.test(utility) && !isZeroWidthUtility(utility)) return "ringWithElevation";
70
- return undefined;
71
- }
72
-
73
- /** One finding per offending edge, however many elevations it sits beside. */
74
- function edgeViolations(classes) {
75
- const tokens = classes.map((token) => ({ token, ...tailwindToken(token) }));
76
- const elevations = tokens.filter(({ utility }) => ELEVATION.test(utility));
77
- const violations = new Map();
78
- for (const elevation of elevations) {
79
- const elevationTarget = targetKey(elevation.modifiers);
80
- for (const candidate of tokens) {
81
- if (targetKey(candidate.modifiers) !== elevationTarget) continue;
82
- const messageId = edgeRule(candidate.utility);
83
- if (messageId && !violations.has(candidate.token)) {
84
- violations.set(candidate.token, { messageId, shadow: elevation.token });
85
- }
86
- }
87
- }
88
- return [...violations].map(([token, finding]) => ({ token, ...finding }));
89
- }
90
-
91
- function isComposerCall(node) {
92
- return (
93
- node?.type === "CallExpression" &&
94
- node.callee.type === "Identifier" &&
95
- COMPOSERS.has(node.callee.name)
96
- );
97
- }
98
-
99
- function insideComposerCall(node) {
100
- for (let current = node.parent; current; current = current.parent) {
101
- if (isComposerCall(current)) return true;
102
- }
103
- return false;
104
- }
105
-
106
- export const noElevatedEdge = {
107
- meta: {
108
- type: "problem",
109
- docs: {
110
- description:
111
- "Disallow a border or a second ring on the same target as an nc-elevation-* utility",
112
- },
113
- schema: [],
114
- messages: {
115
- borderWithElevation:
116
- '"{{token}}" draws a hard border on the same target as {{shadow}}. An elevated surface carries its own optical ring: drop the border, or drop the elevation.',
117
- ringWithElevation:
118
- '"{{token}}" draws a second ring on the same target as {{shadow}}. An elevated surface carries its own optical ring already.',
119
- },
120
- },
121
- create(context) {
122
- const report = (node, classes) => {
123
- for (const { token, messageId, shadow } of edgeViolations(classes)) {
124
- context.report({ node, messageId, data: { token, shadow } });
125
- }
126
- };
127
-
128
- return {
129
- CallExpression(node) {
130
- // The outermost composer owns the surface: a nested one's strings are
131
- // already in its list, so reviewing both would report twice.
132
- if (!isComposerCall(node) || insideComposerCall(node)) return;
133
- report(node, nodeClassUtilities(node, context, { skipPropertyKeys: true }));
134
- },
135
- Literal(node) {
136
- if (typeof node.value !== "string" || insideComposerCall(node)) return;
137
- report(node, node.value.split(/\s+/).filter(Boolean));
138
- },
139
- };
140
- },
141
- };
@@ -1,89 +0,0 @@
1
- /*
2
- * An assertion that something exists is not an assertion about behavior.
3
- *
4
- * Two shapes in this suite pass for nearly any implementation.
5
- *
6
- * A `&&` or `||` subject returns one of its operands, not a boolean, so the
7
- * short-circuit alone can satisfy the matcher: the left operand decides the
8
- * result and the right one is never evaluated.
9
- *
10
- * An optional call yields `undefined` when the method is gone. That is exactly
11
- * what `toBeFalsy()` and `toBeUndefined()` accept, which is how
12
- * `expect(options.clientPrivileges?.()).toBeFalsy()` in
13
- * `apps/portal/src/lib/server/nadia-oauth-provider.test.ts` kept asserting that
14
- * OAuth grants no configuration privileges after the privilege hooks had been
15
- * deleted from the provider.
16
- *
17
- * What is deliberately left alone: `toBeDefined()` / `toBeTruthy()` on any
18
- * other subject. On a plain identifier or member read it is the type-narrowing
19
- * step before reading a property off it. On a Testing Library `getBy*` query it
20
- * is redundant, since the query already throws, but it is the suite's idiom for
21
- * "this element renders" and replacing it mechanically produces
22
- * `expect(getByText("X").textContent).toBe("X")`, which is a tautology of its
23
- * own: 214 such sites were rewritten that way once and reverted. `toBeNull()`
24
- * and `toBeUndefined()` on any other subject are a real claim of absence.
25
- *
26
- * Standard tools checked first: `vitest/expect-expect` refuses a test with no
27
- * assertion at all and `vitest/valid-expect` checks the call's arity; neither
28
- * asks what the asserted expression can produce, which is the whole question
29
- * here.
30
- */
31
- import { expectAssertion } from "./expect-chain.js";
32
-
33
- const SHORT_CIRCUIT_MATCHERS = new Set(["toBeDefined", "toBeTruthy", "toBeFalsy", "toBeUndefined"]);
34
- const MISSING_VALUE_MATCHERS = new Set(["toBeFalsy", "toBeUndefined"]);
35
-
36
- function isOptionalCall(node) {
37
- return node?.type === "CallExpression" && node.optional === true;
38
- }
39
-
40
- export const noExistenceOnlyAssertion = {
41
- meta: {
42
- type: "problem",
43
- docs: {
44
- description:
45
- "Disallow expectations that only assert a value exists, which pass for almost any implementation",
46
- },
47
- schema: [],
48
- messages: {
49
- shortCircuitSubject:
50
- "`{{operator}}` returns one of its operands rather than a boolean, so `{{assertion}}` passes on the short-circuit alone, with the right operand never evaluated. Assert each operand separately, or assert the value the expression stands for.",
51
- optionalCallAbsence:
52
- "`{{assertion}}` on an optional call passes when the call never happens: a deleted or renamed method makes `?.()` yield `undefined`, which is both falsy and undefined. Assert the method is present, then assert what the call returns.",
53
- },
54
- },
55
-
56
- create(context) {
57
- function assertionText(matcher, modifiers) {
58
- return [...modifiers, `${matcher}()`].join(".");
59
- }
60
-
61
- return {
62
- CallExpression(node) {
63
- const assertion = expectAssertion(node);
64
- if (!assertion) return;
65
- const { matcher, modifiers, subject } = assertion;
66
- if (!subject) return;
67
-
68
- if (modifiers.length > 0) return;
69
-
70
- if (subject.type === "LogicalExpression" && SHORT_CIRCUIT_MATCHERS.has(matcher)) {
71
- context.report({
72
- node,
73
- messageId: "shortCircuitSubject",
74
- data: { operator: subject.operator, assertion: assertionText(matcher, modifiers) },
75
- });
76
- return;
77
- }
78
-
79
- if (isOptionalCall(subject) && MISSING_VALUE_MATCHERS.has(matcher)) {
80
- context.report({
81
- node,
82
- messageId: "optionalCallAbsence",
83
- data: { assertion: assertionText(matcher, modifiers) },
84
- });
85
- }
86
- },
87
- };
88
- },
89
- };
@@ -1,46 +0,0 @@
1
- import { createRequire } from "node:module";
2
-
3
- import { tokenUtilityVisitors } from "./jsx-class-utility.js";
4
-
5
- const require = createRequire(import.meta.url);
6
- const generatedTheme = require("@nadicodeai/design-system/tailwind").theme.extend;
7
- const typographyUtilities = new Set(
8
- Object.keys(generatedTheme.fontSize).map((name) => `nc-type-${name}`),
9
- );
10
- const colorTokens = new Set(Object.keys(generatedTheme.colors));
11
- const COLOR_UTILITY =
12
- /^(?:bg|text|border|outline|ring|fill|stroke|decoration|accent|caret|divide|from|via|to|shadow)-nc-([^/]+)(?:\/.*)?$/;
13
-
14
- function withoutVariants(utility) {
15
- return utility.slice(utility.lastIndexOf(":") + 1);
16
- }
17
-
18
- function isInvalidGeneratedUtility(utility) {
19
- const unqualified = withoutVariants(utility);
20
- if (unqualified.startsWith("nc-type-")) {
21
- return !typographyUtilities.has(unqualified);
22
- }
23
-
24
- const color = COLOR_UTILITY.exec(unqualified);
25
- return color ? !colorTokens.has(color[1]) : false;
26
- }
27
-
28
- export const noInvalidTokenUtility = {
29
- meta: {
30
- type: "problem",
31
- docs: {
32
- description: "Disallow token utilities that are absent from generated design-system output",
33
- },
34
- schema: [],
35
- messages: {
36
- invalidTokenUtility:
37
- "{{utility}} is not generated by the installed nadicode design system. Use a utility from @nadicodeai/design-system/tailwind.",
38
- },
39
- },
40
- create(context) {
41
- return tokenUtilityVisitors(context, (node, utility) => {
42
- if (!isInvalidGeneratedUtility(utility)) return;
43
- context.report({ node, messageId: "invalidTokenUtility", data: { utility } });
44
- });
45
- },
46
- };
@@ -1,73 +0,0 @@
1
- /*
2
- * Design law 9: a title is prose, so it never renders in the mono-caps
3
- * register. The rule is mechanical because the register is — `font-mono`
4
- * together with `uppercase` on a slot that carries a title — and everything
5
- * judgment-bearing stays out of it: an eyebrow, a fact label, an identifier or
6
- * a marketing label may all be mono, and none of them is a title slot.
7
- *
8
- * Standard tool checked first: `better-tailwindcss`'s
9
- * `no-restricted-classes` judges one class at a time and never against the
10
- * element it lands on, so it can ban `font-mono` everywhere or nowhere. The
11
- * register is a PAIR of classes on a TITLE element, which is a relationship —
12
- * so it lives here, on the shared className reader.
13
- *
14
- * Replaces `monoTitleRegisterViolations` in the retired portal page-grammar
15
- * analyzers (#821).
16
- */
17
- import { openingElementClassUtilities } from "./jsx-class-utility.js";
18
-
19
- // The kit's title elements plus the hand-written headings.
20
- const TITLE_TAGS = new Set(["CardTitle", "h1", "h2", "h3", "h4"]);
21
- const TITLE_SLOT = /-title$/;
22
-
23
- function withoutVariants(utility) {
24
- return utility.slice(utility.lastIndexOf(":") + 1);
25
- }
26
-
27
- function slotName(node) {
28
- for (const attribute of node.attributes) {
29
- if (attribute.type !== "JSXAttribute" || attribute.name.name !== "data-slot") continue;
30
- const value = attribute.value;
31
- if (value?.type === "Literal" && typeof value.value === "string") return value.value;
32
- if (
33
- value?.type === "JSXExpressionContainer" &&
34
- value.expression.type === "Literal" &&
35
- typeof value.expression.value === "string"
36
- ) {
37
- return value.expression.value;
38
- }
39
- }
40
- return undefined;
41
- }
42
-
43
- function isTitleElement(node) {
44
- if (node.name.type !== "JSXIdentifier") return false;
45
- if (TITLE_TAGS.has(node.name.name)) return true;
46
- const slot = slotName(node);
47
- return Boolean(slot && TITLE_SLOT.test(slot));
48
- }
49
-
50
- export const noMonoTitleRegister = {
51
- meta: {
52
- type: "problem",
53
- docs: {
54
- description: "Disallow the retired mono-caps register on a title slot (design law 9)",
55
- },
56
- schema: [],
57
- messages: {
58
- monoTitle:
59
- "A title never renders in the mono-caps register: drop font-mono and uppercase (design law 9).",
60
- },
61
- },
62
- create(context) {
63
- return {
64
- JSXOpeningElement(node) {
65
- if (!isTitleElement(node)) return;
66
- const utilities = openingElementClassUtilities(node, context).map(withoutVariants);
67
- if (utilities.includes("font-mono") && utilities.includes("uppercase")) {
68
- context.report({ node, messageId: "monoTitle" });
69
- }
70
- },
71
- };
72
- },
73
- };
@@ -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
- };