@camunda/design-system 0.63.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.
Files changed (32) hide show
  1. package/dist/src/components/ui/data-table.d.ts +17 -2
  2. package/dist/src/components/ui/data-table.d.ts.map +1 -1
  3. package/dist/src/components/ui/data-table.js +16 -7
  4. package/dist/src/components/ui/data-table.js.map +1 -1
  5. package/dist/src/components/ui/input.d.ts.map +1 -1
  6. package/dist/src/components/ui/input.js +7 -4
  7. package/dist/src/components/ui/input.js.map +1 -1
  8. package/dist/src/components/ui/select.d.ts.map +1 -1
  9. package/dist/src/components/ui/select.js +11 -8
  10. package/dist/src/components/ui/select.js.map +1 -1
  11. package/dist/src/components/ui/textarea.d.ts.map +1 -1
  12. package/dist/src/components/ui/textarea.js +8 -5
  13. package/dist/src/components/ui/textarea.js.map +1 -1
  14. package/eslint-plugin/index.d.ts +18 -0
  15. package/eslint-plugin/index.js +102 -0
  16. package/eslint-rules/button-has-type.js +56 -0
  17. package/eslint-rules/class-literal.js +20 -0
  18. package/eslint-rules/ds-imports.js +131 -0
  19. package/eslint-rules/icon-button-needs-label.js +110 -0
  20. package/eslint-rules/index.js +31 -0
  21. package/eslint-rules/navigation.js +295 -0
  22. package/eslint-rules/no-adhoc-typography.js +77 -0
  23. package/eslint-rules/no-deep-import.js +59 -0
  24. package/eslint-rules/no-direct-lucide-import.js +67 -0
  25. package/eslint-rules/no-hardcoded-i18n-prop.js +78 -0
  26. package/eslint-rules/no-hardcoded-user-facing-string.js +82 -0
  27. package/eslint-rules/no-primitive-tailwind-colors.js +36 -0
  28. package/eslint-rules/no-raw-color-in-classname.js +34 -0
  29. package/eslint-rules/prefer-link-props.js +107 -0
  30. package/eslint-rules/require-browsable-link.js +236 -0
  31. package/eslint-rules/require-shared-sidebar-provider.js +165 -0
  32. package/package.json +18 -3
