@camunda/design-system 0.62.0 → 0.64.0
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/src/components/ui/alert-dialog.d.ts +1 -1
- package/dist/src/components/ui/alert-dialog.d.ts.map +1 -1
- package/dist/src/components/ui/alert-dialog.js +3 -1
- package/dist/src/components/ui/alert-dialog.js.map +1 -1
- package/dist/src/components/ui/app-sidebar.d.ts +7 -15
- package/dist/src/components/ui/app-sidebar.d.ts.map +1 -1
- package/dist/src/components/ui/app-sidebar.js +192 -55
- package/dist/src/components/ui/app-sidebar.js.map +1 -1
- package/dist/src/components/ui/collapsible.d.ts +1 -1
- package/dist/src/components/ui/collapsible.d.ts.map +1 -1
- package/dist/src/components/ui/collapsible.js +3 -1
- package/dist/src/components/ui/collapsible.js.map +1 -1
- package/dist/src/components/ui/data-table.d.ts +17 -2
- package/dist/src/components/ui/data-table.d.ts.map +1 -1
- package/dist/src/components/ui/data-table.js +16 -7
- package/dist/src/components/ui/data-table.js.map +1 -1
- package/dist/src/components/ui/dialog.d.ts +1 -1
- package/dist/src/components/ui/dialog.d.ts.map +1 -1
- package/dist/src/components/ui/dialog.js +3 -1
- package/dist/src/components/ui/dialog.js.map +1 -1
- package/dist/src/components/ui/dropdown.d.ts +3 -3
- package/dist/src/components/ui/dropdown.d.ts.map +1 -1
- package/dist/src/components/ui/dropdown.js +7 -2
- package/dist/src/components/ui/dropdown.js.map +1 -1
- package/dist/src/components/ui/input.d.ts.map +1 -1
- package/dist/src/components/ui/input.js +7 -4
- package/dist/src/components/ui/input.js.map +1 -1
- package/dist/src/components/ui/popover.d.ts +1 -1
- package/dist/src/components/ui/popover.d.ts.map +1 -1
- package/dist/src/components/ui/popover.js +8 -1
- package/dist/src/components/ui/popover.js.map +1 -1
- package/dist/src/components/ui/select.d.ts +1 -1
- package/dist/src/components/ui/select.d.ts.map +1 -1
- package/dist/src/components/ui/select.js +16 -9
- package/dist/src/components/ui/select.js.map +1 -1
- package/dist/src/components/ui/sheet.d.ts +1 -1
- package/dist/src/components/ui/sheet.d.ts.map +1 -1
- package/dist/src/components/ui/sheet.js +3 -1
- package/dist/src/components/ui/sheet.js.map +1 -1
- package/dist/src/components/ui/sidebar-provider.d.ts +7 -1
- package/dist/src/components/ui/sidebar-provider.d.ts.map +1 -1
- package/dist/src/components/ui/sidebar-provider.js +10 -6
- package/dist/src/components/ui/sidebar-provider.js.map +1 -1
- package/dist/src/components/ui/tabs.d.ts +1 -1
- package/dist/src/components/ui/tabs.d.ts.map +1 -1
- package/dist/src/components/ui/tabs.js +2 -1
- package/dist/src/components/ui/tabs.js.map +1 -1
- package/dist/src/components/ui/textarea.d.ts.map +1 -1
- package/dist/src/components/ui/textarea.js +8 -5
- package/dist/src/components/ui/textarea.js.map +1 -1
- package/dist/src/components/ui/tooltip.d.ts +1 -1
- package/dist/src/components/ui/tooltip.d.ts.map +1 -1
- package/dist/src/components/ui/tooltip.js +5 -1
- package/dist/src/components/ui/tooltip.js.map +1 -1
- package/dist/src/icons/index.d.ts +1 -1
- package/dist/src/icons/index.d.ts.map +1 -1
- package/dist/src/icons/index.js +1 -1
- package/dist/src/icons/index.js.map +1 -1
- package/dist/src/icons/registry.d.ts +1 -0
- package/dist/src/icons/registry.d.ts.map +1 -1
- package/dist/src/icons/registry.js +3 -1
- package/dist/src/icons/registry.js.map +1 -1
- package/dist/src/icons/registry.json +12 -4
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/i18n/dictionary.d.ts +0 -1
- package/dist/src/lib/i18n/dictionary.d.ts.map +1 -1
- package/dist/src/lib/i18n/dictionary.js +0 -1
- package/dist/src/lib/i18n/dictionary.js.map +1 -1
- package/dist/styles.css +49 -34
- package/eslint-plugin/index.d.ts +18 -0
- package/eslint-plugin/index.js +102 -0
- package/eslint-rules/button-has-type.js +56 -0
- package/eslint-rules/class-literal.js +20 -0
- package/eslint-rules/ds-imports.js +131 -0
- package/eslint-rules/icon-button-needs-label.js +110 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/navigation.js +295 -0
- package/eslint-rules/no-adhoc-typography.js +77 -0
- package/eslint-rules/no-deep-import.js +59 -0
- package/eslint-rules/no-direct-lucide-import.js +67 -0
- package/eslint-rules/no-hardcoded-i18n-prop.js +78 -0
- package/eslint-rules/no-hardcoded-user-facing-string.js +82 -0
- package/eslint-rules/no-primitive-tailwind-colors.js +36 -0
- package/eslint-rules/no-raw-color-in-classname.js +34 -0
- package/eslint-rules/prefer-link-props.js +107 -0
- package/eslint-rules/require-browsable-link.js +236 -0
- package/eslint-rules/require-shared-sidebar-provider.js +165 -0
- package/package.json +18 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags a DS `<Button size="icon…">` with no accessible name: no `aria-label`
|
|
3
|
+
* / `aria-labelledby` on it or on a descendant (the `asChild` link case), and
|
|
4
|
+
* no text among its children. Icon-only buttons
|
|
5
|
+
* without a name are the most common a11y violation in consumer apps.
|
|
6
|
+
*
|
|
7
|
+
* Only `Button` imported from a DS module is checked (see ds-imports.js).
|
|
8
|
+
* Conservative by design — it stays silent whenever the name may come from
|
|
9
|
+
* somewhere it cannot see: a spread, a dynamic `size`, or any `{expression}`
|
|
10
|
+
* child (a translated string, a label variable, a conditional).
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
attributeValueKind,
|
|
14
|
+
findAttribute,
|
|
15
|
+
hasSpread,
|
|
16
|
+
importSourcesSchema,
|
|
17
|
+
staticString,
|
|
18
|
+
trackDsImports,
|
|
19
|
+
} from "./ds-imports.js";
|
|
20
|
+
|
|
21
|
+
const HAS_WORDS = /\p{L}/u;
|
|
22
|
+
|
|
23
|
+
// A name attribute counts unless it is statically empty: `aria-label=""`,
|
|
24
|
+
// `aria-label={null}`, bare `aria-label`. Dynamic values are trusted.
|
|
25
|
+
function hasNameAttribute(opening) {
|
|
26
|
+
return ["aria-label", "aria-labelledby"].some((name) => {
|
|
27
|
+
const attr = findAttribute(opening, name);
|
|
28
|
+
if (!attr) return false;
|
|
29
|
+
const { kind, value } = attributeValueKind(attr.value);
|
|
30
|
+
return kind === "dynamic" || (kind === "string" && value.trim() !== "");
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isAriaHidden(opening) {
|
|
35
|
+
const attr = findAttribute(opening, "aria-hidden");
|
|
36
|
+
if (!attr) return false;
|
|
37
|
+
const { kind, value } = attributeValueKind(attr.value);
|
|
38
|
+
return (
|
|
39
|
+
(kind === "string" && value === "true") ||
|
|
40
|
+
attr.value == null ||
|
|
41
|
+
(attr.value.type === "JSXExpressionContainer" &&
|
|
42
|
+
attr.value.expression.type === "Literal" &&
|
|
43
|
+
attr.value.expression.value === true)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// true when some descendant may provide the name: a letter-bearing JSXText,
|
|
48
|
+
// any expression container (its value is unknown statically), or an element
|
|
49
|
+
// carrying aria-label/aria-labelledby (the `asChild` link/anchor case).
|
|
50
|
+
function mayProvideName(children) {
|
|
51
|
+
return children.some((child) => {
|
|
52
|
+
if (child.type === "JSXText") return HAS_WORDS.test(child.value);
|
|
53
|
+
if (child.type === "JSXExpressionContainer") {
|
|
54
|
+
const e = child.expression;
|
|
55
|
+
if (e.type === "JSXEmptyExpression") return false;
|
|
56
|
+
// {null} / {false} / {true} / {undefined} render nothing; a string
|
|
57
|
+
// literal names only if it has letters. Anything dynamic is trusted.
|
|
58
|
+
if (e.type === "Identifier" && e.name === "undefined") return false;
|
|
59
|
+
if (e.type === "Literal") {
|
|
60
|
+
return typeof e.value === "string"
|
|
61
|
+
? HAS_WORDS.test(e.value)
|
|
62
|
+
: typeof e.value === "number";
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (child.type === "JSXElement") {
|
|
67
|
+
// An aria-hidden subtree is excluded from the accessible name.
|
|
68
|
+
if (isAriaHidden(child.openingElement)) return false;
|
|
69
|
+
return (
|
|
70
|
+
hasNameAttribute(child.openingElement) || mayProvideName(child.children)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (child.type === "JSXFragment") return mayProvideName(child.children);
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default {
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: {
|
|
82
|
+
description:
|
|
83
|
+
'A DS <Button size="icon…"> must have an accessible name (aria-label or aria-labelledby).',
|
|
84
|
+
},
|
|
85
|
+
messages: {
|
|
86
|
+
missingLabel:
|
|
87
|
+
'Icon-only <Button size="{{size}}"> has no accessible name. Add aria-label="…" (or use <IconButton label="…">, which also renders a tooltip).',
|
|
88
|
+
},
|
|
89
|
+
schema: [importSourcesSchema],
|
|
90
|
+
},
|
|
91
|
+
create(context) {
|
|
92
|
+
const ds = trackDsImports(context);
|
|
93
|
+
return {
|
|
94
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
95
|
+
JSXElement(node) {
|
|
96
|
+
const opening = node.openingElement;
|
|
97
|
+
if (ds.importedName(opening.name) !== "Button") return;
|
|
98
|
+
const size = staticString(findAttribute(opening, "size")?.value);
|
|
99
|
+
if (!size?.startsWith("icon")) return;
|
|
100
|
+
if (hasSpread(opening)) return;
|
|
101
|
+
if (hasNameAttribute(opening) || mayProvideName(node.children)) return;
|
|
102
|
+
context.report({
|
|
103
|
+
node: opening,
|
|
104
|
+
messageId: "missingLabel",
|
|
105
|
+
data: { size },
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Registry of the `local/*` convention rules this repo's own lint enables
|
|
2
|
+
// (eslint.config.js). Rules that also make sense in product repos are shipped,
|
|
3
|
+
// from the same implementation files, by the consumer plugin in
|
|
4
|
+
// eslint-plugin/index.js — that plugin (not this registry) is what product
|
|
5
|
+
// repos extend and what scripts/audit-conventions.mjs runs on agent output.
|
|
6
|
+
import buttonHasType from "./button-has-type.js";
|
|
7
|
+
import iconButtonNeedsLabel from "./icon-button-needs-label.js";
|
|
8
|
+
import requireBrowsableLink from "./require-browsable-link.js";
|
|
9
|
+
import noPrimitiveTailwindColors from "./no-primitive-tailwind-colors.js";
|
|
10
|
+
import noRawColorInClassName from "./no-raw-color-in-classname.js";
|
|
11
|
+
import noAdhocTypography from "./no-adhoc-typography.js";
|
|
12
|
+
import requireSharedSidebarProvider from "./require-shared-sidebar-provider.js";
|
|
13
|
+
import noHardcodedUserFacingString from "./no-hardcoded-user-facing-string.js";
|
|
14
|
+
|
|
15
|
+
export const rules = {
|
|
16
|
+
"button-has-type": buttonHasType,
|
|
17
|
+
"icon-button-needs-label": iconButtonNeedsLabel,
|
|
18
|
+
"require-browsable-link": requireBrowsableLink,
|
|
19
|
+
"no-primitive-tailwind-colors": noPrimitiveTailwindColors,
|
|
20
|
+
"no-raw-color-in-classname": noRawColorInClassName,
|
|
21
|
+
"no-adhoc-typography": noAdhocTypography,
|
|
22
|
+
"require-shared-sidebar-provider": requireSharedSidebarProvider,
|
|
23
|
+
"no-hardcoded-user-facing-string": noHardcodedUserFacingString,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ESLint flat-config plugin object. A SINGLE shared reference is required:
|
|
27
|
+
// flat config rejects redefining a plugin key with a different object when
|
|
28
|
+
// blocks' `files` overlap, so every config block must reuse this one object.
|
|
29
|
+
export const localPlugin = { rules };
|
|
30
|
+
|
|
31
|
+
export default localPlugin;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the link rules (require-browsable-link, prefer-link-props):
|
|
3
|
+
* decide whether a click handler visibly navigates, and find the DS
|
|
4
|
+
* "descriptor" objects (sidebar items, breadcrumb segments, …) that carry
|
|
5
|
+
* their own click/link props.
|
|
6
|
+
*
|
|
7
|
+
* "Navigates" = the handler (or a same-file function it calls, a few levels
|
|
8
|
+
* deep) calls `navigate(url)`, `router.push/replace`, `history.push/replace`
|
|
9
|
+
* (also nested: `props.history.push`, `store.router.push`),
|
|
10
|
+
* `location.assign/replace`, `window.open`, or assigns `location`/
|
|
11
|
+
* `location.href`. Not a destination a link could carry, so ignored:
|
|
12
|
+
* - `navigate(-1)` (history back);
|
|
13
|
+
* - navigation after an `await` in the same function (create, then open the
|
|
14
|
+
* new item — the URL doesn't exist before the click);
|
|
15
|
+
* - navigation inside a nested callback the handler only creates or passes
|
|
16
|
+
* on (`openConfirm({ onConfirm: () => navigate(url) })`, `.then(…)`).
|
|
17
|
+
*
|
|
18
|
+
* `navigationFunctions` adds app helpers: a bare name (`"goTo"`) matches
|
|
19
|
+
* `goTo(…)`; a dotted name (`"router.goTo"`) matches any `….router.goTo(…)`.
|
|
20
|
+
*/
|
|
21
|
+
export const navigationSchemaProperties = {
|
|
22
|
+
navigationFunctions: {
|
|
23
|
+
type: "array",
|
|
24
|
+
items: { type: "string" },
|
|
25
|
+
uniqueItems: true,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const NAV_OBJECTS = new Set(["router", "history", "navigation"]);
|
|
30
|
+
const NAV_METHODS = new Set(["push", "replace", "navigate"]);
|
|
31
|
+
const LOCATION_METHODS = new Set(["assign", "replace"]);
|
|
32
|
+
const MAX_DEPTH = 3;
|
|
33
|
+
const FUNCTION_TYPES = new Set([
|
|
34
|
+
"ArrowFunctionExpression",
|
|
35
|
+
"FunctionExpression",
|
|
36
|
+
"FunctionDeclaration",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function isLocation(node) {
|
|
40
|
+
if (node.type === "Identifier") return node.name === "location";
|
|
41
|
+
return (
|
|
42
|
+
node.type === "MemberExpression" &&
|
|
43
|
+
!node.computed &&
|
|
44
|
+
node.property.name === "location" &&
|
|
45
|
+
node.object.type === "Identifier" &&
|
|
46
|
+
(node.object.name === "window" || node.object.name === "document")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isHistoryBack(call) {
|
|
51
|
+
const arg = call.arguments[0];
|
|
52
|
+
return (
|
|
53
|
+
arg &&
|
|
54
|
+
((arg.type === "Literal" && typeof arg.value === "number") ||
|
|
55
|
+
(arg.type === "UnaryExpression" && arg.argument.type === "Literal"))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The navigating call's destination node, when it has one (`navigate(url)`).
|
|
60
|
+
function navigationTarget(node, navFns) {
|
|
61
|
+
if (node.type === "AssignmentExpression") {
|
|
62
|
+
const left = node.left;
|
|
63
|
+
if (isLocation(left)) return node;
|
|
64
|
+
if (
|
|
65
|
+
left.type === "MemberExpression" &&
|
|
66
|
+
!left.computed &&
|
|
67
|
+
left.property.name === "href" &&
|
|
68
|
+
isLocation(left.object)
|
|
69
|
+
) {
|
|
70
|
+
return node;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (node.type !== "CallExpression") return null;
|
|
75
|
+
const callee = node.callee;
|
|
76
|
+
if (callee.type === "Identifier") {
|
|
77
|
+
if (callee.name === "navigate" || navFns.has(callee.name)) {
|
|
78
|
+
return isHistoryBack(node) ? null : node;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (callee.type !== "MemberExpression" || callee.computed) return null;
|
|
83
|
+
const method = callee.property.name;
|
|
84
|
+
const obj = callee.object;
|
|
85
|
+
// Last segment of the receiver: `history` in both `history.push` and
|
|
86
|
+
// `props.history.push`, `router` in `store.router.goTo`.
|
|
87
|
+
const objName =
|
|
88
|
+
obj.type === "Identifier"
|
|
89
|
+
? obj.name
|
|
90
|
+
: obj.type === "MemberExpression" && !obj.computed
|
|
91
|
+
? obj.property.name
|
|
92
|
+
: null;
|
|
93
|
+
if (objName && navFns.has(`${objName}.${method}`)) {
|
|
94
|
+
return isHistoryBack(node) ? null : node;
|
|
95
|
+
}
|
|
96
|
+
if (objName && NAV_OBJECTS.has(objName)) {
|
|
97
|
+
return NAV_METHODS.has(method) && !isHistoryBack(node) ? node : null;
|
|
98
|
+
}
|
|
99
|
+
if (obj.type === "Identifier" && obj.name === "window" && method === "open") {
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
if (isLocation(obj) && LOCATION_METHODS.has(method)) return node;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// True when `node` navigates through a built-in navigator (`navigate`,
|
|
107
|
+
// `history`/`router` push/replace, `window.open`, `location`), whose first
|
|
108
|
+
// argument is a URL — unlike an app helper (`goTo(route, params)`).
|
|
109
|
+
export function isBuiltinNavigation(node) {
|
|
110
|
+
return navigationTarget(node, new Set()) === node;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function findVariableInit(context, identifier) {
|
|
114
|
+
let scope = context.sourceCode.getScope(identifier);
|
|
115
|
+
while (scope) {
|
|
116
|
+
const variable = scope.set.get(identifier.name);
|
|
117
|
+
if (variable) {
|
|
118
|
+
const def = variable.defs[0];
|
|
119
|
+
if (!def) return null;
|
|
120
|
+
if (def.node.type === "FunctionDeclaration") return def.node;
|
|
121
|
+
if (def.node.type === "VariableDeclarator") return def.node.init;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
scope = scope.upper;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// `useCallback(fn, deps)` / `useMemo(() => value, deps)` → their first arg.
|
|
130
|
+
function unwrapHook(node) {
|
|
131
|
+
if (
|
|
132
|
+
node?.type === "CallExpression" &&
|
|
133
|
+
node.callee.type === "Identifier" &&
|
|
134
|
+
(node.callee.name === "useCallback" || node.callee.name === "useMemo") &&
|
|
135
|
+
node.arguments[0]
|
|
136
|
+
) {
|
|
137
|
+
return node.arguments[0];
|
|
138
|
+
}
|
|
139
|
+
return node;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolves an expression to the function it denotes (inline, or a same-file
|
|
143
|
+
// identifier, optionally wrapped in useCallback), else null.
|
|
144
|
+
export function resolveFunction(context, expr) {
|
|
145
|
+
let node = expr;
|
|
146
|
+
if (node?.type === "Identifier") node = findVariableInit(context, node);
|
|
147
|
+
node = unwrapHook(node);
|
|
148
|
+
return node && FUNCTION_TYPES.has(node.type) ? node : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Depth-first walk. `visit` returns true to stop, "skip" to not descend into
|
|
152
|
+
// the node's children, anything else to continue.
|
|
153
|
+
function walk(node, visit) {
|
|
154
|
+
if (!node || typeof node.type !== "string") return false;
|
|
155
|
+
const verdict = visit(node);
|
|
156
|
+
if (verdict === true) return true;
|
|
157
|
+
if (verdict === "skip") return false;
|
|
158
|
+
for (const key of Object.keys(node)) {
|
|
159
|
+
if (key === "parent") continue;
|
|
160
|
+
const child = node[key];
|
|
161
|
+
if (Array.isArray(child)) {
|
|
162
|
+
if (child.some((c) => walk(c, visit))) return true;
|
|
163
|
+
} else if (child && typeof child.type === "string") {
|
|
164
|
+
if (walk(child, visit)) return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Returns the first navigating node reachable from `expr` (a handler
|
|
171
|
+
// expression), or null. Follows calls to same-file functions up to MAX_DEPTH.
|
|
172
|
+
export function findNavigation(context, expr, depth = 0, seen = new Set()) {
|
|
173
|
+
const fn = resolveFunction(context, expr);
|
|
174
|
+
if (!fn || seen.has(fn) || depth > MAX_DEPTH) return null;
|
|
175
|
+
seen.add(fn);
|
|
176
|
+
const navFns = new Set(context.options[0]?.navigationFunctions ?? []);
|
|
177
|
+
// Nested function bodies are deferred work (callbacks passed on, `.then`),
|
|
178
|
+
// not something this handler runs — skip them; called same-file functions
|
|
179
|
+
// are still followed through the Identifier-callee branch below.
|
|
180
|
+
const skipNested = (node) =>
|
|
181
|
+
node !== fn.body && FUNCTION_TYPES.has(node.type);
|
|
182
|
+
const awaitEnds = [];
|
|
183
|
+
walk(fn.body, (node) => {
|
|
184
|
+
if (skipNested(node)) return "skip";
|
|
185
|
+
if (node.type === "AwaitExpression") awaitEnds.push(node.range[1]);
|
|
186
|
+
return false;
|
|
187
|
+
});
|
|
188
|
+
let found = null;
|
|
189
|
+
walk(fn.body, (node) => {
|
|
190
|
+
if (skipNested(node)) return "skip";
|
|
191
|
+
const target = navigationTarget(node, navFns);
|
|
192
|
+
if (target) {
|
|
193
|
+
if (awaitEnds.some((end) => end <= target.range[0])) return false;
|
|
194
|
+
found = target;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier") {
|
|
198
|
+
found = findNavigation(context, node.callee, depth + 1, seen);
|
|
199
|
+
return Boolean(found);
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
});
|
|
203
|
+
return found;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Navigation found through a JSX attribute's handler, or null.
|
|
207
|
+
export function attributeNavigation(context, attr) {
|
|
208
|
+
const value = attr?.value;
|
|
209
|
+
if (value?.type !== "JSXExpressionContainer") return null;
|
|
210
|
+
return findNavigation(context, value.expression);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// DS components whose props take descriptor objects carrying their own
|
|
214
|
+
// `onClick`/`navigate` + `linkProps`/`href` (sidebar items, breadcrumb
|
|
215
|
+
// segments and switcher entries, notifications).
|
|
216
|
+
const DESCRIPTOR_COMPONENTS = new Set([
|
|
217
|
+
"AppSidebar",
|
|
218
|
+
"NavBreadcrumb",
|
|
219
|
+
"NavBreadcrumbOverflow",
|
|
220
|
+
"NavBreadcrumbSwitcher",
|
|
221
|
+
"NavSwitcher",
|
|
222
|
+
"NotificationBell",
|
|
223
|
+
"NotificationsPanel",
|
|
224
|
+
]);
|
|
225
|
+
const DESCRIPTOR_FACTORY = /^create[A-Za-z]*Descriptor$/;
|
|
226
|
+
const DESCRIPTOR_CLICK_PROPS = ["onClick", "navigate"];
|
|
227
|
+
const DESCRIPTOR_LINK_PROPS = new Set(["linkProps", "href", "to"]);
|
|
228
|
+
// Descriptor fields holding objects with a click but no link API
|
|
229
|
+
// (`BreadcrumbAction` under `actions`) — never descriptors to lint.
|
|
230
|
+
const NON_LINK_FIELDS = new Set(["actions"]);
|
|
231
|
+
|
|
232
|
+
function propertyName(prop) {
|
|
233
|
+
if (prop.type !== "Property" || prop.computed) return null;
|
|
234
|
+
if (prop.key.type === "Identifier") return prop.key.name;
|
|
235
|
+
return prop.key.type === "Literal" ? String(prop.key.value) : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Visits every object literal reachable from `expr` (arrays, nested objects,
|
|
239
|
+
// `.map(() => ({…}))` callbacks, a same-file `const items = […]`, useMemo).
|
|
240
|
+
// Calls onDescriptor(object, clickProperty) for objects that have a click
|
|
241
|
+
// prop and no link prop; spreads count as "may carry the link prop".
|
|
242
|
+
export function forEachLinklessDescriptor(context, expr, onDescriptor) {
|
|
243
|
+
let root = expr;
|
|
244
|
+
if (root?.type === "Identifier") root = findVariableInit(context, root);
|
|
245
|
+
root = unwrapHook(root);
|
|
246
|
+
if (
|
|
247
|
+
root &&
|
|
248
|
+
FUNCTION_TYPES.has(root.type) &&
|
|
249
|
+
root.body.type !== "BlockStatement"
|
|
250
|
+
) {
|
|
251
|
+
root = root.body;
|
|
252
|
+
}
|
|
253
|
+
walk(root, (node) => {
|
|
254
|
+
if (node.type === "Property" && NON_LINK_FIELDS.has(propertyName(node))) {
|
|
255
|
+
return "skip";
|
|
256
|
+
}
|
|
257
|
+
if (node.type !== "ObjectExpression") return false;
|
|
258
|
+
if (node.properties.some((p) => p.type === "SpreadElement")) return false;
|
|
259
|
+
// A link field set to null/false/undefined carries no link.
|
|
260
|
+
const hasLink = node.properties.some(
|
|
261
|
+
(p) =>
|
|
262
|
+
DESCRIPTOR_LINK_PROPS.has(propertyName(p)) &&
|
|
263
|
+
!(p.value.type === "Identifier" && p.value.name === "undefined") &&
|
|
264
|
+
!(
|
|
265
|
+
p.value.type === "Literal" &&
|
|
266
|
+
(p.value.value === null || p.value.value === false)
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
if (hasLink) return false;
|
|
270
|
+
const click = node.properties.find((p) =>
|
|
271
|
+
DESCRIPTOR_CLICK_PROPS.includes(propertyName(p)),
|
|
272
|
+
);
|
|
273
|
+
if (click) onDescriptor(node, click);
|
|
274
|
+
return false;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Every descriptor root in a JSX element / factory call from a DS module.
|
|
279
|
+
export function descriptorRoots(ds, node) {
|
|
280
|
+
if (node.type === "JSXOpeningElement") {
|
|
281
|
+
if (!DESCRIPTOR_COMPONENTS.has(ds.importedName(node.name) ?? "")) return [];
|
|
282
|
+
return node.attributes
|
|
283
|
+
.filter(
|
|
284
|
+
(a) =>
|
|
285
|
+
a.type === "JSXAttribute" &&
|
|
286
|
+
a.value?.type === "JSXExpressionContainer",
|
|
287
|
+
)
|
|
288
|
+
.map((a) => a.value.expression);
|
|
289
|
+
}
|
|
290
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier") {
|
|
291
|
+
const imported = ds.importedCallee(node.callee.name);
|
|
292
|
+
return imported && DESCRIPTOR_FACTORY.test(imported) ? node.arguments : [];
|
|
293
|
+
}
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags ad-hoc typography that should use the DS <Heading>/<Text> primitives.
|
|
3
|
+
* Moderate scope: bare <h1>-<h6>, Carbon Heading/Section imports, and elements
|
|
4
|
+
* whose className string literal combines a font-size AND a font-weight utility.
|
|
5
|
+
* Only string-literal classNames are inspected (cn()/template-literals/cva not).
|
|
6
|
+
*/
|
|
7
|
+
import { classLiteral } from "./class-literal.js";
|
|
8
|
+
|
|
9
|
+
const SIZE = /\btext-(xs|sm|base|lg|xl|[2-9]xl|\[[^\]]+\])\b/;
|
|
10
|
+
const WEIGHT =
|
|
11
|
+
/\bfont-(thin|extralight|light|normal|medium|semibold|bold|extrabold|black|\[\d+\])\b/;
|
|
12
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
13
|
+
const PRIMITIVE_NAMES = new Set(["Heading", "Text"]);
|
|
14
|
+
const SVG_TEXT_ELEMENTS = new Set(["text", "tspan", "textPath"]);
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
meta: {
|
|
18
|
+
type: "suggestion",
|
|
19
|
+
docs: {
|
|
20
|
+
description:
|
|
21
|
+
"Use the DS Heading/Text primitives instead of bare headings or ad-hoc type-scale classes.",
|
|
22
|
+
},
|
|
23
|
+
messages: {
|
|
24
|
+
bareHeading:
|
|
25
|
+
'Use <Heading as="{{tag}}" variant="…"> instead of a bare <{{tag}}>.',
|
|
26
|
+
carbonImport:
|
|
27
|
+
"Use the DS <Heading> primitive; drop <Section>/<HeadingLevel> and set the level via `as`.",
|
|
28
|
+
adhocClasses:
|
|
29
|
+
"Use a <Heading>/<Text> variant instead of hand-written type-scale classes (font-size + font-weight combo).",
|
|
30
|
+
},
|
|
31
|
+
schema: [],
|
|
32
|
+
},
|
|
33
|
+
create(context) {
|
|
34
|
+
return {
|
|
35
|
+
JSXOpeningElement(node) {
|
|
36
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
37
|
+
const name = node.name.name;
|
|
38
|
+
if (HEADING_TAGS.has(name)) {
|
|
39
|
+
context.report({
|
|
40
|
+
node,
|
|
41
|
+
messageId: "bareHeading",
|
|
42
|
+
data: { tag: name },
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (PRIMITIVE_NAMES.has(name)) return;
|
|
47
|
+
// Ad-hoc type-scale only matters on raw HTML host elements. Capitalized React
|
|
48
|
+
// components (DS *Title composites that consume typographyVariants, and consumer
|
|
49
|
+
// components) own their className semantics — an override is deliberate, like the
|
|
50
|
+
// Heading/Text allowance. SVG text nodes can't host Heading/Text.
|
|
51
|
+
if (!/^[a-z]/.test(name) || SVG_TEXT_ELEMENTS.has(name)) return;
|
|
52
|
+
const classAttr = node.attributes.find(
|
|
53
|
+
(a) => a.type === "JSXAttribute" && a.name.name === "className",
|
|
54
|
+
);
|
|
55
|
+
if (!classAttr) return;
|
|
56
|
+
const raw = classLiteral(classAttr.value);
|
|
57
|
+
if (raw && SIZE.test(raw) && WEIGHT.test(raw)) {
|
|
58
|
+
// Anchor to the opening element (like `bareHeading`) so the audit
|
|
59
|
+
// gate prints a consistent line:col for both heading findings.
|
|
60
|
+
context.report({ node, messageId: "adhocClasses" });
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
ImportDeclaration(node) {
|
|
64
|
+
if (node.source.value !== "@carbon/react") return;
|
|
65
|
+
for (const spec of node.specifiers) {
|
|
66
|
+
if (
|
|
67
|
+
spec.type === "ImportSpecifier" &&
|
|
68
|
+
(spec.imported.name === "Heading" ||
|
|
69
|
+
spec.imported.name === "Section")
|
|
70
|
+
) {
|
|
71
|
+
context.report({ node: spec, messageId: "carbonImport" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags imports of `@camunda/design-system/<subpath>` where the subpath is not
|
|
3
|
+
* a public entry point (e.g. `/dist/src/icons`, `/src/components/ui/button`).
|
|
4
|
+
* Deep paths are internal layout and break on any refactor.
|
|
5
|
+
*
|
|
6
|
+
* The allowed subpaths are read from this package's own `exports` map at load
|
|
7
|
+
* time, so a new public entry point is allowed without touching this rule.
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
|
|
11
|
+
const PACKAGE = "@camunda/design-system";
|
|
12
|
+
const pkg = createRequire(import.meta.url)("../package.json");
|
|
13
|
+
const PUBLIC_SUBPATHS = new Set(
|
|
14
|
+
Object.keys(pkg.exports)
|
|
15
|
+
.filter((k) => k !== ".")
|
|
16
|
+
.map((k) => k.slice(2)),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
function publicList() {
|
|
20
|
+
return [PACKAGE, ...[...PUBLIC_SUBPATHS].map((s) => `${PACKAGE}/${s}`)]
|
|
21
|
+
.map((s) => `"${s}"`)
|
|
22
|
+
.join(", ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
meta: {
|
|
27
|
+
type: "problem",
|
|
28
|
+
docs: {
|
|
29
|
+
description: `Import ${PACKAGE} only through its public entry points.`,
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
deepImport: `"{{source}}" is not a public entry point of ${PACKAGE}. Import from one of: {{allowed}}.`,
|
|
33
|
+
},
|
|
34
|
+
schema: [],
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
function checkSource(node) {
|
|
38
|
+
const source = node.source?.value;
|
|
39
|
+
if (typeof source !== "string" || !source.startsWith(`${PACKAGE}/`)) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (PUBLIC_SUBPATHS.has(source.slice(PACKAGE.length + 1))) return;
|
|
43
|
+
context.report({
|
|
44
|
+
node: node.source,
|
|
45
|
+
messageId: "deepImport",
|
|
46
|
+
data: { source, allowed: publicList() },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
ImportDeclaration: checkSource,
|
|
52
|
+
ExportNamedDeclaration: checkSource,
|
|
53
|
+
ExportAllDeclaration: checkSource,
|
|
54
|
+
ImportExpression(node) {
|
|
55
|
+
if (node.source.type === "Literal") checkSource(node);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags any import or re-export sourced directly from "lucide-react" (or one of
|
|
3
|
+
* its subpaths). Glyphs must flow through the DS icon proxy instead, so the
|
|
4
|
+
* concrete glyph behind a name can be swapped centrally.
|
|
5
|
+
*
|
|
6
|
+
* Two variants share one implementation and differ only in the proxy path:
|
|
7
|
+
* - default export: DS repo source, proxy is the internal "icons/lucide"
|
|
8
|
+
* alias. The proxy module itself (src/icons/lucide.ts) is exempted via an
|
|
9
|
+
* eslint.config.js block, not via logic in this rule.
|
|
10
|
+
* - `consumerNoDirectLucideImport`: product repos, proxy is the public
|
|
11
|
+
* "@camunda/design-system/icons" subpath. Shipped in the ESLint plugin.
|
|
12
|
+
*
|
|
13
|
+
* Autofix rewrites a bare "lucide-react" source to the proxy path — safe
|
|
14
|
+
* because the proxy re-exports the whole lucide-react surface (`export *`).
|
|
15
|
+
* Subpath sources ("lucide-react/dynamic", …) are reported but not fixed: the
|
|
16
|
+
* proxy only mirrors the package root.
|
|
17
|
+
*/
|
|
18
|
+
function createNoDirectLucideImport(proxyPath) {
|
|
19
|
+
return {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description: `Import Lucide glyphs from the DS icon proxy (${proxyPath}), not lucide-react directly.`,
|
|
24
|
+
},
|
|
25
|
+
fixable: "code",
|
|
26
|
+
messages: {
|
|
27
|
+
noDirectLucideImport: `Import glyphs from the DS icon proxy ("${proxyPath}"), not lucide-react directly.`,
|
|
28
|
+
},
|
|
29
|
+
schema: [],
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
function checkSource(node) {
|
|
33
|
+
const source = node.source?.value;
|
|
34
|
+
if (typeof source !== "string") return;
|
|
35
|
+
if (source !== "lucide-react" && !source.startsWith("lucide-react/")) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
context.report({
|
|
39
|
+
node,
|
|
40
|
+
messageId: "noDirectLucideImport",
|
|
41
|
+
fix:
|
|
42
|
+
source === "lucide-react"
|
|
43
|
+
? (fixer) => {
|
|
44
|
+
const quote = node.source.raw[0];
|
|
45
|
+
return fixer.replaceText(
|
|
46
|
+
node.source,
|
|
47
|
+
`${quote}${proxyPath}${quote}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
: null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ImportDeclaration: checkSource,
|
|
56
|
+
ExportNamedDeclaration: checkSource,
|
|
57
|
+
ExportAllDeclaration: checkSource,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const consumerNoDirectLucideImport = createNoDirectLucideImport(
|
|
64
|
+
"@camunda/design-system/icons",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export default createNoDirectLucideImport("icons/lucide");
|