@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,135 +0,0 @@
1
- /*
2
- * A Portal route does not decide how wide it is.
3
- *
4
- * The signed-in chrome owns the content column: `(dashboard)/layout.tsx` renders
5
- * `PortalShell`, and a reading-width surface takes `ReadingColumn` from
6
- * `@nadicodeai/ui`. A route that caps its own width with `max-w-*` or centres
7
- * itself with `mx-auto` makes a second owner for one measurement, which is how
8
- * neighbouring pages end up at 42rem and 48rem with nothing to say which is
9
- * right, and how a shell change stops reaching half the routes.
10
- *
11
- * What is read is the OUTERMOST element the route renders — the root of every
12
- * expression its default export returns, descended through fragments and through
13
- * the conditionals a route writes around a fallback. A width inside the page is
14
- * a different statement: a skeleton that matches the field it stands in for, or
15
- * a form column, constrains its own element, not the route, and those are
16
- * ordinary. The rule's subject is the route's own frame.
17
- *
18
- * `max-w-full` and `max-w-none` are kept legal for the same reason: they remove
19
- * a cap rather than set one, so neither can be the second owner of a width.
20
- *
21
- * Standard tool checked first: `better-tailwindcss/no-restricted-classes`
22
- * matches a class wherever it is written and cannot ask whether the element
23
- * carrying it is the one the route returns. That relationship is the rule, so it
24
- * is read here.
25
- */
26
- import { openingElementClassUtilities } from "./jsx-class-utility.js";
27
-
28
- const CENTERING = "mx-auto";
29
- const MAX_WIDTH = /^-?max-w-(.+)$/;
30
- const UNCAPPED = new Set(["full", "none"]);
31
-
32
- function withoutVariants(token) {
33
- let bracketDepth = 0;
34
- let start = 0;
35
- for (let index = 0; index < token.length; index += 1) {
36
- if (token[index] === "[") bracketDepth += 1;
37
- if (token[index] === "]") bracketDepth = Math.max(0, bracketDepth - 1);
38
- if (token[index] === ":" && bracketDepth === 0) start = index + 1;
39
- }
40
- return token.slice(start);
41
- }
42
-
43
- function isWidthOverride(utility) {
44
- const unqualified = withoutVariants(utility);
45
- if (unqualified === CENTERING) return true;
46
- const capped = MAX_WIDTH.exec(unqualified);
47
- return capped ? !UNCAPPED.has(capped[1]) : false;
48
- }
49
-
50
- /** The elements one returned expression renders at its outermost level. */
51
- function rootElements(node, found = []) {
52
- if (!node) return found;
53
- switch (node.type) {
54
- case "JSXElement":
55
- found.push(node);
56
- return found;
57
- case "JSXFragment":
58
- for (const child of node.children) rootElements(child, found);
59
- return found;
60
- case "JSXExpressionContainer":
61
- return rootElements(node.expression, found);
62
- case "ConditionalExpression":
63
- rootElements(node.consequent, found);
64
- return rootElements(node.alternate, found);
65
- case "LogicalExpression":
66
- return rootElements(node.right, found);
67
- default:
68
- return found;
69
- }
70
- }
71
-
72
- const FUNCTION_TYPES = new Set([
73
- "ArrowFunctionExpression",
74
- "FunctionDeclaration",
75
- "FunctionExpression",
76
- ]);
77
-
78
- /*
79
- * Every expression the route function returns, its own returns only: a helper
80
- * declared inside it renders something else, and its root is not the route's.
81
- */
82
- function returnedExpressions(node, found = []) {
83
- if (!node || typeof node.type !== "string") return found;
84
- if (node.type === "ReturnStatement") {
85
- if (node.argument) found.push(node.argument);
86
- return found;
87
- }
88
- if (FUNCTION_TYPES.has(node.type)) return found;
89
- for (const [key, value] of Object.entries(node)) {
90
- if (key === "parent" || key === "loc" || key === "range") continue;
91
- if (Array.isArray(value)) {
92
- for (const child of value) returnedExpressions(child, found);
93
- } else if (value && typeof value === "object") {
94
- returnedExpressions(value, found);
95
- }
96
- }
97
- return found;
98
- }
99
-
100
- function routeRoots(routeFunction) {
101
- const body = routeFunction.body;
102
- if (body.type !== "BlockStatement") return rootElements(body);
103
- return returnedExpressions(body).flatMap((expression) => rootElements(expression));
104
- }
105
-
106
- export const noRouteWidthOverride = {
107
- meta: {
108
- type: "problem",
109
- docs: {
110
- description: "Disallow a Portal route file setting the width of the frame it returns",
111
- },
112
- schema: [],
113
- messages: {
114
- routeWidthOverride:
115
- '"{{utility}}" makes this route a second owner of the content width, so neighbouring pages drift apart and a shell change stops reaching this one. Page width belongs to the shell column; a reading-width surface takes ReadingColumn from @nadicodeai/ui.',
116
- },
117
- },
118
- create(context) {
119
- return {
120
- ExportDefaultDeclaration(node) {
121
- if (!FUNCTION_TYPES.has(node.declaration.type)) return;
122
- for (const root of routeRoots(node.declaration)) {
123
- for (const utility of openingElementClassUtilities(root.openingElement, context)) {
124
- if (!isWidthOverride(utility)) continue;
125
- context.report({
126
- node: root.openingElement,
127
- messageId: "routeWidthOverride",
128
- data: { utility },
129
- });
130
- }
131
- }
132
- },
133
- };
134
- },
135
- };
@@ -1,46 +0,0 @@
1
- /*
2
- * A test hands the subject the store methods it calls, as an object typed by
3
- * the subject's own parameter. `as PortalStore` (or `as unknown as
4
- * PortalStore`) turns a partial object into the whole 227-method store: the
5
- * compiler then checks nothing about the methods the fake does implement, and a
6
- * subject that grows a call reaches `undefined` at run time instead of failing
7
- * to compile. Narrow the subject's parameter to the capability it uses
8
- * (`Pick<SomePersistence, "a" | "b">`), then build the fake as that type.
9
- */
10
-
11
- export const noStoreCast = {
12
- meta: {
13
- type: "problem",
14
- docs: {
15
- description:
16
- "Disallow casting a partial object to the whole store in a test; type the subject by the store methods it calls and build the fake as that type",
17
- },
18
- schema: [
19
- {
20
- type: "object",
21
- properties: { types: { type: "array", items: { type: "string" } } },
22
- additionalProperties: false,
23
- },
24
- ],
25
- messages: {
26
- storeCast:
27
- '`as {{name}}` hides which store methods this fake implements, so the compiler checks none of them. Type the subject\'s store parameter as the methods it calls (`Pick<SomePersistence, "a" | "b">`) and build the fake as `Parameters<typeof subject>[0]`.',
28
- },
29
- },
30
-
31
- create(context) {
32
- const names = new Set(context.options[0]?.types ?? ["PortalStore"]);
33
- return {
34
- TSAsExpression(node) {
35
- const type = node.typeAnnotation;
36
- if (
37
- type.type === "TSTypeReference" &&
38
- type.typeName.type === "Identifier" &&
39
- names.has(type.typeName.name)
40
- ) {
41
- context.report({ node, messageId: "storeCast", data: { name: type.typeName.name } });
42
- }
43
- },
44
- };
45
- },
46
- };
@@ -1,53 +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 borderRadius = require("@nadicodeai/design-system/tailwind").theme.extend.borderRadius;
7
- const radiusNameByValue = new Map(
8
- Object.entries(borderRadius).map(([name, value]) => [value, name]),
9
- );
10
- const ARBITRARY_RADIUS = /^(rounded(?:-(?:t|r|b|l|s|e|ss|se|ee|es|tl|tr|br|bl))?)-\[(.+)\]$/;
11
- const CANONICAL_RADIUS_VARIABLE = /^var\(--(?:nc-rounded|radius)-([a-z0-9-]+)\)$/;
12
-
13
- function canonicalRadiusReplacement(utility) {
14
- const variantBoundary = utility.lastIndexOf(":") + 1;
15
- const variants = utility.slice(0, variantBoundary);
16
- const unqualified = utility.slice(variantBoundary);
17
- const match = ARBITRARY_RADIUS.exec(unqualified);
18
- if (!match) return undefined;
19
-
20
- const [, prefix, rawValue] = match;
21
- const value = rawValue.trim();
22
- const variable = CANONICAL_RADIUS_VARIABLE.exec(value);
23
- const token = variable?.[1] ?? radiusNameByValue.get(value);
24
- if (!token || !(token in borderRadius)) return undefined;
25
-
26
- return `${variants}${prefix}-${token}`;
27
- }
28
-
29
- export const noTokenCategoryBypass = {
30
- meta: {
31
- type: "problem",
32
- docs: {
33
- description:
34
- "Disallow arbitrary radius values when the generated design system provides an exact utility",
35
- },
36
- schema: [],
37
- messages: {
38
- canonicalTokenUtility:
39
- "Replace {{utility}} with the generated design-system utility {{replacement}}.",
40
- },
41
- },
42
- create(context) {
43
- return tokenUtilityVisitors(context, (node, utility) => {
44
- const replacement = canonicalRadiusReplacement(utility);
45
- if (!replacement) return;
46
- context.report({
47
- node,
48
- messageId: "canonicalTokenUtility",
49
- data: { replacement, utility },
50
- });
51
- });
52
- },
53
- };
@@ -1,104 +0,0 @@
1
- // The standard tool was checked first: core `no-restricted-imports` takes
2
- // `paths[].importNames` / `allowImportNames`, so a per-symbol allowlist is
3
- // within its vocabulary. What it cannot do is bind that allowlist to a file.
4
- // The ban would live in the shared options and each allowed module would need a
5
- // file-scoped override — and flat config replaces rule options instead of
6
- // merging them, so every one of those overrides must restate the whole shared
7
- // object (the package-source patterns, the raw-logo patterns, the portal's kit
8
- // barrel) to keep them alive. That is one copy of the ban list per allowed
9
- // module, where a single stale copy silently reopens a boundary; the same
10
- // last-wins failure `tests/module-boundary-imports.test.ts` exists to catch. A
11
- // plugin rule keeps the module-to-symbol map in one place and carries the
12
- // incident and the remediation in its message.
13
- //
14
- // Static `import` declarations only. The rule's provenance is a real production
15
- // crash: `ipAddress()` duck-typed its input and threw on the device-authorize
16
- // route, and
17
- // that crash arrived through a plain named import, which is also the only form
18
- // that can prove which symbols it binds. The rule used to also flag any string
19
- // literal or template literal spelling the package name anywhere in a file,
20
- // reaching dynamic `import()`, `require`, and re-exports; nothing in this
21
- // repository writes those forms, and the catch-all fired on any quoted mention
22
- // of the name.
23
- const PACKAGE_NAME = "@vercel/functions";
24
-
25
- // Third-party helpers stay behind owned, single-purpose capability seams. Pin
26
- // the full workspace-relative suffix so a same-named file in another workspace
27
- // cannot acquire a platform dependency by shadowing an allowed module.
28
- const ALLOWED_IMPORTS = new Map([
29
- ["apps/portal/src/lib/server/db.ts", new Set(["attachDatabasePool"])],
30
- ["apps/portal/src/lib/server/deferred-work.ts", new Set(["waitUntil"])],
31
- ["apps/portal/src/lib/server/usage-check-cache.ts", new Set(["getCache", "RuntimeCache"])],
32
- ["apps/portal/src/lib/server/quiet-job-runs.ts", new Set(["getCache", "RuntimeCache"])],
33
- ]);
34
-
35
- function matchesPackage(value) {
36
- return (
37
- typeof value === "string" && (value === PACKAGE_NAME || value.startsWith(`${PACKAGE_NAME}/`))
38
- );
39
- }
40
-
41
- function allowedSymbolsFor(filename) {
42
- const normalized = filename.replaceAll("\\", "/");
43
- for (const [suffix, symbols] of ALLOWED_IMPORTS) {
44
- if (normalized.endsWith(suffix)) {
45
- return symbols;
46
- }
47
- }
48
- return undefined;
49
- }
50
-
51
- function importedName(specifier) {
52
- if (specifier.type !== "ImportSpecifier") {
53
- return undefined;
54
- }
55
- return specifier.imported.type === "Identifier"
56
- ? specifier.imported.name
57
- : specifier.imported.value;
58
- }
59
-
60
- /** @type {import('eslint').Rule.RuleModule} */
61
- export const noUnpinnedVercelFunctionsImport = {
62
- meta: {
63
- type: "problem",
64
- docs: {
65
- description: "Confine static @vercel/functions imports to pinned edge modules and symbols",
66
- },
67
- messages: {
68
- banned:
69
- "@vercel/functions duck-types its inputs and ipAddress() crashed production once on a headers-like object. Its use is pinned per module and per symbol; amend the allowlist deliberately or keep the dependency out (found {{value}}).",
70
- },
71
- schema: [],
72
- },
73
- create(context) {
74
- const allowedSymbols = allowedSymbolsFor(context.filename);
75
-
76
- function report(node, value) {
77
- context.report({
78
- node,
79
- messageId: "banned",
80
- data: { value },
81
- });
82
- }
83
-
84
- return {
85
- ImportDeclaration(node) {
86
- if (!matchesPackage(node.source.value)) {
87
- return;
88
- }
89
-
90
- const importsOnlyPinnedSymbols =
91
- allowedSymbols &&
92
- node.specifiers.length > 0 &&
93
- node.specifiers.every((specifier) => {
94
- const name = importedName(specifier);
95
- return name !== undefined && allowedSymbols.has(name);
96
- });
97
-
98
- if (!importsOnlyPinnedSymbols) {
99
- report(node, node.source.value);
100
- }
101
- },
102
- };
103
- },
104
- };
@@ -1,163 +0,0 @@
1
- /*
2
- * Product chrome uses the 32px control.
3
- *
4
- * DESIGN.md ("Layout") gives every control in a product's chrome one height,
5
- * `control-height`, with a 14px label at 500. The small sizes a control
6
- * component still ships (`xs`, `sm`, `icon-xs`, `icon-sm`) exist for a control
7
- * nested inside dense content in another product, such as a composer; a
8
- * surface that enables this rule has none. The failure this catches: the
9
- * Portal's header actions rendered at 28px with a 12.8px label beside 32px
10
- * buttons in the card below them, because each caller picked `size="sm"`.
11
- *
12
- * What is read is a JSX element that is a control (a name ending in `Button`
13
- * or `Trigger`, or a `Toggle`/`ToggleGroupItem`/`NativeSelect`) and its `size`
14
- * attribute and the heights its `className` sets (`h-7`, `min-h-7`, `size-7`,
15
- * `h-[28px]`, and a fixed `h-10` or `size-10` above the control unless every
16
- * branch of its `size` is `"lg"`), with `!` read on either side, plus the `triggerSize` attribute a confirm
17
- * form passes to the button it renders, plus the `size` key of a `buttonVariants({ ... })` or
18
- * `toggleVariants({ ... })` call that styles a link as a button.
19
- * A string literal is read directly and through both branches of a
20
- * conditional, so `size={compact ? "sm" : "default"}` is reported too.
21
- *
22
- * Not read: `InputGroupButton`, a control nested inside an input group, where
23
- * DESIGN.md allows the small sizes, and `SidebarMenuSubButton`, whose `size`
24
- * sets its label, not its height.
25
- *
26
- * Standard tool checked first: `no-restricted-syntax` can select
27
- * `JSXAttribute[name.name="size"][value.value="sm"]` but not the element names
28
- * that make it a control, nor the branches of a conditional value, without a
29
- * selector per combination.
30
- */
31
- import { classUtilities, jsxComponentName } from "./jsx-class-utility.js";
32
-
33
- const SMALL_SIZES = new Set(["xs", "sm", "icon-xs", "icon-sm"]);
34
- const CONTROL_NAMES = new Set(["Toggle", "ToggleGroupItem", "NativeSelect"]);
35
- const NESTED_CONTROL_NAMES = new Set(["InputGroupButton", "SidebarMenuSubButton"]);
36
- const VARIANT_FUNCTIONS = new Set(["buttonVariants", "toggleVariants"]);
37
- const CONTROL_HEIGHT_PX = 32;
38
- const SPACING_UNIT_PX = 4;
39
-
40
- function isControl(name) {
41
- return (
42
- typeof name === "string" &&
43
- !NESTED_CONTROL_NAMES.has(name) &&
44
- (CONTROL_NAMES.has(name) || /(?:Button|Trigger)$/.test(name))
45
- );
46
- }
47
-
48
- function variantsAndUtility(className) {
49
- const parts = [];
50
- let depth = 0;
51
- let start = 0;
52
- for (let index = 0; index < className.length; index += 1) {
53
- const character = className[index];
54
- if (character === "[") depth += 1;
55
- else if (character === "]") depth -= 1;
56
- else if (character === ":" && depth === 0) {
57
- parts.push(className.slice(start, index));
58
- start = index + 1;
59
- }
60
- }
61
- return { variants: parts, utility: className.slice(start).replace(/^!|!$/g, "") };
62
- }
63
-
64
- function offHeight(className) {
65
- const { variants, utility } = variantsAndUtility(className);
66
- if (variants.some((variant) => variant.startsWith("[&") || /^\*{1,2}$/.test(variant)))
67
- return undefined;
68
- const match =
69
- /^(?<property>min-h|h|size)-(?:(?<steps>\d+(?:\.\d+)?)|\[(?<px>\d+(?:\.\d+)?)px\])$/.exec(
70
- utility,
71
- );
72
- if (!match?.groups) return undefined;
73
- const { property, steps, px } = match.groups;
74
- const pixels = steps === undefined ? Number(px) : Number(steps) * SPACING_UNIT_PX;
75
- const growsFromFloor = property === "min-h";
76
- if (pixels > 0 && pixels < CONTROL_HEIGHT_PX) return "small";
77
- if (pixels > CONTROL_HEIGHT_PX && !growsFromFloor) return "tall";
78
- return undefined;
79
- }
80
-
81
- function literals(node) {
82
- if (!node) return [];
83
- switch (node.type) {
84
- case "Literal":
85
- return typeof node.value === "string" ? [{ node, value: node.value }] : [];
86
- case "JSXExpressionContainer":
87
- return literals(node.expression);
88
- case "ConditionalExpression":
89
- return [...literals(node.consequent), ...literals(node.alternate)];
90
- case "LogicalExpression":
91
- return literals(node.right);
92
- case "TemplateLiteral":
93
- return node.expressions.length === 0
94
- ? [{ node, value: node.quasis.map((quasi) => quasi.value.cooked).join("") }]
95
- : [];
96
- default:
97
- return [];
98
- }
99
- }
100
-
101
- export const oneControlHeight = {
102
- meta: {
103
- type: "problem",
104
- docs: {
105
- description: "Require the 32px control height for every control in product chrome",
106
- },
107
- schema: [],
108
- messages: {
109
- smallControl:
110
- '{{attribute}}="{{size}}" renders this control below the 32px control height. Product chrome uses one control: drop the size for the default, or use "icon" for an icon-only control (DESIGN.md "Layout", rule/one-control-height).',
111
- smallClassHeight:
112
- '"{{utility}}" sets this control below the 32px control height. Product chrome uses one control: drop the height and let the control keep `h-control-height` (DESIGN.md "Layout", rule/one-control-height).',
113
- tallClassHeight:
114
- '"{{utility}}" sets this control above the 32px control height. Product chrome uses one control: drop the height and let the control keep `h-control-height` (DESIGN.md "Layout", rule/one-control-height).',
115
- },
116
- },
117
- create(context) {
118
- const reportSmall = (valueNode, attribute) => {
119
- for (const { node, value } of literals(valueNode)) {
120
- if (!SMALL_SIZES.has(value)) continue;
121
- context.report({ node, messageId: "smallControl", data: { attribute, size: value } });
122
- }
123
- };
124
- return {
125
- CallExpression(call) {
126
- if (call.callee.type !== "Identifier" || !VARIANT_FUNCTIONS.has(call.callee.name)) return;
127
- const [options] = call.arguments;
128
- if (options?.type !== "ObjectExpression") return;
129
- for (const property of options.properties) {
130
- if (property.type !== "Property" || property.computed) continue;
131
- const key = property.key.type === "Identifier" ? property.key.name : property.key.value;
132
- if (key === "size") reportSmall(property.value, "size");
133
- }
134
- },
135
- JSXAttribute(attribute) {
136
- const attributeName = attribute.name.type === "JSXIdentifier" ? attribute.name.name : "";
137
- if (attributeName === "className") {
138
- if (!isControl(jsxComponentName(attribute.parent, context))) return;
139
- const large = attribute.parent.attributes.some((other) => {
140
- if (other.type !== "JSXAttribute" || other.name.name !== "size") return false;
141
- const sizes = literals(other.value);
142
- return sizes.length > 0 && sizes.every(({ value }) => value === "lg");
143
- });
144
- for (const utility of classUtilities(attribute, context)) {
145
- const off = offHeight(utility);
146
- if (off === "tall" && large) continue;
147
- if (off)
148
- context.report({
149
- node: attribute,
150
- messageId: `${off}ClassHeight`,
151
- data: { utility },
152
- });
153
- }
154
- return;
155
- }
156
- if (attributeName !== "size" && attributeName !== "triggerSize") return;
157
- if (attributeName === "size" && !isControl(jsxComponentName(attribute.parent, context)))
158
- return;
159
- reportSmall(attribute.value, attributeName);
160
- },
161
- };
162
- },
163
- };
@@ -1,62 +0,0 @@
1
- /*
2
- * An overlay scrolls in its body slot.
3
- *
4
- * `DialogContent` already scrolls when its content outgrows the viewport. A
5
- * dialog or drawer that keeps its header and actions in place while the rest
6
- * scrolls puts that rest in `DialogBody` or `DrawerBody` from `@nadicodeai/ui`,
7
- * which own the scroll, the flex sizing that lets it shrink, and the inset that
8
- * keeps a field's focus outline from being clipped. A caller that writes its own
9
- * `overflow-y-auto` inside the overlay repeats that geometry, and the copies
10
- * drift: the Portal's action dialog carried two, a pixel apart.
11
- *
12
- * Standard tool checked first: `better-tailwindcss/no-restricted-classes` bans a
13
- * class everywhere and cannot ask what encloses it. "A scroll container inside an
14
- * overlay" is a relationship between two elements, so it is read here, on the
15
- * shared component-name and className readers.
16
- */
17
- import { jsxComponentName, openingElementClassUtilities } from "./jsx-class-utility.js";
18
-
19
- const OVERLAY_CONTENT = new Map([
20
- ["DialogContent", "DialogBody"],
21
- ["DrawerContent", "DrawerBody"],
22
- ]);
23
-
24
- const SCROLL_UTILITIES = new Set(["overflow-y-auto", "overflow-y-scroll", "overflow-auto"]);
25
-
26
- export const overlayBodyOwnsScroll = {
27
- meta: {
28
- type: "problem",
29
- docs: {
30
- description: "Require an overlay's scrolling region to be its package body slot",
31
- },
32
- schema: [],
33
- messages: {
34
- ownScroll:
35
- '"{{utility}}" makes this element a second scroll container inside <{{overlay}}>. The body slot owns an overlay\'s scroll, its shrinking and the inset that keeps focus outlines visible. Put the content in <{{body}}> from @nadicodeai/ui and drop the overflow classes (rule/overlay-body-owns-scroll).',
36
- },
37
- },
38
- create(context) {
39
- return {
40
- JSXOpeningElement(node) {
41
- const utility = openingElementClassUtilities(node, context).find((candidate) =>
42
- SCROLL_UTILITIES.has(candidate),
43
- );
44
- if (!utility) return;
45
- const own = jsxComponentName(node, context);
46
- if (own && OVERLAY_CONTENT.has(own)) return;
47
-
48
- for (const ancestor of context.sourceCode.getAncestors(node)) {
49
- if (ancestor.type !== "JSXElement" || ancestor.openingElement === node) continue;
50
- const overlay = jsxComponentName(ancestor.openingElement, context);
51
- if (!overlay || !OVERLAY_CONTENT.has(overlay)) continue;
52
- context.report({
53
- node,
54
- messageId: "ownScroll",
55
- data: { utility, overlay, body: OVERLAY_CONTENT.get(overlay) },
56
- });
57
- return;
58
- }
59
- },
60
- };
61
- },
62
- };
@@ -1,102 +0,0 @@
1
- /*
2
- * An overlay is mounted closed and then opened, so its entry runs.
3
- *
4
- * A dialog, drawer, sheet or menu fades in from its starting style when its
5
- * `open` turns true on a mounted element. One that first mounts already open
6
- * has no earlier frame to transition from, so it appears at full opacity, and
7
- * one that unmounts on dismissal never plays its exit. Three spellings mount an
8
- * overlay open: an `open` fixed to true (its parent mounts it on demand), a
9
- * truthy `defaultOpen`, and a `key` that changes per opening, which remounts it
10
- * in the same render that opens it. A list key inside a `.map` callback and a
11
- * literal key are an element's identity, not an opening.
12
- *
13
- * Standard tool checked first: `no-restricted-syntax` can match the attribute,
14
- * but not the renamed import or the enclosing `.map` a list key sits in, so the
15
- * shared component-name reader resolves it here.
16
- */
17
- import { jsxComponentName } from "./jsx-class-utility.js";
18
-
19
- const OVERLAY = /Dialog|Drawer|Sheet|Popover|Menu|Tooltip|HoverCard|Combobox/;
20
- const PART =
21
- /(Content|Trigger|Title|Description|Header|Footer|Body|Item|Group|Label|Separator|Close|Portal|Overlay|Backdrop)$/;
22
- const LIST_CALLBACK = new Set(["map", "flatMap"]);
23
-
24
- function attribute(node, name) {
25
- return node.attributes.find(
26
- (entry) =>
27
- entry.type === "JSXAttribute" &&
28
- entry.name.type === "JSXIdentifier" &&
29
- entry.name.name === name,
30
- );
31
- }
32
-
33
- function isTrue(entry) {
34
- if (!entry) return false;
35
- if (entry.value === null) return true;
36
- return (
37
- entry.value.type === "JSXExpressionContainer" &&
38
- entry.value.expression.type === "Literal" &&
39
- entry.value.expression.value === true
40
- );
41
- }
42
-
43
- function isLiteralKey(entry) {
44
- if (entry.value?.type === "Literal") return true;
45
- const expression =
46
- entry.value?.type === "JSXExpressionContainer" ? entry.value.expression : undefined;
47
- return (
48
- expression?.type === "Literal" ||
49
- (expression?.type === "TemplateLiteral" && expression.expressions.length === 0)
50
- );
51
- }
52
-
53
- function insideListCallback(node, context) {
54
- return context.sourceCode
55
- .getAncestors(node)
56
- .some(
57
- (ancestor) =>
58
- (ancestor.type === "ArrowFunctionExpression" || ancestor.type === "FunctionExpression") &&
59
- ancestor.parent?.type === "CallExpression" &&
60
- ancestor.parent.arguments.includes(ancestor) &&
61
- ancestor.parent.callee.type === "MemberExpression" &&
62
- LIST_CALLBACK.has(ancestor.parent.callee.property.name),
63
- );
64
- }
65
-
66
- export const overlayMountsClosed = {
67
- meta: {
68
- type: "problem",
69
- docs: {
70
- description:
71
- "Disallow an overlay that mounts already open, so its entry and exit transitions run",
72
- },
73
- schema: [],
74
- messages: {
75
- openFixed:
76
- "<{{name}}> is given `open` fixed to true, so it only exists while open and neither its fade in nor its fade out runs. Keep it mounted and drive `open` from state.",
77
- defaultOpen:
78
- "<{{name}}> mounts open through `defaultOpen`, so its entry never runs. Mount it closed and open it from state.",
79
- keyPerOpening:
80
- "<{{name}}> takes a `key` that remounts it, so an opening that changes the key mounts it already open and its fade in never runs. Keep one mounted overlay and reset its content another way, such as a key on the content inside it.",
81
- },
82
- },
83
- create(context) {
84
- return {
85
- JSXOpeningElement(node) {
86
- const name = jsxComponentName(node, context);
87
- if (!name || !OVERLAY.test(name) || PART.test(name)) return;
88
-
89
- if (isTrue(attribute(node, "open"))) {
90
- context.report({ node, messageId: "openFixed", data: { name } });
91
- }
92
- if (isTrue(attribute(node, "defaultOpen"))) {
93
- context.report({ node, messageId: "defaultOpen", data: { name } });
94
- }
95
- const key = attribute(node, "key");
96
- if (key && !isLiteralKey(key) && !insideListCallback(node, context)) {
97
- context.report({ node, messageId: "keyPerOpening", data: { name } });
98
- }
99
- },
100
- };
101
- },
102
- };