@@ -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
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Flags `<AppHeader>` and `<AppSidebar>` composed in the same file without a
3
+ * shared `<SidebarProvider>` ancestor. Without one, each reads a separate
4
+ * (or missing) sidebar context — `AppHeader`'s `isMobile` check silently
5
+ * reads `false` regardless of viewport, the mobile hamburger never renders,
6
+ * and `AppSidebar` renders its fixed desktop rail on a phone screen instead
7
+ * of the mobile Sheet overlay (issue #470).
8
+ *
9
+ * Either component used alone (no sidebar at all, or a sidebar-only shell)
10
+ * is a legitimate, documented composition and must never warn — this rule
11
+ * only fires when BOTH are present and don't share one provider instance.
12
+ *
13
+ * Same-file heuristic only: it cannot see a provider wrapping either
14
+ * component from a different file (e.g. a router rendering each at a
15
+ * different route level) — that case is covered by AppSidebar's own
16
+ * dev-mode runtime warning instead, not by static analysis.
17
+ *
18
+ * Optional `importSources`: when set, only `AppHeader`/`AppSidebar` imported
19
+ * from those modules count (aliases resolved), so a consumer's own component
20
+ * with the same name never trips it. `SidebarProvider` is still matched by
21
+ * bare name as well — a local re-export of the provider is a valid ancestor.
22
+ * Without the option (DS repo, relative imports) names match bare.
23
+ */
24
+ import { importSourcesSchema, trackDsImports } from "./ds-imports.js";
25
+
26
+ export default {
27
+ meta: {
28
+ type: "problem",
29
+ docs: {
30
+ description:
31
+ "AppHeader and AppSidebar must share one SidebarProvider ancestor when both are used.",
32
+ },
33
+ messages: {
34
+ missingSharedProvider:
35
+ "<{{name}}> is composed with <{{other}}> in this file but they don't share a <SidebarProvider> ancestor — the mobile hamburger/Sheet overlay will silently fail to work. Wrap both in one shared <SidebarProvider>.",
36
+ },
37
+ schema: [importSourcesSchema],
38
+ },
39
+ create(context) {
40
+ const scoped = Boolean(context.options[0]?.importSources);
41
+ const ds = trackDsImports(context);
42
+ const NAMES = ["AppHeader", "AppSidebar"];
43
+ const otherName = (name) =>
44
+ name === "AppHeader" ? "AppSidebar" : "AppHeader";
45
+ const FUNCTION_TYPES = new Set([
46
+ "FunctionDeclaration",
47
+ "FunctionExpression",
48
+ "ArrowFunctionExpression",
49
+ ]);
50
+
51
+ // Grouping key: the nearest enclosing COMPONENT-level function (each
52
+ // component is its own scope — an AppHeader in one component has nothing
53
+ // to do with an AppSidebar in an unrelated one, even in the same file).
54
+ // `null` = module top level, its own scope too.
55
+ // A plain helper function nested inside a component (e.g.
56
+ // `const header = () => <AppHeader />`) is NOT a new scope — it inherits
57
+ // its enclosing component's scope, so JSX it returns still gets grouped
58
+ // with siblings rendered directly in that component.
59
+ // key -> { AppHeader: [{node, providerAncestors}], AppSidebar: [...] }
60
+ const scopes = new Map();
61
+ const providerStack = [];
62
+ const functionStack = [];
63
+
64
+ function elementName(node) {
65
+ const n = node.openingElement.name;
66
+ if (n.type !== "JSXIdentifier") return null;
67
+ return scoped ? ds.importedName(n) : n.name;
68
+ }
69
+
70
+ function isProvider(node) {
71
+ const n = node.openingElement.name;
72
+ return (
73
+ (n.type === "JSXIdentifier" && n.name === "SidebarProvider") ||
74
+ elementName(node) === "SidebarProvider"
75
+ );
76
+ }
77
+
78
+ function currentScopeKey() {
79
+ return functionStack.length
80
+ ? functionStack[functionStack.length - 1]
81
+ : null;
82
+ }
83
+
84
+ function isComponentScope(node) {
85
+ if (
86
+ (node.type === "FunctionDeclaration" ||
87
+ node.type === "FunctionExpression") &&
88
+ node.id &&
89
+ /^[A-Z]/.test(node.id.name)
90
+ ) {
91
+ return true;
92
+ }
93
+ const parent = node.parent;
94
+ return (
95
+ parent?.type === "VariableDeclarator" &&
96
+ parent.id.type === "Identifier" &&
97
+ /^[A-Z]/.test(parent.id.name)
98
+ );
99
+ }
100
+
101
+ function pushFn(node) {
102
+ if (FUNCTION_TYPES.has(node.type) && isComponentScope(node)) {
103
+ functionStack.push(node);
104
+ }
105
+ }
106
+ function popFn(node) {
107
+ if (
108
+ FUNCTION_TYPES.has(node.type) &&
109
+ functionStack[functionStack.length - 1] === node
110
+ ) {
111
+ functionStack.pop();
112
+ }
113
+ }
114
+
115
+ return {
116
+ ImportDeclaration: ds.ImportDeclaration,
117
+ FunctionDeclaration: pushFn,
118
+ FunctionExpression: pushFn,
119
+ ArrowFunctionExpression: pushFn,
120
+ "FunctionDeclaration:exit": popFn,
121
+ "FunctionExpression:exit": popFn,
122
+ "ArrowFunctionExpression:exit": popFn,
123
+
124
+ JSXElement(node) {
125
+ const name = elementName(node);
126
+ if (isProvider(node)) providerStack.push(node);
127
+ if (!NAMES.includes(name)) return;
128
+
129
+ const key = currentScopeKey();
130
+ if (!scopes.has(key))
131
+ scopes.set(key, { AppHeader: [], AppSidebar: [] });
132
+ scopes.get(key)[name].push({
133
+ node,
134
+ providerAncestors: new Set(providerStack),
135
+ });
136
+ },
137
+ "JSXElement:exit"(node) {
138
+ if (isProvider(node)) providerStack.pop();
139
+ },
140
+ "Program:exit"() {
141
+ for (const occurrences of scopes.values()) {
142
+ for (const name of NAMES) {
143
+ const other = otherName(name);
144
+ if (occurrences[other].length === 0) continue; // used alone — fine
145
+
146
+ for (const occ of occurrences[name]) {
147
+ const sharesProvider = occurrences[other].some((otherOcc) =>
148
+ [...occ.providerAncestors].some((p) =>
149
+ otherOcc.providerAncestors.has(p),
150
+ ),
151
+ );
152
+ if (!sharesProvider) {
153
+ context.report({
154
+ node: occ.node,
155
+ messageId: "missingSharedProvider",
156
+ data: { name, other },
157
+ });
158
+ }
159
+ }
160
+ }
161
+ }
162
+ },
163
+ };
164
+ },
165
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda/design-system",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Camunda's AI-first design system. shadcn + Tailwind components, design tokens aligned to Camunda's identity, and AI tooling for component authoring + migration.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -29,17 +29,28 @@
29
29
  "types": "./dist/carbon-compat/index.d.ts",
30
30
  "import": "./dist/carbon-compat/index.js"
31
31
  },
32
+ "./eslint-plugin": {
33
+ "types": "./eslint-plugin/index.d.ts",
34
+ "import": "./eslint-plugin/index.js"
35
+ },
32
36
  "./styles.css": "./dist/styles.css"
33
37
  },
