@nadicodeai/ui 12.1.54 → 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.
- package/dist/eslint/index.js +27 -69
- package/dist/eslint/rules/jsx-class-utility.js +2 -56
- package/dist/eslint/rules/no-choice-control-wrapper.js +95 -0
- package/dist/eslint/rules/no-comment.js +44 -0
- package/dist/eslint/rules/no-subject-derived-expectation.js +55 -1
- package/dist/eslint/strict-design-system.js +224 -0
- package/dist/styles/components.css +1 -1
- package/package.json +2 -2
- package/dist/.build-kind +0 -1
- package/dist/eslint/rules/card-row-owner.js +0 -167
- package/dist/eslint/rules/confirm-destructive-action.js +0 -177
- package/dist/eslint/rules/drawer-owns-its-edges.js +0 -66
- package/dist/eslint/rules/expect-chain.js +0 -60
- package/dist/eslint/rules/icon-control-accessible-name.js +0 -156
- package/dist/eslint/rules/menu-command-owner.js +0 -45
- package/dist/eslint/rules/no-bare-page-navigation.js +0 -46
- package/dist/eslint/rules/no-elevated-edge.js +0 -141
- package/dist/eslint/rules/no-existence-only-assertion.js +0 -89
- package/dist/eslint/rules/no-invalid-token-utility.js +0 -46
- package/dist/eslint/rules/no-mono-title-register.js +0 -73
- package/dist/eslint/rules/no-native-visible-control.js +0 -92
- package/dist/eslint/rules/no-nested-overlay.js +0 -56
- package/dist/eslint/rules/no-off-grid-spacing.js +0 -115
- package/dist/eslint/rules/no-paired-size-utility.js +0 -66
- package/dist/eslint/rules/no-raw-shadow.js +0 -70
- package/dist/eslint/rules/no-repainted-focus.js +0 -75
- package/dist/eslint/rules/no-resting-settings-control.js +0 -89
- package/dist/eslint/rules/no-route-width-override.js +0 -135
- package/dist/eslint/rules/no-store-cast.js +0 -46
- package/dist/eslint/rules/no-token-category-bypass.js +0 -53
- package/dist/eslint/rules/no-unpinned-vercel-functions-import.js +0 -104
- package/dist/eslint/rules/one-control-height.js +0 -163
- package/dist/eslint/rules/overlay-body-owns-scroll.js +0 -62
- package/dist/eslint/rules/overlay-mounts-closed.js +0 -102
- package/dist/eslint/rules/prefer-radio-for-few-static-options.js +0 -77
- package/dist/eslint/rules/settings-section-description.js +0 -45
- package/dist/eslint/rules/shadcn-composition.js +0 -197
- package/dist/eslint/rules/table-column-wraps-by-meta.js +0 -55
|
@@ -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
|
-
};
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* A short fixed set of choices is shown, not hidden.
|
|
3
|
-
*
|
|
4
|
-
* A `Select` spends a click and a popup to reveal what it holds. That trade pays
|
|
5
|
-
* for itself over a long or a loaded list; it does not pay for two or three
|
|
6
|
-
* options that are known at author time, where the popup costs the reader the
|
|
7
|
-
* one thing they came for — seeing what the alternatives are — and costs the
|
|
8
|
-
* keyboard user an open-then-arrow instead of a single arrow.
|
|
9
|
-
*
|
|
10
|
-
* The count has to be STATIC to be a finding. A `SelectContent` filled from
|
|
11
|
-
* `{items.map(...)}` holds however many rows the data has today, so its shape is
|
|
12
|
-
* not this rule's business and any expression child stands the rule down.
|
|
13
|
-
*
|
|
14
|
-
* Standard tool checked first: `no-restricted-syntax` can select a
|
|
15
|
-
* `SelectContent`, but not one whose children are exactly two or three elements
|
|
16
|
-
* of one name and nothing dynamic. That is a property of the subtree, so it is
|
|
17
|
-
* read here.
|
|
18
|
-
*/
|
|
19
|
-
import { jsxComponentName } from "./jsx-class-utility.js";
|
|
20
|
-
|
|
21
|
-
const CONTENT_TAG = "SelectContent";
|
|
22
|
-
const ITEM_TAG = "SelectItem";
|
|
23
|
-
|
|
24
|
-
/*
|
|
25
|
-
* The items one content subtree renders, and whether anything in it is computed.
|
|
26
|
-
* Groups and separators are descended through; an item's own children are not,
|
|
27
|
-
* because a label built from a variable says nothing about how many items there
|
|
28
|
-
* are. Attributes are out of scope for the same reason: `value={option.value}`
|
|
29
|
-
* on a hand-written item is ordinary.
|
|
30
|
-
*/
|
|
31
|
-
function scanContent(node, context, found) {
|
|
32
|
-
for (const child of node.children) {
|
|
33
|
-
if (child.type === "JSXExpressionContainer") {
|
|
34
|
-
if (child.expression.type !== "JSXEmptyExpression") found.dynamic = true;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (child.type === "JSXFragment") {
|
|
38
|
-
scanContent(child, context, found);
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
|
-
if (child.type !== "JSXElement") continue;
|
|
42
|
-
if (jsxComponentName(child.openingElement, context) === ITEM_TAG) {
|
|
43
|
-
found.items.push(child);
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
scanContent(child, context, found);
|
|
47
|
-
}
|
|
48
|
-
return found;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const preferRadioForFewStaticOptions = {
|
|
52
|
-
meta: {
|
|
53
|
-
type: "suggestion",
|
|
54
|
-
docs: {
|
|
55
|
-
description: "Prefer RadioGroup over Select for two or three statically written options",
|
|
56
|
-
},
|
|
57
|
-
schema: [],
|
|
58
|
-
messages: {
|
|
59
|
-
fewStaticOptions:
|
|
60
|
-
"This Select holds {{count}} options fixed at author time, so a reader has to open it to learn what the alternatives are. RadioGroup shows all {{count}} at once and takes one arrow key instead of an open and an arrow. Use RadioGroup with a RadioGroupItem per option.",
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
create(context) {
|
|
64
|
-
return {
|
|
65
|
-
JSXElement(node) {
|
|
66
|
-
if (jsxComponentName(node.openingElement, context) !== CONTENT_TAG) return;
|
|
67
|
-
const { items, dynamic } = scanContent(node, context, { items: [], dynamic: false });
|
|
68
|
-
if (dynamic || items.length < 2 || items.length > 3) return;
|
|
69
|
-
context.report({
|
|
70
|
-
node: node.openingElement,
|
|
71
|
-
messageId: "fewStaticOptions",
|
|
72
|
-
data: { count: String(items.length) },
|
|
73
|
-
});
|
|
74
|
-
},
|
|
75
|
-
};
|
|
76
|
-
},
|
|
77
|
-
};
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Every settings section says what it changes.
|
|
3
|
-
*
|
|
4
|
-
* DESIGN.md ("Cards and sections") gives a settings section a one-line
|
|
5
|
-
* description under its title, the line that tells a person what changing the
|
|
6
|
-
* fields below will do before they change them. The failure this catches: the
|
|
7
|
-
* Portal's Profile photo and Personal details sections, whose titles named the
|
|
8
|
-
* content and nothing said what the change reaches.
|
|
9
|
-
*
|
|
10
|
-
* What is read is a `SettingsSection` element and whether it passes a
|
|
11
|
-
* `description` attribute. A spread may carry one, so an element with a spread
|
|
12
|
-
* attribute is not reported.
|
|
13
|
-
*
|
|
14
|
-
* Standard tool checked first: `no-restricted-syntax` can select
|
|
15
|
-
* `JSXOpeningElement[name.name="SettingsSection"]:not(:has(JSXAttribute[name.name="description"]))`,
|
|
16
|
-
* but not through a renamed import, which `jsxComponentName` resolves.
|
|
17
|
-
*/
|
|
18
|
-
import { jsxComponentName } from "./jsx-class-utility.js";
|
|
19
|
-
|
|
20
|
-
export const settingsSectionDescription = {
|
|
21
|
-
meta: {
|
|
22
|
-
type: "problem",
|
|
23
|
-
docs: {
|
|
24
|
-
description: "Require a one-line description on every SettingsSection",
|
|
25
|
-
},
|
|
26
|
-
schema: [],
|
|
27
|
-
messages: {
|
|
28
|
-
missingDescription:
|
|
29
|
-
'This SettingsSection has no description. Say in one line what changing it does, under its title (DESIGN.md "Cards and sections", rule/settings-section-described).',
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
create(context) {
|
|
33
|
-
return {
|
|
34
|
-
JSXOpeningElement(node) {
|
|
35
|
-
if (jsxComponentName(node, context) !== "SettingsSection") return;
|
|
36
|
-
const described = node.attributes.some(
|
|
37
|
-
(attribute) =>
|
|
38
|
-
attribute.type === "JSXSpreadAttribute" ||
|
|
39
|
-
(attribute.name?.type === "JSXIdentifier" && attribute.name.name === "description"),
|
|
40
|
-
);
|
|
41
|
-
if (!described) context.report({ node, messageId: "missingDescription" });
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
},
|
|
45
|
-
};
|