@assure-one/design-system 1.35.0 → 1.36.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/README.md CHANGED
@@ -8,12 +8,16 @@ The single source of truth for visual design across all Assure products.
8
8
 
9
9
  ## Documentation
10
10
 
11
+ - [**`docs/components.md`**](./docs/components.md) — the component catalogue: every public component by need and category, what it replaces, what not to use it for, deprecated aliases and their codemods. Generated from the API reports; ships in the package (`node_modules/@assure-one/design-system/docs/components.md`).
12
+ - [**`docs/for-ai-agents.md`**](./docs/for-ai-agents.md) — the short rulebook for AI agents and new contributors: never raw HTML, the `Field` recipe, `className` vs `classNames`, the `ds:` prefix, tokens, codemods, testing, what is experimental, how to propose a component.
11
13
  - [**`CHANGELOG.md`**](./CHANGELOG.md) — release-by-release log of what shipped.
12
14
  - [**`CONTRIBUTING.md`**](./CONTRIBUTING.md) — dev loop, release flow, versioning, pitfalls.
13
15
  - [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
14
16
  - [**`docs/integration/css.md`**](./docs/integration/css.md) — how an application loads our CSS: layer order, import sequence, the compatibility preset, removing `@source`, runtime brand theming.
15
17
  - [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
18
+ - [**`docs/forms.md`**](./docs/forms.md) — building a form: `Field` + design-system controls + a Server Action + zod, reset and `required`, what each control submits, and testing it with `fillField` / `selectOption` / `pickDate`.
16
19
  - [**`docs/design-system/icons.md`**](./docs/design-system/icons.md) — the icon catalogue and its server-safe entry (`@assure-one/design-system/icons`, experimental): import an icon in a Server Component without a client boundary.
20
+ - [**`docs/adr/010-design-system-provider.md`**](./docs/adr/010-design-system-provider.md) — the optional `DesignSystemProvider` (messages, locale, link adapter) and the `/next` entry; the quick start is [below](#the-provider-and-the-next-entry-experimental).
17
21
  - [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
18
22
 
19
23
  ## Install
@@ -84,6 +88,54 @@ canvas, and guarantees WCAG AA for every foreground it emits. The seed shape is
84
88
  gated by decision D7 and may change while that decision is open — see
85
89
  [`docs/integration/css.md`](./docs/integration/css.md#5-runtime-brand-theming).
86
90
 
91
+ ### The provider and the `/next` entry (experimental)
92
+
93
+ Nothing requires a provider: every component renders English strings, formats
94
+ with `en-US` and (for `LinkButton` / `Logo`) routes through `next/link` on its
95
+ own. `DesignSystemProvider` is the one optional, thin, client-side provider
96
+ for what CSS cannot do — the generic strings, the locale, the text direction,
97
+ the portal container, the link and image adapters, shared tooltip defaults and
98
+ (opt-in) the toast region. It renders no DOM wrapper ([ADR-010](./docs/adr/010-design-system-provider.md)).
99
+
100
+ ```tsx
101
+ // app/layout.tsx (Next.js) — the Next adapters are filled in for you
102
+ import { NextDesignSystemProvider } from "@assure-one/design-system/next";
103
+
104
+ export default function RootLayout({ children }) {
105
+ return (
106
+ <html lang="de">
107
+ <body>
108
+ <NextDesignSystemProvider
109
+ locale="de-DE"
110
+ messages={{ select: { clear: "Auswahl löschen" }, combobox: { empty: "Keine Treffer" } }}
111
+ >
112
+ {children}
113
+ </NextDesignSystemProvider>
114
+ </body>
115
+ </html>
116
+ );
117
+ }
118
+ ```
119
+
120
+ - **`messages`** — any subset of the typed catalogue `DsMessages`, by component
121
+ namespace (`select`, `searchInput`, `combobox`, `field`, `fileUpload`; more
122
+ join as the overlays adopt it). Resolution is always *component prop >
123
+ provider > English default*, per key. The full English list is
124
+ `src/foundation/messages/en.json`; parameterised messages are functions
125
+ (`combobox.create: (query) => string`).
126
+ - **`locale`** — a BCP 47 tag, default `"en-US"` on the server *and* the client.
127
+ Pass the locale your app already knows server-side; the design system never
128
+ reads `navigator.language` during render, so server and client HTML agree.
129
+ - **`linkComponent` / `imageComponent`** — what `LinkButton` and `Logo` render
130
+ through. Without them the components keep their static `next/link` /
131
+ `next/image` imports until 2.0, so a Next.js app that renders no provider is
132
+ unchanged. A non-Next app passes its router's link; a Next.js app uses
133
+ `NextDesignSystemProvider` so it is ready for the 2.0 removal of the static
134
+ imports.
135
+ - **`portalContainer`**, **`dir`**, **`tooltip`**, **`toasts`** — documented on
136
+ the props. `toasts` mounts the toast region for `useToast()`; leave it off if
137
+ you already render `ToastProvider`.
138
+
87
139
  ## Develop
88
140
 
89
141
  ```bash
@@ -107,18 +107,21 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
107
107
 
108
108
  ## Codemods
109
109
 
110
- | Id | Class | What it does |
111
- | ----- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
- | CM-05 | A | Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
113
- | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
114
- | CM-04 | R | Button family legacy `variant` `variant` + `intent` pair (`primary` → `solid`, `destructive` `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
115
- | CM-06 | R | `tone` → `intent` on StatusDot, IconTile, Spinner, SegmentedProgress, SuiteProgress (`pro` → `brand`, listed for review); product hues → report |
116
- | CM-19 | R | ProgressBar/ProgressRing: `variant` → `intent`, and the value colour rule written down as an explicit `intent` where the value is a literal; dynamic values → report |
117
- | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
118
- | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
119
- | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
120
- | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
121
- | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
110
+ | Id | Class | What it does |
111
+ | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
+ | CM-05 | A | Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
113
+ | CM-07 | A | `inputSize` `size` on `Input`, `Textarea`, `SearchInput` (pure rename, literal or dynamic value; both names, spreads → "could not be transformed") |
114
+ | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
115
+ | CM-04 | R | Button family legacy `variant` → `variant` + `intent` pair (`primary` `solid`, `destructive` → `solid` + `danger`, ); `accent`, `dashed`, dynamic → report |
116
+ | CM-06 | R | `tone` → `intent` on StatusDot, IconTile, Spinner, SegmentedProgress, SuiteProgress (`pro` `brand`, listed for review); product hues → report |
117
+ | CM-19 | R | ProgressBar/ProgressRing: `variant` `intent`, and the value colour rule written down as an explicit `intent` where the value is a literal; dynamic values → report |
118
+ | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
119
+ | CM-10 | R | `DatePicker onChange={e => f(e.target.value)}` `onValueChange={value => f(value)}` for the recognisable arrow shapes; handlers by reference, other shapes report |
120
+ | CM-08 | R | `SearchSelect` `Combobox`: single-mode call sites rewritten (tag, import, `{id,label}` accessors, placeholder → `aria-label`); multi API, extras and unnamed sites → report |
121
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
122
+ | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
123
+ | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
124
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
122
125
 
123
126
  The remaining ids of plan §29 land with the waves that ship their replacement
124
127
  APIs.
@@ -146,6 +149,30 @@ the same slot, and which one wins depends on attribute order). Not touched and
146
149
  not reported: elements already on the new names, local components with the
147
150
  same names, test files.
148
151
 
152
+ ### CM-07 — `inputSize` → `size` on Input, Textarea, SearchInput
153
+
154
+ Class A. W4-05/06/07 put the three text controls on the control scale:
155
+ `size` is the prop, `inputSize` the deprecated spelling of the same value
156
+ (contract C-INPUT-SIZE, removed in 3.0). Every legacy word renders the
157
+ classes it always did and `size` wins when both are given, so the rename is
158
+ pixel-neutral and a pure rename: only the attribute's name is replaced, and
159
+ its value — a literal or a dynamic `inputSize={dense ? "sm" : "md"}` — keeps
160
+ its text and formatting. Aliased and namespace imports count.
161
+
162
+ | rule | Contract | What happens |
163
+ | -------------- | ------------ | ---------------------------- |
164
+ | `size-renamed` | C-INPUT-SIZE | `inputSize={…}` → `size={…}` |
165
+
166
+ Not touched, and listed under "could not be transformed": an element that
167
+ passes **both** `size` and `inputSize` (`size` already wins; a rename would
168
+ make a duplicate attribute — delete `inputSize` by hand), and an element with
169
+ a spread (`<Input inputSize="sm" {...props}>` — the spread may carry `size`,
170
+ and which one wins depends on attribute order). Not touched and not reported:
171
+ elements already on `size`, other controls with a `size` of their own, local
172
+ components with the same names, test files.
173
+ `tests/codemods/cm-07.test.mjs` checks the component set against the sources
174
+ that declare `inputSize?:`.
175
+
149
176
  ### CM-04 — Button family legacy `variant` → `variant` + `intent`
150
177
 
151
178
  Class R. W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
@@ -292,6 +319,43 @@ capitalised `<Form>` does not count: the react-hook-form / shadcn `Form` is a
292
319
  context provider, not a form element. `SubmitButton`, `LinkButton`, a Button
293
320
  with any `type`, a Button with `asChild` and test files are not reported.
294
321
 
322
+ ### CM-10 — DatePicker fake-event `onChange` → `onValueChange`
323
+
324
+ Class R. `DatePicker` never fired a DOM event: its `onChange` receives
325
+ `{ target: { name, value } }` with the ISO string (`""` when cleared), and
326
+ every consumer handler unwraps `.target.value`. W4-18 added the value callback
327
+ of target architecture §25, `onValueChange(iso | null)`, and kept the fake
328
+ event as a deprecated alias that fires after it (contract C-DATE-FAKEEVENT,
329
+ removed in 3.0). CM-10 rewrites the handlers whose shape makes the unwrapping
330
+ visible — an inline arrow whose parameter is read **only** as the value:
331
+
332
+ | handler | becomes |
333
+ | ----------------------------------------- | --------------------------------- |
334
+ | `(e) => f(e.target.value)` (typed or not) | `(value) => f(value)` |
335
+ | `e => setX(e.target.value)` | `value => setX(value)` |
336
+ | `({ target }) => f(target.value)` | `(value) => f(value)` |
337
+ | `({ target: { value } }) => f(value)` | `(value) => f(value)` |
338
+ | `() => refetch()` | body unchanged, attribute renamed |
339
+
340
+ The parameter is named `value` — `v`, then `next`, when the enclosing
341
+ component binds that name already, so nothing is shadowed — and the body
342
+ keeps its formatting. **A cleared picker now passes `null` where `onChange`
343
+ passed `""`**: that is why every rewrite is in the review list (medium), and
344
+ why a handler that reads a member of the value (`e.target.value.slice(0, 4)`)
345
+ is not rewritten — it would throw on clear.
346
+
347
+ | rule | Contract | What happens |
348
+ | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
349
+ | `fake-event-unwrapped` | C-DATE-FAKEEVENT | one of the shapes above was rewritten (applied, **medium**): confirm `null` on clear is what the state wants |
350
+ | `handler-reference` | C-DATE-FAKEEVENT | the handler is passed by reference (`onChange={handleChange}`, `{field.onChange}`, `{onField("due")}`): its body is elsewhere (review, high) |
351
+ | `handler-shape` | C-DATE-FAKEEVENT | an inline handler that reads more than `.target.value` — `e.target.name`, the whole event, a member of the value — or a `function` expression (review, high) |
352
+ | `has-value-change` | C-DATE-FAKEEVENT | `onValueChange` is already wired; `onChange` still fires after it — delete it by hand (review, low) |
353
+ | `spread-props` | C-DATE-FAKEEVENT | `{...props}` may carry either callback; which one wins depends on attribute order (review, medium) |
354
+
355
+ `DateRangePicker` is not touched: its `onChange` is a value callback already.
356
+ Not touched and not reported: elements that only pass `onValueChange`, local
357
+ components called `DatePicker`, test files.
358
+
295
359
  ### CM-14 — hidden-input mirror finder
296
360
 
297
361
  Report-only, **for ever**: deleting a mirror changes what the server receives,
@@ -350,6 +414,32 @@ Sentinels outside the Select family — grouping keys, tab ids, route segments
350
414
  are out of scope even when the spelling matches, and nothing is followed across
351
415
  files.
352
416
 
417
+ ### CM-08 — `SearchSelect` → `Combobox`
418
+
419
+ Class R. W4-12 made `SearchSelect` a deprecated adapter over `Combobox`
420
+ (contract C-SEARCHSELECT, removed in 3.0). A **single-mode** call site
421
+ (`value` / `onValueChange`, no spread) is rewritten: the tag and the named
422
+ import become `Combobox`, `getOptionValue={(o) => o.id}` and
423
+ `getOptionLabel={(o) => o.label}` are inserted after `options` so the
424
+ `{ id, label }` records need no remapping, and a literal `placeholder` — which
425
+ named the SearchSelect input — is copied into `aria-label`. Everything else is
426
+ reported for a human.
427
+
428
+ | rule | Contract | What happens |
429
+ | ----------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
430
+ | `renamed` | C-SEARCHSELECT | tag, import and accessors rewritten (applied, low) |
431
+ | `clear-null` | C-SEARCHSELECT | the rewritten Combobox reports `null` on clear where SearchSelect passed `""`; check the handler (applied, **medium**) |
432
+ | `option-fields` | C-SEARCHSELECT | `description` / `icon` / `color` on the records are not read through the accessors; move them to `renderOption` if used (applied, low) |
433
+ | `rendering` | C-SEARCHSELECT | per call site (plan §29): SearchSelect opened on focus, below the field at its width, never flipping; Combobox flips to stay in the viewport and opens on click or typing (applied on a rewritten element, review otherwise, low) |
434
+ | `multi-api` | C-SEARCHSELECT | `selected` / `onSelect` / `onRemove` → `multiple` + `value: string[]` + `onValueChange`: the state shape changes (review, high) |
435
+ | `unmapped-prop` | C-SEARCHSELECT | `groupFilter`, `pinnedGroups`, `showGroupCounts`, `onCreateNew`, `createNewLabel`, `descriptionVariant`, `closeOnSelect` have no Combobox prop (review, high) |
436
+ | `accessible-name` | C-SEARCHSELECT | no literal placeholder and no `aria-label`: left alone — a Combobox without a name fires the W4-02 development error (review, high) |
437
+ | `spread-props` | C-SEARCHSELECT | `{...props}` may carry any of the above (review, medium) |
438
+ | `option-type` | C-SEARCHSELECT | `SearchSelectOption` is still imported; the Combobox shape is `Option` (review, low) |
439
+
440
+ Not touched: elements already on `Combobox` (idempotency), local components
441
+ with the same name, test files.
442
+
353
443
  ### CM-15 — DOM-selector finder
354
444
 
355
445
  Report-only. It never modifies a file; the runner throws if it tries. What it
@@ -4,11 +4,14 @@
4
4
  */
5
5
  export const CODEMODS = [
6
6
  { id: "CM-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
7
+ { id: "CM-07", module: "../transforms/cm-07-input-size.mjs" },
7
8
  { id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
8
9
  { id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
9
10
  { id: "CM-06", module: "../transforms/cm-06-tone-to-intent.mjs" },
10
11
  { id: "CM-19", module: "../transforms/cm-19-progress-explicit-intent.mjs" },
11
12
  { id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
13
+ { id: "CM-10", module: "../transforms/cm-10-date-picker-value-change.mjs" },
14
+ { id: "CM-08", module: "../transforms/cm-08-search-select-to-combobox.mjs" },
12
15
  { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
13
16
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
14
17
  { id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CM-07 — `inputSize` → `size` on `Input`, `Textarea` and `SearchInput`
3
+ * (class A; plan §29 seq 3, registry `C-INPUT-SIZE`).
4
+ *
5
+ * W4-05/06/07 put the three text controls on the control scale: `size` is the
6
+ * prop, `inputSize` the deprecated spelling of the same value
7
+ * (`InputVariantsOptions`, `TextareaProps`, `SearchInputProps`). Every legacy
8
+ * word renders the classes it always did and `size` wins when both are
9
+ * given, so the rename is pixel-neutral and a pure rename: only the
10
+ * attribute's name is replaced — a literal or a dynamic `inputSize={x}` keeps
11
+ * its value and its formatting.
12
+ *
13
+ * | rule | registry | what happens |
14
+ * | -------------- | ------------ | ------------------------------------- |
15
+ * | `size-renamed` | C-INPUT-SIZE | `inputSize={…}` → `size={…}` |
16
+ *
17
+ * ## What it leaves alone, and lists under "could not be transformed"
18
+ *
19
+ * - An element that passes **both** `size` and `inputSize`: `size` already
20
+ * wins in the component, and a rename would make a duplicate attribute.
21
+ * A human deletes `inputSize`.
22
+ * - An element with a spread (`<Input inputSize="sm" {...props}>`): the
23
+ * spread may carry `size`, and which one wins depends on attribute order —
24
+ * renaming the literal could change it.
25
+ *
26
+ * Elements already on `size` are not touched (idempotency). Local components
27
+ * with the same names are not touched; test files are skipped, as by the
28
+ * scanner that measures C-INPUT-SIZE.
29
+ */
30
+ import { analyseForms } from "../lib/forms.mjs";
31
+ import { applyEdits, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
32
+
33
+ export const meta = {
34
+ id: "CM-07",
35
+ title: "Input, Textarea, SearchInput: inputSize → size",
36
+ class: "A",
37
+ oneShot: false,
38
+ requires: { codemods: [], dsVersion: null },
39
+ parses: ["code"],
40
+ includeTests: false,
41
+ usesTypeScript: true,
42
+ usesPostcss: false,
43
+ registryIds: ["C-INPUT-SIZE"],
44
+ };
45
+
46
+ /**
47
+ * The design-system components that accept the deprecated `inputSize`
48
+ * (W4-05/06/07). `tests/codemods/cm-07.test.mjs` checks the set against the
49
+ * sources that declare `inputSize?:`.
50
+ */
51
+ export const SIZED_CONTROLS = new Set(["Input", "Textarea", "SearchInput"]);
52
+
53
+ export const LEGACY_PROP = "inputSize";
54
+ export const PROP = "size";
55
+
56
+ export function transform(file, { ts }) {
57
+ const facts = analyseForms(ts, file.source, file.rel);
58
+ const findings = [];
59
+ const notTransformed = [];
60
+ const edits = [];
61
+
62
+ for (const el of facts.elements) {
63
+ if (!el.isDs || !SIZED_CONTROLS.has(el.base) || el.component !== el.base) continue;
64
+ if (!el.props.has(LEGACY_PROP)) continue;
65
+
66
+ if (el.props.has(PROP)) {
67
+ notTransformed.push({
68
+ line: el.line,
69
+ reason: "both-names",
70
+ detail: `<${el.tag}> passes \`${LEGACY_PROP}\` and \`${PROP}\` — \`${PROP}\` already wins; delete \`${LEGACY_PROP}\` by hand`,
71
+ });
72
+ continue;
73
+ }
74
+ if (el.spread) {
75
+ notTransformed.push({
76
+ line: el.line,
77
+ reason: "spread-props",
78
+ detail: `<${el.tag} {…}> — the spread may carry \`${PROP}\`; rename by hand once you know it does not`,
79
+ });
80
+ continue;
81
+ }
82
+
83
+ const edit = renameAttribute(ts, facts.sf, openingOf(ts, el.node), LEGACY_PROP, PROP);
84
+ if (!edit) continue;
85
+ edits.push(edit);
86
+ findings.push({
87
+ line: el.props.get(LEGACY_PROP).line,
88
+ registryId: "C-INPUT-SIZE",
89
+ rule: "size-renamed",
90
+ match: `<${el.tag} ${LEGACY_PROP}>`,
91
+ component: el.component,
92
+ action: "applied",
93
+ gate: null,
94
+ detail: { from: LEGACY_PROP, to: PROP, value: el.props.get(LEGACY_PROP).text },
95
+ });
96
+ }
97
+
98
+ return {
99
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
100
+ findings,
101
+ notTransformed,
102
+ parseErrors: facts.parseErrors,
103
+ };
104
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * CM-08 — `SearchSelect` → `Combobox` (class R; plan §29 seq 10, registry
3
+ * `C-SEARCHSELECT`, lands with W4-12).
4
+ *
5
+ * W4-12 turned `SearchSelect` into a deprecated adapter over `Combobox`. The
6
+ * single-mode call sites (33 of 40 uses in the products) are a mechanical
7
+ * rewrite; the multi API and the SearchSelect-only props are not, and are
8
+ * reported for a human.
9
+ *
10
+ * ## What it rewrites (single mode: `value` / `onValueChange`, no spread)
11
+ *
12
+ * - the tag `SearchSelect` → `Combobox`, and the named import with it when
13
+ * the file has no other `SearchSelect` element left
14
+ * - `options` keeps its expression; `getOptionValue={(o) => o.id}` and
15
+ * `getOptionLabel={(o) => o.label}` are inserted after it, so the `{ id,
16
+ * label }` records need no remapping (target architecture §32 accessors)
17
+ * - `placeholder="…"` named the SearchSelect input; a Combobox is named by a
18
+ * Field label or `aria-label`, so a literal placeholder is copied into
19
+ * `aria-label` when the element has none
20
+ *
21
+ * ## What it reports
22
+ *
23
+ * | rule | action | severity | why |
24
+ * | -------------------- | ------- | -------- | ----------------------------------------------------------------------------------- |
25
+ * | `renamed` | applied | low | tag, import and accessors rewritten |
26
+ * | `clear-null` | applied | medium | the rewritten element's Combobox reports `null` on clear where SearchSelect passed `""`; check the handler |
27
+ * | `rendering` | applied / review | low | SearchSelect opened on focus, below the field, never flipping; Combobox flips to stay in the viewport and opens on click/typing (plan §29: flagged per call site — `applied` on a rewritten element, `review` on one left alone) |
28
+ * | `option-fields` | applied | low | `description` / `icon` / `color` on the records are not read through the accessors; move them to `renderOption` if used |
29
+ * | `multi-api` | review | high | `selected` / `onSelect` / `onRemove` → `multiple` + `value: string[]` + `onValueChange`; the state shape changes |
30
+ * | `unmapped-prop` | review | high | `groupFilter` / `pinnedGroups` / `showGroupCounts` / `onCreateNew` / `createNewLabel` / `descriptionVariant` / `closeOnSelect` have no Combobox prop |
31
+ * | `accessible-name` | review | high | no literal placeholder and no `aria-label`: the element is left alone — a Combobox without a name fires the W4-02 development error |
32
+ * | `spread-props` | review | medium | `{...props}` may carry any of the above |
33
+ * | `option-type` | review | low | `SearchSelectOption` is still imported; the Combobox shape is `Option` |
34
+ *
35
+ * Elements already on `Combobox` are not touched (idempotency). Local
36
+ * components with the same name are not touched; test files are skipped.
37
+ */
38
+ import { analyseForms } from "../lib/forms.mjs";
39
+ import { applyEdits, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
40
+
41
+ export const meta = {
42
+ id: "CM-08",
43
+ title: "SearchSelect → Combobox (single mode automatic; multi and extras reported)",
44
+ class: "R",
45
+ oneShot: false,
46
+ requires: { codemods: [], dsVersion: null },
47
+ parses: ["code"],
48
+ includeTests: false,
49
+ usesTypeScript: true,
50
+ usesPostcss: false,
51
+ registryIds: ["C-SEARCHSELECT"],
52
+ };
53
+
54
+ export const RULES = {
55
+ renamed: { action: "applied", severity: "low" },
56
+ "clear-null": { action: "applied", severity: "medium" },
57
+ rendering: { action: "applied", severity: "low" },
58
+ "option-fields": { action: "applied", severity: "low" },
59
+ "multi-api": { action: "review", severity: "high" },
60
+ "unmapped-prop": { action: "review", severity: "high" },
61
+ "accessible-name": { action: "review", severity: "high" },
62
+ "spread-props": { action: "review", severity: "medium" },
63
+ "option-type": { action: "review", severity: "low" },
64
+ };
65
+
66
+ /** Props the Combobox takes under the same name and meaning. */
67
+ export const MAPPED_PROPS = new Set([
68
+ "options",
69
+ "value",
70
+ "onValueChange",
71
+ "placeholder",
72
+ "className",
73
+ "disabled",
74
+ "onCreate",
75
+ "id",
76
+ "aria-label",
77
+ "aria-labelledby",
78
+ "aria-describedby",
79
+ ]);
80
+ export const MULTI_PROPS = new Set(["selected", "onSelect", "onRemove"]);
81
+ export const UNMAPPED_PROPS = new Set([
82
+ "groupFilter",
83
+ "pinnedGroups",
84
+ "showGroupCounts",
85
+ "onCreateNew",
86
+ "createNewLabel",
87
+ "descriptionVariant",
88
+ "closeOnSelect",
89
+ ]);
90
+
91
+ const ACCESSORS = "getOptionValue={(o) => o.id} getOptionLabel={(o) => o.label}";
92
+
93
+ export function transform(file, { ts }) {
94
+ const facts = analyseForms(ts, file.source, file.rel);
95
+ const findings = [];
96
+ const edits = [];
97
+ let remaining = 0;
98
+ let renamedAny = false;
99
+
100
+ for (const el of facts.elements) {
101
+ if (!el.isDs || el.base !== "SearchSelect" || el.component !== el.base) continue;
102
+ const base = {
103
+ line: el.line,
104
+ registryId: "C-SEARCHSELECT",
105
+ match: `<${el.tag}>`,
106
+ component: el.component,
107
+ gate: null,
108
+ detail: {},
109
+ };
110
+ const add = (rule, detail = {}, override = {}) =>
111
+ findings.push({
112
+ ...base,
113
+ rule,
114
+ ...RULES[rule],
115
+ ...override,
116
+ detail: { ...base.detail, ...detail },
117
+ });
118
+
119
+ const names = [...el.props.keys()];
120
+ const multi = names.filter((n) => MULTI_PROPS.has(n));
121
+ const unmapped = names.filter((n) => UNMAPPED_PROPS.has(n));
122
+ let blocked = false;
123
+ if (el.spread) {
124
+ add("spread-props");
125
+ blocked = true;
126
+ }
127
+ if (multi.length) {
128
+ add("multi-api", { props: multi });
129
+ blocked = true;
130
+ }
131
+ if (unmapped.length) {
132
+ add("unmapped-prop", { props: unmapped });
133
+ blocked = true;
134
+ }
135
+ if (blocked) {
136
+ remaining += 1;
137
+ add("rendering", {}, { action: "review" });
138
+ continue;
139
+ }
140
+
141
+ // A Combobox without an accessible name fires the W4-02 development
142
+ // error at once, so an element whose name cannot be derived (no literal
143
+ // placeholder, no aria-label) is left for a human rather than rewritten.
144
+ const placeholder = el.props.get("placeholder");
145
+ const hasName = el.props.has("aria-label") || el.props.has("aria-labelledby");
146
+ const literal =
147
+ placeholder && !placeholder.expression && placeholder.literals.length === 1
148
+ ? placeholder.literals[0]
149
+ : null;
150
+ if (!hasName && literal === null) {
151
+ remaining += 1;
152
+ add("accessible-name");
153
+ add("rendering", {}, { action: "review" });
154
+ continue;
155
+ }
156
+
157
+ const opening = openingOf(ts, el.node);
158
+ const sf = facts.sf;
159
+ edits.push({
160
+ pos: opening.tagName.getStart(sf),
161
+ end: opening.tagName.getEnd(),
162
+ text: "Combobox",
163
+ });
164
+ if (ts.isJsxElement(el.node)) {
165
+ const closing = el.node.closingElement.tagName;
166
+ edits.push({ pos: closing.getStart(sf), end: closing.getEnd(), text: "Combobox" });
167
+ }
168
+ if (el.props.has("options")) {
169
+ edits.push(insertAttribute(ts, sf, opening, ACCESSORS, { after: "options" }));
170
+ }
171
+ if (!hasName) {
172
+ edits.push(
173
+ insertAttribute(ts, sf, opening, `aria-label=${JSON.stringify(literal)}`, {
174
+ after: "placeholder",
175
+ }),
176
+ );
177
+ }
178
+ renamedAny = true;
179
+ add("renamed");
180
+ if (el.props.has("onValueChange")) add("clear-null");
181
+ add("option-fields");
182
+ add("rendering");
183
+ }
184
+
185
+ // The import: `SearchSelect` → `Combobox` when no element is left on the
186
+ // old name; a `SearchSelectOption` type import is reported, never rewritten.
187
+ if (renamedAny) {
188
+ const sf = facts.sf;
189
+ for (const stmt of sf.statements) {
190
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
191
+ if (!stmt.moduleSpecifier.text.startsWith("@assure-one/design-system")) continue;
192
+ const bindings = stmt.importClause?.namedBindings;
193
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
194
+ const hasCombobox = bindings.elements.some(
195
+ (e) => (e.propertyName ?? e.name).text === "Combobox",
196
+ );
197
+ for (const element of bindings.elements) {
198
+ const imported = (element.propertyName ?? element.name).text;
199
+ if (imported === "SearchSelectOption") {
200
+ findings.push({
201
+ line: facts.lineOf(element),
202
+ registryId: "C-SEARCHSELECT",
203
+ match: "SearchSelectOption",
204
+ component: "SearchSelect",
205
+ gate: null,
206
+ rule: "option-type",
207
+ ...RULES["option-type"],
208
+ detail: {},
209
+ });
210
+ }
211
+ if (imported !== "SearchSelect" || element.propertyName || remaining > 0) continue;
212
+ if (hasCombobox) {
213
+ // Drop the specifier: `Combobox` is already imported.
214
+ const list = bindings.elements;
215
+ const index = list.indexOf(element);
216
+ const from = index === 0 ? element.getStart(sf) : list[index - 1].getEnd();
217
+ const end = index === 0 && list.length > 1 ? list[1].getStart(sf) : element.getEnd();
218
+ edits.push({ pos: from, end, text: "" });
219
+ } else {
220
+ edits.push({
221
+ pos: element.name.getStart(sf),
222
+ end: element.name.getEnd(),
223
+ text: "Combobox",
224
+ });
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ return {
231
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
232
+ findings,
233
+ notTransformed: [],
234
+ parseErrors: facts.parseErrors,
235
+ };
236
+ }