@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nadicodeai/ui",
|
|
3
|
-
"version": "12.1.
|
|
3
|
+
"version": "12.1.55",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"@assistant-ui/react": "0.15.22",
|
|
78
78
|
"@assistant-ui/react-markdown": "0.14.17",
|
|
79
79
|
"@base-ui/react": "^1.8.0",
|
|
80
|
-
"@nadicodeai/design-system": "12.1.
|
|
80
|
+
"@nadicodeai/design-system": "12.1.55",
|
|
81
81
|
"class-variance-authority": "^0.7.1",
|
|
82
82
|
"clsx": "^2.1.1",
|
|
83
83
|
"cmdk": "^1.1.1",
|
package/dist/.build-kind
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
clean
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Cards that share a row share one height, and one component makes that true.
|
|
3
|
-
*
|
|
4
|
-
* What is read is a JSX element's OWN class utilities together with its OWN
|
|
5
|
-
* direct children: the utilities say whether the element puts its children side
|
|
6
|
-
* by side — a grid with more than one column, or a flex that is not a column —
|
|
7
|
-
* and the children say whether those are cards. A `Card`, anything whose name
|
|
8
|
-
* ends in `Card` (`OverviewChartCard`), and a `.map()` whose callback returns
|
|
9
|
-
* one all count, because a mapped list is the row that goes ragged first.
|
|
10
|
-
*
|
|
11
|
-
* Why: a hand-written row leaves each card at its content's height, so three
|
|
12
|
-
* cards with 4, 5 and 5 rows end on three different lines and a finished
|
|
13
|
-
* analytics surface reads as broken (`rule/card-row-equal-height`). `CardRow`
|
|
14
|
-
* from `@nadicodeai/ui` stretches its cells and makes each card fill the one it
|
|
15
|
-
* lands in; a row written by hand has to remember both halves and usually
|
|
16
|
-
* remembers neither.
|
|
17
|
-
*
|
|
18
|
-
* A vertical stack is not a row and is never reported: `flex-col`, a `grid`
|
|
19
|
-
* with one column or no column count at all, and `space-y-*` each put one card
|
|
20
|
-
* under the next, where equal height means nothing.
|
|
21
|
-
*
|
|
22
|
-
* What it cannot see, by design, because each would need another file: a card
|
|
23
|
-
* behind an intermediate wrapper element, a card a child component renders from
|
|
24
|
-
* its own file, and a class list the shared reader's one hop cannot resolve.
|
|
25
|
-
* Those stay review findings. `packages/ui/src` is exempt by default, since that
|
|
26
|
-
* is where `CardRow` and the package's own compositions are built;
|
|
27
|
-
* `packageOwnedPrimitives: "lint"` holds the package to the rule too.
|
|
28
|
-
*
|
|
29
|
-
* Standard tool checked first: `better-tailwindcss/no-restricted-classes`
|
|
30
|
-
* matches a class wherever it is written and would have to ban `grid-cols-2`
|
|
31
|
-
* outright; it cannot ask what the element carrying it contains. "A multi-column
|
|
32
|
-
* container whose children are cards" is a relationship between a class list and
|
|
33
|
-
* a child list, so it is read here, on the shared class and component-name
|
|
34
|
-
* readers.
|
|
35
|
-
*/
|
|
36
|
-
import {
|
|
37
|
-
jsxComponentName,
|
|
38
|
-
openingElementClassUtilities,
|
|
39
|
-
packageOwnedPrimitivesSchema,
|
|
40
|
-
skipsPackageOwnedPrimitive,
|
|
41
|
-
} from "./jsx-class-utility.js";
|
|
42
|
-
|
|
43
|
-
const OWNER = "CardRow";
|
|
44
|
-
|
|
45
|
-
function withoutVariants(token) {
|
|
46
|
-
let bracketDepth = 0;
|
|
47
|
-
let start = 0;
|
|
48
|
-
for (let index = 0; index < token.length; index += 1) {
|
|
49
|
-
if (token[index] === "[") bracketDepth += 1;
|
|
50
|
-
if (token[index] === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
|
51
|
-
if (token[index] === ":" && bracketDepth === 0) start = index + 1;
|
|
52
|
-
}
|
|
53
|
-
return token.slice(start);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The utility that makes this element a row, or undefined. A `grid-cols-*` of
|
|
58
|
-
* anything but one, an arbitrary column template, an explicit `flex-row`, or a
|
|
59
|
-
* bare `flex` that no `flex-col` turns on its side — at any breakpoint, since a
|
|
60
|
-
* row at `lg` is still a row.
|
|
61
|
-
*/
|
|
62
|
-
function rowUtility(utilities) {
|
|
63
|
-
const unqualified = utilities.map(withoutVariants);
|
|
64
|
-
for (const utility of unqualified) {
|
|
65
|
-
if (!utility.startsWith("grid-cols-")) continue;
|
|
66
|
-
const columns = utility.slice("grid-cols-".length);
|
|
67
|
-
if (columns !== "1") return utility;
|
|
68
|
-
}
|
|
69
|
-
if (unqualified.includes("flex-row")) return "flex-row";
|
|
70
|
-
if (unqualified.includes("flex") && !unqualified.includes("flex-col")) return "flex";
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function isCardName(name) {
|
|
75
|
-
return typeof name === "string" && (name === "Card" || /Card$/.test(name));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The element a `.map()` or `Array.from()` callback renders, descended through its return. */
|
|
79
|
-
function mappedElement(expression) {
|
|
80
|
-
if (expression?.type !== "CallExpression") return undefined;
|
|
81
|
-
const { callee } = expression;
|
|
82
|
-
if (callee.type !== "MemberExpression" || callee.computed) return undefined;
|
|
83
|
-
if (callee.property.name !== "map" && callee.property.name !== "from") return undefined;
|
|
84
|
-
const callback = expression.arguments.find(
|
|
85
|
-
(argument) =>
|
|
86
|
-
argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression",
|
|
87
|
-
);
|
|
88
|
-
if (!callback) return undefined;
|
|
89
|
-
if (callback.body.type !== "BlockStatement") return returnedElement(callback.body);
|
|
90
|
-
for (const statement of callback.body.body) {
|
|
91
|
-
if (statement.type !== "ReturnStatement") continue;
|
|
92
|
-
const element = returnedElement(statement.argument);
|
|
93
|
-
if (element) return element;
|
|
94
|
-
}
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The JSX element one expression renders, through parentheses and conditionals. */
|
|
99
|
-
function returnedElement(node) {
|
|
100
|
-
if (!node) return undefined;
|
|
101
|
-
switch (node.type) {
|
|
102
|
-
case "JSXElement":
|
|
103
|
-
return node;
|
|
104
|
-
case "ConditionalExpression":
|
|
105
|
-
return returnedElement(node.consequent) ?? returnedElement(node.alternate);
|
|
106
|
-
case "LogicalExpression":
|
|
107
|
-
return returnedElement(node.right);
|
|
108
|
-
default:
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* How many cards this element lays out directly. A `.map()` counts as two: one
|
|
115
|
-
* expression stands for however many records there are, which is the case the
|
|
116
|
-
* ragged edge shows up in. A card behind a condition counts as one.
|
|
117
|
-
*/
|
|
118
|
-
function cardChildCount(node, context) {
|
|
119
|
-
let count = 0;
|
|
120
|
-
for (const child of node.children) {
|
|
121
|
-
if (child.type === "JSXElement") {
|
|
122
|
-
if (isCardName(jsxComponentName(child.openingElement, context))) count += 1;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (child.type !== "JSXExpressionContainer") continue;
|
|
126
|
-
const mapped = mappedElement(child.expression);
|
|
127
|
-
if (mapped) {
|
|
128
|
-
if (isCardName(jsxComponentName(mapped.openingElement, context))) count += 2;
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const conditional = returnedElement(child.expression);
|
|
132
|
-
if (conditional && isCardName(jsxComponentName(conditional.openingElement, context)))
|
|
133
|
-
count += 1;
|
|
134
|
-
}
|
|
135
|
-
return count;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export const cardRowOwner = {
|
|
139
|
-
meta: {
|
|
140
|
-
type: "problem",
|
|
141
|
-
docs: {
|
|
142
|
-
description: "Require the shared CardRow owner for a row of sibling cards",
|
|
143
|
-
},
|
|
144
|
-
schema: [packageOwnedPrimitivesSchema],
|
|
145
|
-
messages: {
|
|
146
|
-
cardRowOwner:
|
|
147
|
-
'"{{utility}}" lays these cards side by side without the owner of that row. An alignment class or a wrapper around a card is then enough for the shortest card to stop at its content and the row to end on a ragged edge. Use CardRow from @nadicodeai/ui, which stretches its cells and makes every card fill the one it lands in, wrapped in a list item or not (rule/card-row-equal-height).',
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
create(context) {
|
|
151
|
-
if (skipsPackageOwnedPrimitive(context)) return {};
|
|
152
|
-
return {
|
|
153
|
-
JSXElement(node) {
|
|
154
|
-
if (jsxComponentName(node.openingElement, context) === OWNER) return;
|
|
155
|
-
const utility = rowUtility(openingElementClassUtilities(node.openingElement, context));
|
|
156
|
-
if (!utility) return;
|
|
157
|
-
const count = cardChildCount(node, context);
|
|
158
|
-
if (count < 2) return;
|
|
159
|
-
context.report({
|
|
160
|
-
node: node.openingElement,
|
|
161
|
-
messageId: "cardRowOwner",
|
|
162
|
-
data: { utility },
|
|
163
|
-
});
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
},
|
|
167
|
-
};
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* A destructive Server Action never fires on a bare form or submit button.
|
|
3
|
-
*
|
|
4
|
-
* Two shapes are destructive, and both are read off the JSX itself rather than
|
|
5
|
-
* out of a registry:
|
|
6
|
-
*
|
|
7
|
-
* - the bound symbol follows the Portal's destructive-action naming convention
|
|
8
|
-
* (`archiveThingAction`, `removeMemberAction`, `revokeInstallationAction`);
|
|
9
|
-
* - the form submits a lifecycle change to `archived`, which is how an archive
|
|
10
|
-
* is written through an otherwise ordinary update action — the hidden field
|
|
11
|
-
* is the destructive part, not the symbol's name.
|
|
12
|
-
*
|
|
13
|
-
* The approved carriers are `ConfirmedActionForm` and a destructive
|
|
14
|
-
* `ActionDialogForm`. A component that receives a destructive action as a prop
|
|
15
|
-
* binds it under that prop's name, so the convention holds at the site where
|
|
16
|
-
* the binding is actually written and this rule sees it there.
|
|
17
|
-
*
|
|
18
|
-
* Standard tool checked first: `no-restricted-syntax` judges one
|
|
19
|
-
* node at a time, and the archive shape is a relationship between two — an
|
|
20
|
-
* ordinary `action={updateCustomerAction}` binding on the form, and the
|
|
21
|
-
* `lifecycle_status="archived"` field somewhere below it, which is what makes
|
|
22
|
-
* that ordinary binding destructive. A selector can match either node alone;
|
|
23
|
-
* neither alone is a violation. Customer archival in
|
|
24
|
-
* `apps/portal/src/components/customers/customer-settings-tab.tsx` is exactly that
|
|
25
|
-
* shape, so the relationship has to be read, and that is what this rule does.
|
|
26
|
-
*
|
|
27
|
-
* Replaces the vitest guard that joined a hand-kept `CONFIRM_REQUIRED_ACTIONS`
|
|
28
|
-
* ledger of file paths and symbols against a TypeScript AST walk (#821, #822).
|
|
29
|
-
* What the ledger held that a derivation cannot — an action that destroys
|
|
30
|
-
* nothing yet still deserves a confirmation, such as starting an impersonation,
|
|
31
|
-
* and the `confirm*` callbacks behind `ConfirmDialog` — is judgment, and ADR
|
|
32
|
-
* 0057 puts judgment in review rather than in machinery.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const DESTRUCTIVE_ACTION_NAME =
|
|
36
|
-
/^(?:archive|cancel|decline|remove|revoke|suspend|unassign)[A-Z][A-Za-z]*Action$/;
|
|
37
|
-
const BINDING_ATTRIBUTES = new Set(["action", "formAction"]);
|
|
38
|
-
const LIFECYCLE_FIELD = "lifecycle_status";
|
|
39
|
-
const ARCHIVED = "archived";
|
|
40
|
-
|
|
41
|
-
function elementName(openingElement) {
|
|
42
|
-
return openingElement?.name?.type === "JSXIdentifier" ? openingElement.name.name : undefined;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function openingElementOf(node) {
|
|
46
|
-
if (node.type === "JSXElement") return node.openingElement;
|
|
47
|
-
return node.type === "JSXOpeningElement" ? node : undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function attributeValue(openingElement, name) {
|
|
51
|
-
for (const attribute of openingElement.attributes) {
|
|
52
|
-
if (attribute.type !== "JSXAttribute" || attribute.name.name !== name) continue;
|
|
53
|
-
const value = attribute.value;
|
|
54
|
-
if (value === null) return true;
|
|
55
|
-
if (value.type === "Literal") return value.value;
|
|
56
|
-
if (value.type === "JSXExpressionContainer" && value.expression.type === "Literal") {
|
|
57
|
-
return value.expression.value;
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Every identifier named anywhere inside a subtree. */
|
|
65
|
-
function identifiers(node, found = []) {
|
|
66
|
-
if (!node || typeof node.type !== "string") return found;
|
|
67
|
-
if (node.type === "Identifier") {
|
|
68
|
-
found.push(node.name);
|
|
69
|
-
return found;
|
|
70
|
-
}
|
|
71
|
-
for (const [key, value] of Object.entries(node)) {
|
|
72
|
-
if (key === "parent") continue;
|
|
73
|
-
if (Array.isArray(value)) {
|
|
74
|
-
for (const child of value) identifiers(child, found);
|
|
75
|
-
} else if (value && typeof value === "object") {
|
|
76
|
-
identifiers(value, found);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return found;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/*
|
|
83
|
-
* The children a JSX subtree renders. A field is as often written inside a
|
|
84
|
-
* `{condition && <input …/>}` or a ternary as it is written bare, so the walk
|
|
85
|
-
* steps through the expression container and the two operators that carry JSX
|
|
86
|
-
* inside it. Nothing is evaluated: both branches of a `&&` or a `?:` count as
|
|
87
|
-
* rendered, because either one may be.
|
|
88
|
-
*/
|
|
89
|
-
function renderedChildren(node) {
|
|
90
|
-
switch (node.type) {
|
|
91
|
-
case "JSXElement":
|
|
92
|
-
case "JSXFragment":
|
|
93
|
-
return node.children;
|
|
94
|
-
case "JSXExpressionContainer":
|
|
95
|
-
return [node.expression];
|
|
96
|
-
case "LogicalExpression":
|
|
97
|
-
return [node.left, node.right];
|
|
98
|
-
case "ConditionalExpression":
|
|
99
|
-
return [node.consequent, node.alternate];
|
|
100
|
-
default:
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** True when the subtree submits a lifecycle change to `archived`. */
|
|
106
|
-
function submitsArchive(node) {
|
|
107
|
-
const opening = openingElementOf(node);
|
|
108
|
-
if (
|
|
109
|
-
opening &&
|
|
110
|
-
attributeValue(opening, "name") === LIFECYCLE_FIELD &&
|
|
111
|
-
attributeValue(opening, "value") === ARCHIVED
|
|
112
|
-
) {
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
return renderedChildren(node).some((child) => submitsArchive(child));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function enclosingForm(node, sourceCode) {
|
|
119
|
-
const ancestors = sourceCode.getAncestors(node);
|
|
120
|
-
for (let index = ancestors.length - 1; index >= 0; index -= 1) {
|
|
121
|
-
const ancestor = ancestors[index];
|
|
122
|
-
if (ancestor.type === "JSXElement" && elementName(ancestor.openingElement) === "form") {
|
|
123
|
-
return ancestor;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export const confirmDestructiveAction = {
|
|
130
|
-
meta: {
|
|
131
|
-
type: "problem",
|
|
132
|
-
docs: {
|
|
133
|
-
description:
|
|
134
|
-
"Require a destructive Server Action to be bound through an approved confirmation component",
|
|
135
|
-
},
|
|
136
|
-
schema: [],
|
|
137
|
-
messages: {
|
|
138
|
-
unconfirmed:
|
|
139
|
-
"A destructive Server Action has no opt-out: bind {{binding}} through ConfirmedActionForm, a destructive ActionDialogForm, or ConfirmDialog onConfirm — never on a bare <{{tag}}>.",
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
create(context) {
|
|
143
|
-
return {
|
|
144
|
-
JSXOpeningElement(node) {
|
|
145
|
-
const tag = elementName(node);
|
|
146
|
-
if (!tag) return;
|
|
147
|
-
|
|
148
|
-
for (const attribute of node.attributes) {
|
|
149
|
-
if (attribute.type !== "JSXAttribute" || attribute.name.type !== "JSXIdentifier")
|
|
150
|
-
continue;
|
|
151
|
-
if (!BINDING_ATTRIBUTES.has(attribute.name.name)) continue;
|
|
152
|
-
|
|
153
|
-
const bound = identifiers(attribute.value).filter((name) =>
|
|
154
|
-
DESTRUCTIVE_ACTION_NAME.test(name),
|
|
155
|
-
);
|
|
156
|
-
const scope =
|
|
157
|
-
attribute.name.name === "formAction"
|
|
158
|
-
? enclosingForm(node, context.sourceCode)
|
|
159
|
-
: node.parent;
|
|
160
|
-
const destructive = bound.length > 0 || (scope ? submitsArchive(scope) : false);
|
|
161
|
-
if (!destructive) continue;
|
|
162
|
-
|
|
163
|
-
const approved =
|
|
164
|
-
tag === "ConfirmedActionForm" ||
|
|
165
|
-
(tag === "ActionDialogForm" && attributeValue(node, "destructive") !== undefined);
|
|
166
|
-
if (approved) continue;
|
|
167
|
-
|
|
168
|
-
context.report({
|
|
169
|
-
node: attribute,
|
|
170
|
-
messageId: "unconfirmed",
|
|
171
|
-
data: { binding: bound[0] ?? `the ${ARCHIVED} lifecycle change`, tag },
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
},
|
|
177
|
-
};
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Standard tool checked first: `better-tailwindcss/no-restricted-classes` bans a
|
|
3
|
-
* class everywhere and cannot ask which element it sits on or what that element
|
|
4
|
-
* wraps, so the relationship is read here on the shared readers.
|
|
5
|
-
*/
|
|
6
|
-
import { jsxComponentName, openingElementClassUtilities } from "./jsx-class-utility.js";
|
|
7
|
-
|
|
8
|
-
const SLOTS = new Set(["DrawerHeader", "DrawerBody", "DrawerFooter"]);
|
|
9
|
-
|
|
10
|
-
const EDGE = /^border(?:-[tby])?(?:-\d+)?$/;
|
|
11
|
-
|
|
12
|
-
function edgeUtility(node, context) {
|
|
13
|
-
return openingElementClassUtilities(node, context).find((utility) =>
|
|
14
|
-
EDGE.test(utility.split(":").at(-1).replace(/^!/, "")),
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function wrappedSlot(element, context) {
|
|
19
|
-
for (const child of element.children) {
|
|
20
|
-
if (child.type !== "JSXElement") continue;
|
|
21
|
-
const name = jsxComponentName(child.openingElement, context);
|
|
22
|
-
if (name && SLOTS.has(name)) return name;
|
|
23
|
-
}
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export const drawerOwnsItsEdges = {
|
|
28
|
-
meta: {
|
|
29
|
-
type: "problem",
|
|
30
|
-
docs: {
|
|
31
|
-
description: "Keep the edges between a drawer's header, body and footer with the drawer",
|
|
32
|
-
},
|
|
33
|
-
schema: [],
|
|
34
|
-
messages: {
|
|
35
|
-
slotEdge:
|
|
36
|
-
'"{{utility}}" gives <{{slot}}> a second edge. The drawer draws the hairline between its header, its body and its footer; drop the border class (rule/drawer-owns-its-edges).',
|
|
37
|
-
wrapperEdge:
|
|
38
|
-
'"{{utility}}" draws an edge around <{{slot}}>. The drawer draws the hairline between its header, its body and its footer; render the slot without this wrapper (rule/drawer-owns-its-edges).',
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
create(context) {
|
|
42
|
-
return {
|
|
43
|
-
JSXElement(element) {
|
|
44
|
-
const utility = edgeUtility(element.openingElement, context);
|
|
45
|
-
if (!utility) return;
|
|
46
|
-
const own = jsxComponentName(element.openingElement, context);
|
|
47
|
-
if (own && SLOTS.has(own)) {
|
|
48
|
-
context.report({
|
|
49
|
-
node: element.openingElement,
|
|
50
|
-
messageId: "slotEdge",
|
|
51
|
-
data: { utility, slot: own },
|
|
52
|
-
});
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const slot = wrappedSlot(element, context);
|
|
56
|
-
if (slot) {
|
|
57
|
-
context.report({
|
|
58
|
-
node: element.openingElement,
|
|
59
|
-
messageId: "wrapperEdge",
|
|
60
|
-
data: { utility, slot },
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
},
|
|
66
|
-
};
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* One reader for `expect(subject).<modifiers>.<matcher>(args)`.
|
|
3
|
-
*
|
|
4
|
-
* Both assertion rules here judge a call by its matcher name, and the matcher
|
|
5
|
-
* is not the callee's property in the general case: `not`, `resolves` and
|
|
6
|
-
* `rejects` sit between `expect(...)` and the matcher, so the name has to be
|
|
7
|
-
* read by walking the member chain back to the `expect` call. This is that
|
|
8
|
-
* walk, kept in one place so the two rules cannot disagree about what counts
|
|
9
|
-
* as an assertion.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
function propertyName(member) {
|
|
13
|
-
if (!member.computed && member.property.type === "Identifier") return member.property.name;
|
|
14
|
-
if (member.computed && member.property.type === "Literal") {
|
|
15
|
-
return typeof member.property.value === "string" ? member.property.value : undefined;
|
|
16
|
-
}
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** `a?.b` and `a?.()` arrive wrapped; the rules want the expression itself. */
|
|
21
|
-
function unwrapChain(node) {
|
|
22
|
-
return node?.type === "ChainExpression" ? node.expression : node;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function isExpectCall(node) {
|
|
26
|
-
if (node?.type !== "CallExpression") return false;
|
|
27
|
-
if (node.callee.type === "Identifier") return node.callee.name === "expect";
|
|
28
|
-
// `expect.soft(value)` and `expect.poll(fn)` open the same chain.
|
|
29
|
-
return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier"
|
|
30
|
-
? node.callee.object.name === "expect"
|
|
31
|
-
: false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Reads an assertion call. Returns `{ matcher, modifiers, subject, args }` for
|
|
36
|
-
* `expect(subject).<modifiers>.<matcher>(args)`, or `undefined` when the call
|
|
37
|
-
* is not an `expect` chain. `subject` is unwrapped of its `ChainExpression`.
|
|
38
|
-
*/
|
|
39
|
-
export function expectAssertion(node) {
|
|
40
|
-
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return undefined;
|
|
41
|
-
const matcher = propertyName(node.callee);
|
|
42
|
-
if (!matcher) return undefined;
|
|
43
|
-
|
|
44
|
-
const modifiers = [];
|
|
45
|
-
let current = node.callee.object;
|
|
46
|
-
while (current.type === "MemberExpression") {
|
|
47
|
-
const name = propertyName(current);
|
|
48
|
-
if (!name) return undefined;
|
|
49
|
-
modifiers.unshift(name);
|
|
50
|
-
current = current.object;
|
|
51
|
-
}
|
|
52
|
-
if (!isExpectCall(current)) return undefined;
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
matcher,
|
|
56
|
-
modifiers,
|
|
57
|
-
subject: unwrapChain(current.arguments[0]),
|
|
58
|
-
args: node.arguments,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* A control whose whole content is an icon still has to say what it does.
|
|
3
|
-
*
|
|
4
|
-
* An icon is a picture, and a screen reader announces "button" and nothing else
|
|
5
|
-
* for one. Sighted readers recover the meaning from the glyph; a screen-reader
|
|
6
|
-
* user, and anyone addressing the control by name through voice control, gets an
|
|
7
|
-
* unnamed button. The name comes from `aria-label`, from `aria-labelledby`, or
|
|
8
|
-
* from text inside the control that is hidden visually rather than from the
|
|
9
|
-
* accessibility tree — the `sr-only` span this repository already writes beside
|
|
10
|
-
* its close buttons. That last spelling needs no separate test here: hidden text
|
|
11
|
-
* is text, so a control carrying one is not icon-only in the first place.
|
|
12
|
-
*
|
|
13
|
-
* `title` is NOT one of them. It does name the control for most screen readers,
|
|
14
|
-
* but it also mints a browser tooltip, and the copy contract (the Tooltip
|
|
15
|
-
* contract in `skills/design/product-design/references/copy.md`) says the title
|
|
16
|
-
* attribute is not this product's help system. Accepting it here would make the
|
|
17
|
-
* rule push authors toward the one tooltip the design system refuses.
|
|
18
|
-
*
|
|
19
|
-
* Two shapes are icon-only, and both are read off the element rather than out of
|
|
20
|
-
* a list: a `Button` on one of the icon size variants (`icon`, `icon-xs`,
|
|
21
|
-
* `icon-sm`, `icon-lg`), and any `Button` or `button` whose children render
|
|
22
|
-
* elements and no text at all.
|
|
23
|
-
*
|
|
24
|
-
* Two shapes are deliberately out of scope. A `Button` written as the value of a
|
|
25
|
-
* `render` prop is a STYLE carrier: the primitive that receives it owns the
|
|
26
|
-
* rendered element's children and its label, as `<DropdownMenuTrigger
|
|
27
|
-
* aria-label={label} render={<Button size="icon-sm" />}>` does in
|
|
28
|
-
* `packages/ui/src/components/action-menu.tsx`, so the name is not this element's
|
|
29
|
-
* to carry. An element with a `{...props}` spread may be handed a label by its
|
|
30
|
-
* caller, and this rule does not guess what a spread holds.
|
|
31
|
-
*
|
|
32
|
-
* Standard tool checked first: the root config runs jsx-a11y's `alt-text`,
|
|
33
|
-
* `aria-props`, `aria-proptypes`, `aria-unsupported-elements`,
|
|
34
|
-
* `role-has-required-aria-props` and `role-supports-aria-props`, and Next's
|
|
35
|
-
* core-web-vitals config adds no more. Every one of them judges an attribute
|
|
36
|
-
* that is already present; none asks whether a control has a name at all.
|
|
37
|
-
* `control-has-associated-label` is the upstream rule that would, and it is
|
|
38
|
-
* enabled nowhere, looks for a label element rather than an accessible name, and
|
|
39
|
-
* knows nothing about a size variant that means "icon".
|
|
40
|
-
*/
|
|
41
|
-
import { jsxComponentName } from "./jsx-class-utility.js";
|
|
42
|
-
|
|
43
|
-
const CONTROL_TAGS = new Set(["Button", "button"]);
|
|
44
|
-
const ICON_SIZES = new Set(["icon", "icon-xs", "icon-sm", "icon-lg"]);
|
|
45
|
-
const NAME_ATTRIBUTES = new Set(["aria-label", "aria-labelledby"]);
|
|
46
|
-
|
|
47
|
-
function attributeName(attribute) {
|
|
48
|
-
return attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier"
|
|
49
|
-
? attribute.name.name
|
|
50
|
-
: undefined;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The literal string an attribute carries, or undefined for anything computed. */
|
|
54
|
-
function literalAttributeValue(openingElement, name) {
|
|
55
|
-
for (const attribute of openingElement.attributes) {
|
|
56
|
-
if (attributeName(attribute) !== name) continue;
|
|
57
|
-
const value = attribute.value;
|
|
58
|
-
return value?.type === "Literal" && typeof value.value === "string" ? value.value : undefined;
|
|
59
|
-
}
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** True when this element stands in for another primitive's rendered element. */
|
|
64
|
-
function isRenderProp(node, sourceCode) {
|
|
65
|
-
return sourceCode.getAncestors(node).some((ancestor) => attributeName(ancestor) === "render");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/*
|
|
69
|
-
* Whether an expression can only ever evaluate to JSX or to nothing. `{label}`
|
|
70
|
-
* and `{day.getDate()}` are text; `{copied ? <Check /> : <Copy />}` is not.
|
|
71
|
-
*/
|
|
72
|
-
function producesOnlyElements(expression) {
|
|
73
|
-
switch (expression.type) {
|
|
74
|
-
case "JSXEmptyExpression":
|
|
75
|
-
case "JSXElement":
|
|
76
|
-
case "JSXFragment":
|
|
77
|
-
return true;
|
|
78
|
-
case "ConditionalExpression":
|
|
79
|
-
return (
|
|
80
|
-
producesOnlyElements(expression.consequent) && producesOnlyElements(expression.alternate)
|
|
81
|
-
);
|
|
82
|
-
case "LogicalExpression":
|
|
83
|
-
return producesOnlyElements(expression.right);
|
|
84
|
-
case "Literal":
|
|
85
|
-
return expression.value === null || expression.value === false;
|
|
86
|
-
case "Identifier":
|
|
87
|
-
return expression.name === "undefined";
|
|
88
|
-
default:
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Whether a subtree renders any text, visible or visually hidden. */
|
|
94
|
-
function rendersText(node) {
|
|
95
|
-
for (const child of node.children ?? []) {
|
|
96
|
-
if (child.type === "JSXText") {
|
|
97
|
-
if (child.value.trim() !== "") return true;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (child.type === "JSXElement" || child.type === "JSXFragment") {
|
|
101
|
-
if (rendersText(child)) return true;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
if (child.type === "JSXExpressionContainer" && !producesOnlyElements(child.expression)) {
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Whether a subtree renders at least one element of its own. */
|
|
112
|
-
function rendersElement(node) {
|
|
113
|
-
return (node.children ?? []).some(
|
|
114
|
-
(child) =>
|
|
115
|
-
child.type === "JSXElement" ||
|
|
116
|
-
child.type === "JSXFragment" ||
|
|
117
|
-
(child.type === "JSXExpressionContainer" &&
|
|
118
|
-
child.expression.type !== "JSXEmptyExpression" &&
|
|
119
|
-
producesOnlyElements(child.expression)),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export const iconControlAccessibleName = {
|
|
124
|
-
meta: {
|
|
125
|
-
type: "problem",
|
|
126
|
-
docs: {
|
|
127
|
-
description: "Require an accessible name on a control whose only content is an icon",
|
|
128
|
-
},
|
|
129
|
-
schema: [],
|
|
130
|
-
messages: {
|
|
131
|
-
unnamedIconControl:
|
|
132
|
-
'A screen reader announces "{{tag}}" and nothing more for this control: its content is an icon, and an icon carries no name. Add aria-label with the action it performs, point aria-labelledby at the text that names it, or put that text in a child with the sr-only class.',
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
-
create(context) {
|
|
136
|
-
return {
|
|
137
|
-
JSXOpeningElement(node) {
|
|
138
|
-
const tag = jsxComponentName(node, context);
|
|
139
|
-
if (!tag || !CONTROL_TAGS.has(tag)) return;
|
|
140
|
-
if (node.attributes.some((attribute) => attribute.type === "JSXSpreadAttribute")) return;
|
|
141
|
-
if (
|
|
142
|
-
node.attributes.some((attribute) => NAME_ATTRIBUTES.has(attributeName(attribute) ?? ""))
|
|
143
|
-
)
|
|
144
|
-
return;
|
|
145
|
-
if (isRenderProp(node, context.sourceCode)) return;
|
|
146
|
-
|
|
147
|
-
const element = node.parent;
|
|
148
|
-
const iconSized = ICON_SIZES.has(literalAttributeValue(node, "size") ?? "");
|
|
149
|
-
if (!iconSized && !rendersElement(element)) return;
|
|
150
|
-
if (rendersText(element)) return;
|
|
151
|
-
|
|
152
|
-
context.report({ node, messageId: "unnamedIconControl", data: { tag } });
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
},
|
|
156
|
-
};
|