34
38
  "typesVersions": {
35
39
  "*": {
36
40
  "carbon-compat": [
37
41
  "./dist/carbon-compat/index.d.ts"
42
+ ],
43
+ "eslint-plugin": [
44
+ "./eslint-plugin/index.d.ts"
38
45
  ]
39
46
  }
40
47
  },
41
48
  "files": [
42
49
  "dist",
50
+ "eslint-plugin",
51
+ "!eslint-plugin/*.test.mjs",
52
+ "eslint-rules",
53
+ "!eslint-rules/*.test.mjs",
43
54
  "README.md",
44
55
  "LICENSE",
45
56
  "NOTICE"
@@ -73,7 +84,7 @@
73
84
  "audit:conventions": "node scripts/audit-conventions.mjs",
74
85
  "mapping:build": "bash skills/migrate/scripts/build-mapping.sh",
75
86
  "mapping:check": "bash skills/migrate/scripts/build-mapping.sh --check",
76
- "test:rules": "node eslint-rules/no-adhoc-typography.test.mjs && node eslint-rules/no-primitive-tailwind-colors.test.mjs && node eslint-rules/no-raw-color-in-classname.test.mjs && node eslint-rules/require-shared-sidebar-provider.test.mjs && node eslint-rules/no-direct-lucide-import.test.mjs && node eslint-rules/no-hardcoded-user-facing-string.test.mjs && node --test src/icons/registry.integrity.test.mjs src/lib/trigger-ref.integrity.test.mjs src/lib/i18n/dictionary.test.mjs src/lib/i18n/dictionary-usage.test.mjs src/lib/i18n/context.test.mjs",
87
+ "test:rules": "node eslint-rules/no-adhoc-typography.test.mjs && node eslint-rules/no-primitive-tailwind-colors.test.mjs && node eslint-rules/no-raw-color-in-classname.test.mjs && node eslint-rules/require-shared-sidebar-provider.test.mjs && node eslint-rules/no-hardcoded-user-facing-string.test.mjs && node eslint-rules/no-direct-lucide-import.test.mjs && node eslint-rules/no-deep-import.test.mjs && node eslint-rules/button-has-type.test.mjs && node eslint-rules/icon-button-needs-label.test.mjs && node eslint-rules/prefer-link-props.test.mjs && node eslint-rules/require-browsable-link.test.mjs && node eslint-rules/no-hardcoded-i18n-prop.test.mjs && node --test eslint-plugin/index.test.mjs src/icons/registry.integrity.test.mjs src/lib/trigger-ref.integrity.test.mjs src/lib/i18n/dictionary.test.mjs src/lib/i18n/dictionary-usage.test.mjs src/lib/i18n/context.test.mjs",
77
88
  "test:hooks": "python3 plugins/camunda-design-system/scripts/route-request.test.py",
78
89
  "prepare": "husky",
79
90
  "release": "changeset publish",
@@ -93,11 +104,15 @@
93
104
  "peerDependencies": {
94
105
  "@carbon/react": "^1.107.0",
95
106
  "react": ">=19.0.0 <20",
96
- "react-dom": ">=19.0.0 <20"
107
+ "react-dom": ">=19.0.0 <20",
108
+ "eslint": ">=9.0.0"
97
109
  },
98
110
  "peerDependenciesMeta": {
99
111
  "@carbon/react": {
100
112
  "optional": true
113
+ },
114
+ "eslint": {
115
+ "optional": true
101
116
  }
102
117
  },
103
118
  "dependencies": {