@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags a string literal passed to an i18n-overridable prop of a DS component
|
|
3
|
+
* (`<Dialog closeLabel="Close" />`). Every DS component already renders its
|
|
4
|
+
* own English default from the i18n dictionary; a literal there only restates
|
|
5
|
+
* it and blocks a future translation. Route the consumer's own translated
|
|
6
|
+
* value through the prop, or leave it unset.
|
|
7
|
+
*
|
|
8
|
+
* "Overridable" is matched by naming convention — the DS names every override
|
|
9
|
+
* prop `…Label`, `…LabelText`, `…Message`, `…Prefix` or `searchPlaceholder`
|
|
10
|
+
* (kept in sync with DS source by no-hardcoded-i18n-prop.test.mjs). Bare
|
|
11
|
+
* `label`/`title`/`placeholder` are deliberately excluded: on most components
|
|
12
|
+
* they carry caller content, not a DS default. So are the few convention-named
|
|
13
|
+
* content props listed in CONTENT_PROPS.
|
|
14
|
+
*
|
|
15
|
+
* Heuristic, so shipped at `warn`: a caller-specific message
|
|
16
|
+
* (`emptyMessage="No workspaces yet"`) is legitimate in an app without i18n.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
importSourcesSchema,
|
|
20
|
+
staticString,
|
|
21
|
+
trackDsImports,
|
|
22
|
+
} from "./ds-imports.js";
|
|
23
|
+
|
|
24
|
+
const OVERRIDE_PROP = /^[a-z][A-Za-z]*(Label|LabelText|Message|Prefix)$/;
|
|
25
|
+
const EXTRA_OVERRIDE_PROPS = new Set(["searchPlaceholder"]);
|
|
26
|
+
// Match the naming convention but carry caller content with no DS default.
|
|
27
|
+
const CONTENT_PROPS = new Set([
|
|
28
|
+
"centerLabel", // PieChart center text
|
|
29
|
+
"ctaLabel", // MultiStepDialog visual CTA
|
|
30
|
+
"secondaryLabel", // carbon-compat Tabs
|
|
31
|
+
"rangeLabel", // carbon-compat DatePicker field labels
|
|
32
|
+
"startLabel",
|
|
33
|
+
"endLabel",
|
|
34
|
+
]);
|
|
35
|
+
const HAS_WORDS = /\p{L}{2,}/u;
|
|
36
|
+
|
|
37
|
+
export function isOverridableProp(name) {
|
|
38
|
+
if (CONTENT_PROPS.has(name)) return false;
|
|
39
|
+
return OVERRIDE_PROP.test(name) || EXTRA_OVERRIDE_PROPS.has(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
meta: {
|
|
44
|
+
type: "suggestion",
|
|
45
|
+
docs: {
|
|
46
|
+
description:
|
|
47
|
+
"Don't hardcode string literals on DS i18n-overridable props (closeLabel, emptyMessage, …); pass a translated value or leave the DS default.",
|
|
48
|
+
heuristic: true,
|
|
49
|
+
},
|
|
50
|
+
messages: {
|
|
51
|
+
hardcoded:
|
|
52
|
+
'`{{prop}}="{{value}}"` hardcodes copy on a DS i18n-overridable prop. Pass your app\'s translated string instead, or omit the prop to fall back to the DS default where one exists.',
|
|
53
|
+
},
|
|
54
|
+
schema: [importSourcesSchema],
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const ds = trackDsImports(context);
|
|
58
|
+
return {
|
|
59
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
60
|
+
JSXOpeningElement(node) {
|
|
61
|
+
if (!ds.importedName(node.name)) return;
|
|
62
|
+
for (const attr of node.attributes) {
|
|
63
|
+
if (attr.type !== "JSXAttribute") continue;
|
|
64
|
+
if (attr.name.type !== "JSXIdentifier") continue;
|
|
65
|
+
const prop = attr.name.name;
|
|
66
|
+
if (!isOverridableProp(prop)) continue;
|
|
67
|
+
const value = staticString(attr.value);
|
|
68
|
+
if (value === null || !HAS_WORDS.test(value)) continue;
|
|
69
|
+
context.report({
|
|
70
|
+
node: attr,
|
|
71
|
+
messageId: "hardcoded",
|
|
72
|
+
data: { prop, value },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags hardcoded English copy that should be routed through the i18n
|
|
3
|
+
* dictionary (`useC4Dictionary()` / `t("key")`, see src/lib/i18n/dictionary.ts)
|
|
4
|
+
* instead of a raw string literal.
|
|
5
|
+
*
|
|
6
|
+
* Scope is narrow and precise, not exhaustive:
|
|
7
|
+
* - JSX text content — a component rendering its own static prose.
|
|
8
|
+
* - String-literal values on `aria-label` / `title` / `placeholder` / `alt`
|
|
9
|
+
* — the four attributes whose native HTML/ARIA semantics guarantee the
|
|
10
|
+
* value is always user-facing text, so no attribute-name allowlist is
|
|
11
|
+
* needed to avoid false positives on structural props (`variant`,
|
|
12
|
+
* `type`, `size`, `data-*`, ...).
|
|
13
|
+
*
|
|
14
|
+
* A value wrapped in a JSXExpressionContainer around anything other than a
|
|
15
|
+
* bare string Literal (a `t("key")` call, a prop, a variable, a ternary) is
|
|
16
|
+
* never flagged. Whitespace-only JSX text and text with no letters (glyphs,
|
|
17
|
+
* punctuation, numbers) are not flagged either — neither needs translation.
|
|
18
|
+
*/
|
|
19
|
+
const WATCHED_ATTRIBUTES = new Set([
|
|
20
|
+
"aria-label",
|
|
21
|
+
"title",
|
|
22
|
+
"placeholder",
|
|
23
|
+
"alt",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// At least two letters — excludes whitespace-only text and bare punctuation/
|
|
27
|
+
// glyphs/numbers ("…", "·", "42", "100%"), none of which need translation.
|
|
28
|
+
const HAS_WORDS = /[A-Za-z]{2,}/;
|
|
29
|
+
|
|
30
|
+
function isHardcodedLiteral(value) {
|
|
31
|
+
return typeof value === "string" && HAS_WORDS.test(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
docs: {
|
|
38
|
+
description:
|
|
39
|
+
"Route user-facing copy through the i18n dictionary (useC4Dictionary()/t()) instead of a hardcoded string literal.",
|
|
40
|
+
},
|
|
41
|
+
messages: {
|
|
42
|
+
hardcodedText:
|
|
43
|
+
'Hardcoded text content — add a key to src/lib/i18n/dictionary.ts and render it via t("key") instead.',
|
|
44
|
+
hardcodedAttribute:
|
|
45
|
+
'Hardcoded "{{attr}}" string — add a key to src/lib/i18n/dictionary.ts and pass t("key") instead.',
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
create(context) {
|
|
50
|
+
return {
|
|
51
|
+
JSXText(node) {
|
|
52
|
+
if (!isHardcodedLiteral(node.value)) return;
|
|
53
|
+
context.report({ node, messageId: "hardcodedText" });
|
|
54
|
+
},
|
|
55
|
+
JSXAttribute(node) {
|
|
56
|
+
if (node.name.type !== "JSXIdentifier") return;
|
|
57
|
+
if (!WATCHED_ATTRIBUTES.has(node.name.name)) return;
|
|
58
|
+
const value = node.value;
|
|
59
|
+
if (!value) return;
|
|
60
|
+
if (value.type === "Literal" && isHardcodedLiteral(value.value)) {
|
|
61
|
+
context.report({
|
|
62
|
+
node: value,
|
|
63
|
+
messageId: "hardcodedAttribute",
|
|
64
|
+
data: { attr: node.name.name },
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
value.type === "JSXExpressionContainer" &&
|
|
70
|
+
value.expression.type === "Literal" &&
|
|
71
|
+
isHardcodedLiteral(value.expression.value)
|
|
72
|
+
) {
|
|
73
|
+
context.report({
|
|
74
|
+
node: value.expression,
|
|
75
|
+
messageId: "hardcodedAttribute",
|
|
76
|
+
data: { attr: node.name.name },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags primitive Tailwind color utilities (bg-blue-500, text-gray-100, …) in a
|
|
3
|
+
* className. DS components must use semantic tokens (bg-primary,
|
|
4
|
+
* text-neutral-foreground-subtle, …) so theming/dark-mode stay centralized.
|
|
5
|
+
* Only string-literal classNames are inspected (cn()/template-literals not).
|
|
6
|
+
*/
|
|
7
|
+
import { classLiteral } from "./class-literal.js";
|
|
8
|
+
|
|
9
|
+
const PRIMITIVE_TW_PATTERN =
|
|
10
|
+
/\b(bg|text|border|ring|fill|stroke|outline|shadow|placeholder|accent|caret|divide)-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
meta: {
|
|
14
|
+
type: "suggestion",
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
"Use semantic color tokens instead of primitive Tailwind color classes.",
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
primitiveColor:
|
|
21
|
+
"Primitive Tailwind color class forbidden. Use semantic tokens (bg-primary, text-neutral-foreground-subtle, …).",
|
|
22
|
+
},
|
|
23
|
+
schema: [],
|
|
24
|
+
},
|
|
25
|
+
create(context) {
|
|
26
|
+
return {
|
|
27
|
+
JSXAttribute(node) {
|
|
28
|
+
if (node.name.name !== "className") return;
|
|
29
|
+
const raw = classLiteral(node.value);
|
|
30
|
+
if (raw && PRIMITIVE_TW_PATTERN.test(raw)) {
|
|
31
|
+
context.report({ node, messageId: "primitiveColor" });
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flags raw hex / rgb() color literals in a className (#ff0000, rgba(0,0,0,.5)).
|
|
3
|
+
* DS components must use semantic tokens from src/index.css so theming/dark-mode
|
|
4
|
+
* stay centralized. Only string-literal classNames are inspected.
|
|
5
|
+
*/
|
|
6
|
+
import { classLiteral } from "./class-literal.js";
|
|
7
|
+
|
|
8
|
+
const HEX_RGB_PATTERN = /#[0-9a-fA-F]{3,8}\b|rgba?\s*\(/;
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
meta: {
|
|
12
|
+
type: "suggestion",
|
|
13
|
+
docs: {
|
|
14
|
+
description:
|
|
15
|
+
"Use semantic color tokens instead of raw hex/rgb() in className.",
|
|
16
|
+
},
|
|
17
|
+
messages: {
|
|
18
|
+
rawColor:
|
|
19
|
+
"Raw hex/rgb in className forbidden. Use semantic tokens from src/index.css.",
|
|
20
|
+
},
|
|
21
|
+
schema: [],
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
return {
|
|
25
|
+
JSXAttribute(node) {
|
|
26
|
+
if (node.name.name !== "className") return;
|
|
27
|
+
const raw = classLiteral(node.value);
|
|
28
|
+
if (raw && HEX_RGB_PATTERN.test(raw)) {
|
|
29
|
+
context.report({ node, messageId: "rawColor" });
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft companion to `require-browsable-link`: flags DS components wired with a
|
|
3
|
+
* click prop but no link prop when the handler's intent is NOT visible (a prop,
|
|
4
|
+
* an imported function, a callback from context). Covers `DataTable`
|
|
5
|
+
* (`onRowClick` / `rowHref`), `NavBreadcrumbItem` (`onClick` / `linkProps`) and
|
|
6
|
+
* DS descriptor objects (sidebar items, breadcrumb segments, switcher entries,
|
|
7
|
+
* notifications: `onClick`|`navigate` / `linkProps`|`href`). Without the link
|
|
8
|
+
* prop the target renders as a `<button>` — no hoverable href, no
|
|
9
|
+
* Cmd/middle-click, no "open in new tab".
|
|
10
|
+
*
|
|
11
|
+
* When the handler visibly navigates, `require-browsable-link` reports it (as
|
|
12
|
+
* an error) and this rule stays silent, so a site is never reported twice.
|
|
13
|
+
* Heuristic, so shipped at `warn`: the click may legitimately not navigate
|
|
14
|
+
* (open a panel, select a row). Disable per line when that is the case.
|
|
15
|
+
* Elements with a spread are skipped — the link prop may be in it.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
findAttribute,
|
|
19
|
+
hasActiveAttribute,
|
|
20
|
+
hasSpread,
|
|
21
|
+
trackDsImports,
|
|
22
|
+
} from "./ds-imports.js";
|
|
23
|
+
import {
|
|
24
|
+
attributeNavigation,
|
|
25
|
+
descriptorRoots,
|
|
26
|
+
findNavigation,
|
|
27
|
+
forEachLinklessDescriptor,
|
|
28
|
+
navigationSchemaProperties,
|
|
29
|
+
} from "./navigation.js";
|
|
30
|
+
|
|
31
|
+
// component -> [click prop, link prop]
|
|
32
|
+
const PAIRS = {
|
|
33
|
+
DataTable: ["onRowClick", "rowHref"],
|
|
34
|
+
NavBreadcrumbItem: ["onClick", "linkProps"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
meta: {
|
|
39
|
+
type: "suggestion",
|
|
40
|
+
docs: {
|
|
41
|
+
description:
|
|
42
|
+
"Prefer the link prop (rowHref / linkProps / href) over click-only handlers on DS components that support both.",
|
|
43
|
+
heuristic: true,
|
|
44
|
+
},
|
|
45
|
+
messages: {
|
|
46
|
+
preferLink:
|
|
47
|
+
"<{{component}}> has `{{click}}` but no `{{link}}`. If this navigates, pass `{{link}}` so it renders a real link (href, Cmd/middle-click, open in new tab), and remove the navigation from `{{click}}`.",
|
|
48
|
+
preferDescriptorLink:
|
|
49
|
+
"This item has `{{click}}` but no `linkProps`/`href`. If it navigates, add the link prop so it renders a real link, and remove the navigation from `{{click}}`.",
|
|
50
|
+
},
|
|
51
|
+
schema: [
|
|
52
|
+
{
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
importSources: {
|
|
56
|
+
type: "array",
|
|
57
|
+
items: { type: "string" },
|
|
58
|
+
minItems: 1,
|
|
59
|
+
uniqueItems: true,
|
|
60
|
+
},
|
|
61
|
+
...navigationSchemaProperties,
|
|
62
|
+
},
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const ds = trackDsImports(context);
|
|
69
|
+
const reported = new Set();
|
|
70
|
+
|
|
71
|
+
function checkDescriptors(node) {
|
|
72
|
+
for (const root of descriptorRoots(ds, node)) {
|
|
73
|
+
forEachLinklessDescriptor(context, root, (_obj, click) => {
|
|
74
|
+
if (reported.has(click)) return;
|
|
75
|
+
reported.add(click);
|
|
76
|
+
if (findNavigation(context, click.value)) return;
|
|
77
|
+
context.report({
|
|
78
|
+
node: click,
|
|
79
|
+
messageId: "preferDescriptorLink",
|
|
80
|
+
data: { click: click.key.name ?? click.key.value },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
88
|
+
JSXOpeningElement(node) {
|
|
89
|
+
checkDescriptors(node);
|
|
90
|
+
const component = ds.importedName(node.name);
|
|
91
|
+
const pair = component && PAIRS[component];
|
|
92
|
+
if (!pair) return;
|
|
93
|
+
const [click, link] = pair;
|
|
94
|
+
const clickAttr = findAttribute(node, click);
|
|
95
|
+
if (!clickAttr) return;
|
|
96
|
+
if (hasActiveAttribute(node, link) || hasSpread(node)) return;
|
|
97
|
+
if (attributeNavigation(context, clickAttr)) return;
|
|
98
|
+
context.report({
|
|
99
|
+
node,
|
|
100
|
+
messageId: "preferLink",
|
|
101
|
+
data: { component, click, link },
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
CallExpression: checkDescriptors,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requires real, browsable links (href — hover, Cmd/middle-click, "open in new
|
|
3
|
+
* tab", announced as a link) on the elements where navigation is expected:
|
|
4
|
+
* links in text, navigation items, and tables. Anything else (a generic
|
|
5
|
+
* `<div>`, `Card`, plain `Button`) is never checked.
|
|
6
|
+
*
|
|
7
|
+
* Flags, with no guessing:
|
|
8
|
+
* - links with no destination: `<a onClick>` without `href`; DS `Link` /
|
|
9
|
+
* `BreadcrumbLink` without `href` or `asChild`.
|
|
10
|
+
* - scoped elements whose click handler visibly navigates (see
|
|
11
|
+
* navigation.js): DS `Button variant="link"`, `NavBreadcrumbItem` without
|
|
12
|
+
* `linkProps`, `DropdownMenuItem` without `asChild`, `DataTable` without
|
|
13
|
+
* `rowHref`, `TableRow`/`TableCell`, and DS descriptor objects (sidebar
|
|
14
|
+
* items, breadcrumb segments, switcher entries, notifications) without
|
|
15
|
+
* `linkProps`/`href`.
|
|
16
|
+
*
|
|
17
|
+
* The unclear case — a click prop with no link prop whose intent isn't
|
|
18
|
+
* visible — belongs to `prefer-link-props` (warn), not here.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
findAttribute,
|
|
22
|
+
hasActiveAttribute,
|
|
23
|
+
hasSpread,
|
|
24
|
+
staticString,
|
|
25
|
+
trackDsImports,
|
|
26
|
+
} from "./ds-imports.js";
|
|
27
|
+
import {
|
|
28
|
+
attributeNavigation,
|
|
29
|
+
descriptorRoots,
|
|
30
|
+
findNavigation,
|
|
31
|
+
forEachLinklessDescriptor,
|
|
32
|
+
isBuiltinNavigation,
|
|
33
|
+
navigationSchemaProperties,
|
|
34
|
+
resolveFunction,
|
|
35
|
+
} from "./navigation.js";
|
|
36
|
+
|
|
37
|
+
const LINK_COMPONENTS = new Set(["Link", "BreadcrumbLink"]);
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
meta: {
|
|
41
|
+
type: "problem",
|
|
42
|
+
docs: {
|
|
43
|
+
description:
|
|
44
|
+
"Links in text, navigation items and tables must render real links (href), not click-only elements.",
|
|
45
|
+
},
|
|
46
|
+
hasSuggestions: true,
|
|
47
|
+
messages: {
|
|
48
|
+
anchorWithoutHref:
|
|
49
|
+
'<a> with `onClick` but no `href` is not a link: no hover URL, no Cmd/middle-click, no open in new tab. Add `href` (or use a <button type="button"> if it doesn\'t navigate).',
|
|
50
|
+
linkWithoutHref:
|
|
51
|
+
"<{{component}}> has no `href` and no `asChild`, so it goes nowhere. Pass `href`, or `asChild` around your router's link. If it doesn't navigate (select all, expand, …), use <Button variant=\"link\"> instead.",
|
|
52
|
+
buttonLinkNavigates:
|
|
53
|
+
'<Button variant="link"> navigates from `onClick`. Use the DS <Link href> (or <Link asChild> around your router\'s link) so it is a real link.',
|
|
54
|
+
menuItemNavigates:
|
|
55
|
+
"<DropdownMenuItem> navigates from `{{click}}`. Use `asChild` around a link (`<DropdownMenuItem asChild><Link to=…>`) so the entry is a real link.",
|
|
56
|
+
tableNavigates:
|
|
57
|
+
"<{{component}}> navigates from `onClick`. Render the destination as a link in the row's primary cell (or use DataTable `rowHref`).",
|
|
58
|
+
missingLinkProp:
|
|
59
|
+
"<{{component}}> navigates from `{{click}}` but has no `{{link}}`, so it renders a button. Pass `{{link}}`; the link navigates on its own, so remove the navigation from `{{click}}` (keep it only for side effects such as analytics).",
|
|
60
|
+
descriptorNavigates:
|
|
61
|
+
"This item's `{{click}}` navigates but it has no `linkProps`/`href`, so it renders a button. Add the link prop; the link navigates on its own, so remove the navigation from `{{click}}` (keep it only for side effects such as analytics).",
|
|
62
|
+
suggestRowHref:
|
|
63
|
+
"Replace `onRowClick` with `rowHref` pointing at the same destination.",
|
|
64
|
+
},
|
|
65
|
+
schema: [
|
|
66
|
+
{
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
importSources: {
|
|
70
|
+
type: "array",
|
|
71
|
+
items: { type: "string" },
|
|
72
|
+
minItems: 1,
|
|
73
|
+
uniqueItems: true,
|
|
74
|
+
},
|
|
75
|
+
...navigationSchemaProperties,
|
|
76
|
+
},
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
create(context) {
|
|
82
|
+
const ds = trackDsImports(context);
|
|
83
|
+
const source = context.sourceCode;
|
|
84
|
+
|
|
85
|
+
function report(node, messageId, data, suggest) {
|
|
86
|
+
context.report({ node, messageId, data, suggest });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Suggestion for `onRowClick={(row) => navigate(url(row))}`: replace it
|
|
90
|
+
// with `rowHref={(row) => url(row)}`. DataTable's anchor still calls
|
|
91
|
+
// onRowClick, so keeping a navigating handler would navigate twice — the
|
|
92
|
+
// suggestion is offered only when the handler is nothing but the
|
|
93
|
+
// navigation, and only for a plain one-URL push (see below).
|
|
94
|
+
function rowHrefSuggestion(node, attr, target) {
|
|
95
|
+
// A (disabled) rowHref already present would end up duplicated.
|
|
96
|
+
if (findAttribute(node, "rowHref")) return undefined;
|
|
97
|
+
const handler = attr.value.expression;
|
|
98
|
+
if (resolveFunction(context, handler) !== handler) return undefined;
|
|
99
|
+
// App helpers take routes/params, not URLs — no safe rowHref to offer.
|
|
100
|
+
if (!isBuiltinNavigation(target)) return undefined;
|
|
101
|
+
// A plain anchor can only push one URL: no new tab (window.open), no
|
|
102
|
+
// history replace, no router state/options (a second argument).
|
|
103
|
+
if (target.type === "CallExpression") {
|
|
104
|
+
const method = target.callee.property?.name;
|
|
105
|
+
if (method === "open" || method === "replace") return undefined;
|
|
106
|
+
if (target.arguments.length !== 1) return undefined;
|
|
107
|
+
}
|
|
108
|
+
const body = handler.body;
|
|
109
|
+
const onlyNavigation =
|
|
110
|
+
body === target ||
|
|
111
|
+
(body.type === "BlockStatement" &&
|
|
112
|
+
body.body.length === 1 &&
|
|
113
|
+
body.body[0].type === "ExpressionStatement" &&
|
|
114
|
+
body.body[0].expression === target);
|
|
115
|
+
if (!onlyNavigation) return undefined;
|
|
116
|
+
const dest =
|
|
117
|
+
target.type === "AssignmentExpression"
|
|
118
|
+
? target.right
|
|
119
|
+
: target.arguments[0];
|
|
120
|
+
if (!dest || dest.type === "SpreadElement") return undefined;
|
|
121
|
+
const params = handler.params.map((p) => source.getText(p)).join(", ");
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
messageId: "suggestRowHref",
|
|
125
|
+
fix: (fixer) =>
|
|
126
|
+
fixer.replaceText(
|
|
127
|
+
attr,
|
|
128
|
+
`rowHref={(${params}) => ${source.getText(dest)}}`,
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkJsx(node) {
|
|
135
|
+
const name = node.name;
|
|
136
|
+
if (name.type === "JSXIdentifier" && name.name === "a") {
|
|
137
|
+
if (
|
|
138
|
+
findAttribute(node, "onClick") &&
|
|
139
|
+
!hasActiveAttribute(node, "href") &&
|
|
140
|
+
!hasSpread(node)
|
|
141
|
+
) {
|
|
142
|
+
report(node, "anchorWithoutHref");
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const component = ds.importedName(name);
|
|
147
|
+
if (!component || hasSpread(node)) return;
|
|
148
|
+
|
|
149
|
+
if (LINK_COMPONENTS.has(component)) {
|
|
150
|
+
if (
|
|
151
|
+
!hasActiveAttribute(node, "href") &&
|
|
152
|
+
!hasActiveAttribute(node, "asChild")
|
|
153
|
+
) {
|
|
154
|
+
report(node, "linkWithoutHref", { component });
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (component === "Button") {
|
|
159
|
+
const variant = staticString(findAttribute(node, "variant")?.value);
|
|
160
|
+
if (
|
|
161
|
+
variant === "link" &&
|
|
162
|
+
!hasActiveAttribute(node, "asChild") &&
|
|
163
|
+
attributeNavigation(context, findAttribute(node, "onClick"))
|
|
164
|
+
) {
|
|
165
|
+
report(node, "buttonLinkNavigates");
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (component === "DropdownMenuItem") {
|
|
170
|
+
if (hasActiveAttribute(node, "asChild")) return;
|
|
171
|
+
for (const click of ["onSelect", "onClick"]) {
|
|
172
|
+
if (attributeNavigation(context, findAttribute(node, click))) {
|
|
173
|
+
report(node, "menuItemNavigates", { click });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (component === "TableRow" || component === "TableCell") {
|
|
180
|
+
if (attributeNavigation(context, findAttribute(node, "onClick"))) {
|
|
181
|
+
report(node, "tableNavigates", { component });
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (component === "NavBreadcrumbItem") {
|
|
186
|
+
if (hasActiveAttribute(node, "linkProps")) return;
|
|
187
|
+
if (attributeNavigation(context, findAttribute(node, "onClick"))) {
|
|
188
|
+
report(node, "missingLinkProp", {
|
|
189
|
+
component,
|
|
190
|
+
click: "onClick",
|
|
191
|
+
link: "linkProps",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (component === "DataTable") {
|
|
197
|
+
if (hasActiveAttribute(node, "rowHref")) return;
|
|
198
|
+
const attr = findAttribute(node, "onRowClick");
|
|
199
|
+
const target = attributeNavigation(context, attr);
|
|
200
|
+
if (target) {
|
|
201
|
+
report(
|
|
202
|
+
node,
|
|
203
|
+
"missingLinkProp",
|
|
204
|
+
{ component, click: "onRowClick", link: "rowHref" },
|
|
205
|
+
rowHrefSuggestion(node, attr, target),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// One `const items = [...]` can feed several components — report once.
|
|
212
|
+
const reported = new Set();
|
|
213
|
+
function checkDescriptors(node) {
|
|
214
|
+
for (const root of descriptorRoots(ds, node)) {
|
|
215
|
+
forEachLinklessDescriptor(context, root, (_obj, click) => {
|
|
216
|
+
if (reported.has(click)) return;
|
|
217
|
+
if (findNavigation(context, click.value)) {
|
|
218
|
+
reported.add(click);
|
|
219
|
+
report(click, "descriptorNavigates", {
|
|
220
|
+
click: click.key.name ?? click.key.value,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
ImportDeclaration: ds.ImportDeclaration,
|
|
229
|
+
JSXOpeningElement(node) {
|
|
230
|
+
checkJsx(node);
|
|
231
|
+
checkDescriptors(node);
|
|
232
|
+
},
|
|
233
|
+
CallExpression: checkDescriptors,
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
};
|