@assure-one/design-system 1.36.0 → 1.38.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 +45 -16
- package/codemods/README.md +93 -0
- package/codemods/lib/registry.mjs +3 -0
- package/codemods/transforms/cm-09-side-drawer-to-sheet.mjs +348 -0
- package/codemods/transforms/cm-13-tooltip-delay.mjs +99 -0
- package/codemods/transforms/cm-18-data-table-parts.mjs +234 -0
- package/dist/css/components.css +1 -1
- package/dist/css/legacy-aliases.css +33 -1
- package/dist/css/shadcn.css +2 -2
- package/dist/css/tailwind.css +9 -0
- package/dist/css/tokens.css +72 -38
- package/dist/design-system-provider-B5MbvbRc.d.ts +609 -0
- package/dist/icons/index.d.ts +2 -2
- package/dist/icons/index.js +6 -2
- package/dist/icons/index.js.map +1 -1
- package/dist/{index-CQwzTm0v.d.ts → index-vztxMSfl.d.ts} +7 -4
- package/dist/index.d.ts +1757 -303
- package/dist/index.js +4125 -1956
- package/dist/index.js.map +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/styles.css +1 -1
- package/dist/{system-BDU18fVg.d.ts → system-CyAVbHhM.d.ts} +16 -16
- package/dist/tokens/index.d.ts +1 -1
- package/dist/tokens/index.js +8 -8
- package/dist/tokens/index.js.map +1 -1
- package/docs/components.md +47 -14
- package/docs/components.registry.json +2415 -461
- package/docs/for-ai-agents.md +2 -0
- package/eslint-config/index.mjs +210 -0
- package/eslint-config/stylelint-rules.mjs +54 -0
- package/eslint-config/stylelint.mjs +73 -0
- package/package.json +8 -1
- package/dist/design-system-provider-cPUklDJv.d.ts +0 -220
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ The single source of truth for visual design across all Assure products.
|
|
|
16
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.
|
|
17
17
|
- [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
|
|
18
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`.
|
|
19
|
+
- [**`docs/data-tables.md`**](./docs/data-tables.md) — building a data table: the headless `useDataTable` controller (sorting, pagination, search, selection, column visibility, filters — each controllable), server `manual` mode, and the URL-state recipe.
|
|
19
20
|
- [**`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
21
|
- [**`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).
|
|
21
22
|
- [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
|
|
@@ -52,14 +53,14 @@ Alongside it, the package now also publishes the individual stylesheets the new
|
|
|
52
53
|
CSS mode is built from. They are **experimental**: nothing requires them yet,
|
|
53
54
|
no component reads them, and importing them changes nothing on its own.
|
|
54
55
|
|
|
55
|
-
| Subpath
|
|
56
|
-
|
|
|
57
|
-
| `./css/tokens.css`
|
|
58
|
-
| `./css/legacy-aliases.css` | Read-aliases from today's token names to the namespaced ones (`--color-surface: var(--ds-color-surface)`), for as long as your own CSS reads design-system token names.
|
|
59
|
-
| `./css/tailwind.css`
|
|
60
|
-
| `./css/shadcn.css`
|
|
61
|
-
| `./css/base.css`
|
|
62
|
-
| `./css/components.css`
|
|
56
|
+
| Subpath | What it is |
|
|
57
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `./css/tokens.css` | The `--ds-*` design tokens: `:root`, the colour scheme scope and the brand scopes. Usable without any component. |
|
|
59
|
+
| `./css/legacy-aliases.css` | Read-aliases from today's token names to the namespaced ones (`--color-surface: var(--ds-color-surface)`), for as long as your own CSS reads design-system token names. |
|
|
60
|
+
| `./css/tailwind.css` | A Tailwind `@theme` bridge, so `bg-surface/40`, `hover:text-fg-3` and `rounded-control` compile in _your_ build instead of silently producing nothing. |
|
|
61
|
+
| `./css/shadcn.css` | The app-vocabulary bridge: `background`, `foreground`, `primary`, `muted`, `destructive`, the radius scale and the rest, each reading one `--app-*` input you can override. |
|
|
62
|
+
| `./css/base.css` | Optional document defaults: body background, ink, font and `color-scheme`. Ships no preflight, no element rules and no font import. |
|
|
63
|
+
| `./css/components.css` | The component utilities compiled with Tailwind's `ds` prefix (`ds:flex`, `ds:bg-surface`), inside `@layer ds` and bound to the `--ds-*` tokens. **Unused until the class-vocabulary flip (W1-11):** no component emits a prefixed class yet, so importing it styles nothing. Published so the file, its size and its hygiene can be reviewed before the flip. |
|
|
63
64
|
|
|
64
65
|
Import order is `tokens.css` → `legacy-aliases.css` (optional) → `tailwind.css`
|
|
65
66
|
→ `shadcn.css` (optional) → `base.css` (optional) → `components.css`, after your own
|
|
@@ -119,11 +120,11 @@ export default function RootLayout({ children }) {
|
|
|
119
120
|
|
|
120
121
|
- **`messages`** — any subset of the typed catalogue `DsMessages`, by component
|
|
121
122
|
namespace (`select`, `searchInput`, `combobox`, `field`, `fileUpload`; more
|
|
122
|
-
join as the overlays adopt it). Resolution is always
|
|
123
|
-
provider > English
|
|
123
|
+
join as the overlays adopt it). Resolution is always _component prop >
|
|
124
|
+
provider > English default_, per key. The full English list is
|
|
124
125
|
`src/foundation/messages/en.json`; parameterised messages are functions
|
|
125
126
|
(`combobox.create: (query) => string`).
|
|
126
|
-
- **`locale`** — a BCP 47 tag, default `"en-US"` on the server
|
|
127
|
+
- **`locale`** — a BCP 47 tag, default `"en-US"` on the server _and_ the client.
|
|
127
128
|
Pass the locale your app already knows server-side; the design system never
|
|
128
129
|
reads `navigator.language` during render, so server and client HTML agree.
|
|
129
130
|
- **`linkComponent` / `imageComponent`** — what `LinkButton` and `Logo` render
|
|
@@ -136,6 +137,34 @@ export default function RootLayout({ children }) {
|
|
|
136
137
|
the props. `toasts` mounts the toast region for `useToast()`; leave it off if
|
|
137
138
|
you already render `ToastProvider`.
|
|
138
139
|
|
|
140
|
+
## Lint configs for consumers
|
|
141
|
+
|
|
142
|
+
Two shareable configs ship with the package (**experimental**, W7-18) so a
|
|
143
|
+
product lints for the same things this repository ratchets on itself. Every
|
|
144
|
+
rule is a **warning**: adopt it on the code you have, record the count, and
|
|
145
|
+
ratchet it down.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
// eslint.config.mjs
|
|
149
|
+
import ds from "@assure-one/design-system/eslint-config";
|
|
150
|
+
export default [...ds.configs.recommended];
|
|
151
|
+
|
|
152
|
+
// stylelint.config.mjs (stylelint is yours — an optional peer dependency here)
|
|
153
|
+
export { default } from "@assure-one/design-system/stylelint-config";
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Rule | Reports |
|
|
157
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
158
|
+
| `ds/no-raw-controls` | raw `<button>`, `<input>`, `<select>`, `<textarea>` where a design-system control exists |
|
|
159
|
+
| `ds/no-radix-imports` | a direct `@radix-ui/*` import instead of the primitive that wraps it |
|
|
160
|
+
| `ds/no-deep-imports` | an import past the published entries (`dist/…`, `src/…` — ADR-011) |
|
|
161
|
+
| `ds/no-ds-token-redefinition` | a `--ds-*` token set inline, outside a file under `presets/` |
|
|
162
|
+
| `assure-ds/no-ds-token-redefinition` | the same in CSS (stylelint; `presets/` and `*.preset.css` are exempt) |
|
|
163
|
+
| `assure-ds/no-important-on-ds-selectors` | `!important` on a `ds:`/`data-slot` selector — pass `className`/`classNames` instead |
|
|
164
|
+
|
|
165
|
+
Raise a rule to `"error"` in your own config once you are clean. Adoption per
|
|
166
|
+
app is tracked as W7-19.
|
|
167
|
+
|
|
139
168
|
## Develop
|
|
140
169
|
|
|
141
170
|
```bash
|
|
@@ -153,11 +182,11 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full dev loop, including iter
|
|
|
153
182
|
3-tier token system, authored as DTCG JSON in [`tokens/src`](./tokens/README.md)
|
|
154
183
|
and generated into `src/tokens/` and the `./css/*` entries (ADR-003):
|
|
155
184
|
|
|
156
|
-
| Tier
|
|
157
|
-
|
|
|
158
|
-
| reference
|
|
159
|
-
| system (semantic) | `--ds-color-action-brand-bg: var(--ds-ref-brand-pro-fg)` | the reference tier; **this is the public contract**
|
|
160
|
-
| component
|
|
185
|
+
| Tier | Example (namespaced name → value) | Reads |
|
|
186
|
+
| ----------------- | -------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
187
|
+
| reference | `--ds-ref-brand-pro-fg: #6c42f8` | a literal; the raw ramps, one per brand |
|
|
188
|
+
| system (semantic) | `--ds-color-action-brand-bg: var(--ds-ref-brand-pro-fg)` | the reference tier; **this is the public contract** |
|
|
189
|
+
| component | `--ds-menu-item-hover-bg: var(--ds-color-canvas-sunken)` | the system tier; only where a component must be themable on its own |
|
|
161
190
|
|
|
162
191
|
Components consume **system tokens**, never reference values directly. Themes
|
|
163
192
|
(colour scheme, per-product brand, a runtime brand from `createBrandTheme()`)
|
package/codemods/README.md
CHANGED
|
@@ -110,6 +110,7 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
|
|
|
110
110
|
| Id | Class | What it does |
|
|
111
111
|
| ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
112
112
|
| CM-05 | A | Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
|
|
113
|
+
| CM-13 | A | `Tooltip delayMs` → `delayDuration` (pure rename, literal or dynamic value; both names, spreads → "could not be transformed") |
|
|
113
114
|
| CM-07 | A | `inputSize` → `size` on `Input`, `Textarea`, `SearchInput` (pure rename, literal or dynamic value; both names, spreads → "could not be transformed") |
|
|
114
115
|
| CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
|
|
115
116
|
| CM-04 | R | Button family legacy `variant` → `variant` + `intent` pair (`primary` → `solid`, `destructive` → `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
|
|
@@ -118,6 +119,8 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
|
|
|
118
119
|
| CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
|
|
119
120
|
| 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
121
|
| 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 |
|
|
122
|
+
| CM-09 | R | `SideDrawer` → `Sheet`: drawers with a `SideDrawer.Header` rewritten (root → `Sheet` + `SheetContent side size`, `onClose` → `onOpenChange`, header → `SheetHeader`/`SheetTitle`/`SheetDescription`, Body/Footer → `SheetBody`/`SheetFooter`, import); no header, dynamic width, spreads → report |
|
|
123
|
+
| CM-18 | A | `DataTable*` presentational parts → `Table*` (`DataTableHead` → `TableHeader`, `Body`, `Row`, `Cell`, `DataTableHeader` → `TableHead`) where the mapping is 1:1; uncontrolled sorting, a dynamic `sortable` and spreads → "could not be transformed" |
|
|
121
124
|
| CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
|
|
122
125
|
| 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
126
|
| CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
|
|
@@ -149,6 +152,27 @@ the same slot, and which one wins depends on attribute order). Not touched and
|
|
|
149
152
|
not reported: elements already on the new names, local components with the
|
|
150
153
|
same names, test files.
|
|
151
154
|
|
|
155
|
+
### CM-13 — `Tooltip delayMs` → `delayDuration`
|
|
156
|
+
|
|
157
|
+
Class A. W5-12 made `delayMs` a deprecated alias of Radix's `delayDuration`
|
|
158
|
+
on the design-system `Tooltip` (contract C-TOOLTIP-DELAY, removed in 3.0).
|
|
159
|
+
Both name the same milliseconds and `delayDuration` wins when both are
|
|
160
|
+
given, so the rename is behaviour-neutral and a pure rename: only the
|
|
161
|
+
attribute's name is replaced, and its value — a literal or a dynamic
|
|
162
|
+
`delayMs={slow ? 700 : 300}` — keeps its text and formatting. Aliased and
|
|
163
|
+
namespace imports count.
|
|
164
|
+
|
|
165
|
+
| rule | Contract | What happens |
|
|
166
|
+
| --------------- | --------------- | ----------------------------------- |
|
|
167
|
+
| `delay-renamed` | C-TOOLTIP-DELAY | `delayMs={…}` → `delayDuration={…}` |
|
|
168
|
+
|
|
169
|
+
Not touched, and listed under "could not be transformed": an element that
|
|
170
|
+
passes **both** `delayDuration` and `delayMs` (`delayDuration` already wins;
|
|
171
|
+
a rename would make a duplicate attribute — delete `delayMs` by hand), and an
|
|
172
|
+
element with a spread (`<Tooltip delayMs={300} {...props}>` — the spread may
|
|
173
|
+
carry `delayDuration`). Not touched and not reported: elements already on
|
|
174
|
+
`delayDuration`, local components called Tooltip, test files.
|
|
175
|
+
|
|
152
176
|
### CM-07 — `inputSize` → `size` on Input, Textarea, SearchInput
|
|
153
177
|
|
|
154
178
|
Class A. W4-05/06/07 put the three text controls on the control scale:
|
|
@@ -440,6 +464,75 @@ reported for a human.
|
|
|
440
464
|
Not touched: elements already on `Combobox` (idempotency), local components
|
|
441
465
|
with the same name, test files.
|
|
442
466
|
|
|
467
|
+
### CM-09 — `SideDrawer` → `Sheet`
|
|
468
|
+
|
|
469
|
+
Class R. W5-08 made `SideDrawer` a deprecated adapter over `Sheet` (contract
|
|
470
|
+
C-SIDEDRAWER, removed in 3.0). A drawer **with a `SideDrawer.Header`** is
|
|
471
|
+
rewritten: the root becomes `Sheet` with `onOpenChange={(open) => !open &&
|
|
472
|
+
onClose()}` (a handler by reference is called, an arrow with an expression
|
|
473
|
+
body is inlined, anything else is called through `(expr)()`); the children
|
|
474
|
+
move into a `SheetContent` that carries the `side` and the mapped `size`
|
|
475
|
+
(`sm` → `sm`, `md` → `lg`; `lg` and the omitted default, 50vw, become `xl`
|
|
476
|
+
and are reported); the header's `title` and `subtitle` become `SheetTitle` and
|
|
477
|
+
`SheetDescription` inside `SheetHeader`; `SideDrawer.Body` / `.Footer` become
|
|
478
|
+
`SheetBody` / `SheetFooter` with their `className`. A header that had its own
|
|
479
|
+
`onClose` button hands the job to the sheet's built-in close button; one that
|
|
480
|
+
had none gets `showCloseButton={false}`. The named import follows once no
|
|
481
|
+
`SideDrawer` element is left in the file.
|
|
482
|
+
|
|
483
|
+
| rule | Contract | What happens |
|
|
484
|
+
| ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
485
|
+
| `renamed` | C-SIDEDRAWER | root, content, parts and import rewritten (applied, low) |
|
|
486
|
+
| `width-approximate` | C-SIDEDRAWER | `width="lg"` or no `width` (50vw) → `size="xl"` (760px); set `--ds-sheet-width` for an exact width (applied, **medium**) |
|
|
487
|
+
| `header-close` | C-SIDEDRAWER | the header's close button is now the sheet's built-in one, last child of the content (applied, low) |
|
|
488
|
+
| `rendering` | C-SIDEDRAWER | the Sheet parts pad differently (`p-6` header and footer, `px-6` body) and draw no rules between them (applied, low) |
|
|
489
|
+
| `no-header` | C-SIDEDRAWER | a drawer without `SideDrawer.Header` has no title to become the `SheetTitle`: left alone (review, high) |
|
|
490
|
+
| `width-dynamic` | C-SIDEDRAWER | `width={expr}` has no `size` step: left alone (review, high) |
|
|
491
|
+
| `spread-props` | C-SIDEDRAWER | `{...props}` on the drawer or a part may carry any of the above: left alone (review, medium) |
|
|
492
|
+
|
|
493
|
+
Not touched: elements already on `Sheet` (idempotency), local components with
|
|
494
|
+
the same name, test files. The rewritten children are re-indented one level;
|
|
495
|
+
run the project's formatter afterwards.
|
|
496
|
+
|
|
497
|
+
### CM-18 — `DataTable*` parts → `Table*`
|
|
498
|
+
|
|
499
|
+
Class A. W6-03 made the `DataTable*` table parts aliases of the `Table*` parts
|
|
500
|
+
(contract C-DT-PARTS, removed in 3.0): one styling source, the same props. The
|
|
501
|
+
1:1 parts are a pure rename — tag, closing tag and the named import:
|
|
502
|
+
|
|
503
|
+
| from | to |
|
|
504
|
+
| ----------------- | ------------- |
|
|
505
|
+
| `DataTableHead` | `TableHeader` |
|
|
506
|
+
| `DataTableBody` | `TableBody` |
|
|
507
|
+
| `DataTableRow` | `TableRow` |
|
|
508
|
+
| `DataTableCell` | `TableCell` |
|
|
509
|
+
| `DataTableHeader` | `TableHead` |
|
|
510
|
+
|
|
511
|
+
`DataTableHeader sortable sort={…} onSortChange={…}` loses the `sortable`
|
|
512
|
+
attribute: `TableHead` sorts whenever `sort` is passed (`null` = sortable, not
|
|
513
|
+
sorted). What changes in the DOM is the `data-slot` value (`table-*` instead of
|
|
514
|
+
`data-table-*`) and the dropped `scope="col"` — a `<th>` in a `<thead>` is a
|
|
515
|
+
column header already. Classes and pixels are the same, because since W6-03 the
|
|
516
|
+
`DataTable*` parts render the `Table*` parts.
|
|
517
|
+
|
|
518
|
+
| rule | Contract | What happens |
|
|
519
|
+
| --------------- | ---------- | ------------------------------------------------------------------- |
|
|
520
|
+
| `part-renamed` | C-DT-PARTS | tag, closing tag and the named import rewritten (applied, low) |
|
|
521
|
+
|
|
522
|
+
Reported under "could not be transformed": `DataTableHeader sortable` with no
|
|
523
|
+
controlled `sort`, or with `defaultSort` (`uncontrolled-sort` — `TableHead` has
|
|
524
|
+
no uncontrolled sort state; wire `sort` + `onSortChange`, e.g. from
|
|
525
|
+
`useDataTable`); `sortable={expr}` (`dynamic-sortable`); a spread on the header
|
|
526
|
+
(`spread-props`).
|
|
527
|
+
|
|
528
|
+
Not touched: the parts that are not parallel copies of a `Table*` part —
|
|
529
|
+
`DataTable` (the card frame), `DataTableToolbar`, `DataTableSearch`,
|
|
530
|
+
`DataTableSpacer`, `DataTableResultsCount`, `DataTableCellName` / `CellMono` /
|
|
531
|
+
`CellId` / `CellDue`, `DataTableCheckbox`, `DataTablePagination` — an aliased
|
|
532
|
+
import (`DataTableCell as Cell`), a namespace import, local components with the
|
|
533
|
+
same names, and test files. A file that still refers to the old name as a value
|
|
534
|
+
(`typeof DataTableCell`) keeps its import and gains the new name next to it.
|
|
535
|
+
|
|
443
536
|
### CM-15 — DOM-selector finder
|
|
444
537
|
|
|
445
538
|
Report-only. It never modifies a file; the runner throws if it tries. What it
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export const CODEMODS = [
|
|
6
6
|
{ id: "CM-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
|
|
7
|
+
{ id: "CM-13", module: "../transforms/cm-13-tooltip-delay.mjs" },
|
|
7
8
|
{ id: "CM-07", module: "../transforms/cm-07-input-size.mjs" },
|
|
8
9
|
{ id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
|
|
9
10
|
{ id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
|
|
@@ -12,6 +13,8 @@ export const CODEMODS = [
|
|
|
12
13
|
{ id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
|
|
13
14
|
{ id: "CM-10", module: "../transforms/cm-10-date-picker-value-change.mjs" },
|
|
14
15
|
{ id: "CM-08", module: "../transforms/cm-08-search-select-to-combobox.mjs" },
|
|
16
|
+
{ id: "CM-09", module: "../transforms/cm-09-side-drawer-to-sheet.mjs" },
|
|
17
|
+
{ id: "CM-18", module: "../transforms/cm-18-data-table-parts.mjs" },
|
|
15
18
|
{ id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
|
|
16
19
|
{ id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
|
|
17
20
|
{ id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-09 — `SideDrawer` → `Sheet` (class R; plan §29 seq 11, registry
|
|
3
|
+
* `C-SIDEDRAWER`, lands with W5-08).
|
|
4
|
+
*
|
|
5
|
+
* W5-08 turned `SideDrawer` into a deprecated adapter over `Sheet`. A drawer
|
|
6
|
+
* with a `SideDrawer.Header` is a mechanical rewrite onto the Sheet parts;
|
|
7
|
+
* what has no Sheet equivalent is reported for a human.
|
|
8
|
+
*
|
|
9
|
+
* ## What it rewrites
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <SideDrawer open={open} onClose={onClose} width="md" side="left">
|
|
13
|
+
* <SideDrawer.Header title={name} subtitle="…" onClose={onClose} />
|
|
14
|
+
* <SideDrawer.Body className="…">…</SideDrawer.Body>
|
|
15
|
+
* <SideDrawer.Footer>…</SideDrawer.Footer>
|
|
16
|
+
* </SideDrawer>
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* becomes
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <Sheet open={open} onOpenChange={(open) => !open && onClose()}>
|
|
23
|
+
* <SheetContent side="left" size="lg">
|
|
24
|
+
* <SheetHeader>
|
|
25
|
+
* <SheetTitle>{name}</SheetTitle>
|
|
26
|
+
* <SheetDescription>…</SheetDescription>
|
|
27
|
+
* </SheetHeader>
|
|
28
|
+
* <SheetBody className="…">…</SheetBody>
|
|
29
|
+
* <SheetFooter>…</SheetFooter>
|
|
30
|
+
* </SheetContent>
|
|
31
|
+
* </Sheet>
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* - `onClose` → `onOpenChange`: a handler by reference is called, an arrow
|
|
35
|
+
* with an expression body is inlined, anything else is called through
|
|
36
|
+
* `(expr)()`
|
|
37
|
+
* - `width` → `size` on the content: `sm` → `sm` (420px), `md` → `lg`
|
|
38
|
+
* (640px); `lg` (50vw) and the omitted default have no step and become
|
|
39
|
+
* `xl` (760px), reported as `width-approximate`
|
|
40
|
+
* - `side` moves to the content
|
|
41
|
+
* - `SideDrawer.Header title subtitle` → `SheetHeader` with `SheetTitle` and
|
|
42
|
+
* `SheetDescription`; the header's `onClose` is dropped because the sheet's
|
|
43
|
+
* built-in close button (the last child of the content) closes through
|
|
44
|
+
* `onOpenChange`; a header without `onClose` gets `showCloseButton={false}`
|
|
45
|
+
* - `SideDrawer.Body` → `SheetBody`, `SideDrawer.Footer` → `SheetFooter`
|
|
46
|
+
* (`className` kept)
|
|
47
|
+
* - the named import follows when no `SideDrawer` element is left
|
|
48
|
+
*
|
|
49
|
+
* ## What it reports
|
|
50
|
+
*
|
|
51
|
+
* | rule | action | severity | why |
|
|
52
|
+
* | ------------------- | ------- | -------- | ---------------------------------------------------------------------------- |
|
|
53
|
+
* | `renamed` | applied | low | root, content, parts and import rewritten |
|
|
54
|
+
* | `width-approximate` | applied | medium | `lg` / default (50vw) → `xl` (760px); set `--ds-sheet-width` for an exact width |
|
|
55
|
+
* | `header-close` | applied | low | the header's close button is now the sheet's built-in one |
|
|
56
|
+
* | `rendering` | applied | low | Sheet parts pad differently (`p-6` header/footer, `px-6` body; no rules between them) |
|
|
57
|
+
* | `no-header` | review | high | a drawer without `SideDrawer.Header` has no title to become the `SheetTitle` |
|
|
58
|
+
* | `width-dynamic` | review | high | `width={expr}` cannot be mapped to a `size` step |
|
|
59
|
+
* | `spread-props` | review | medium | `{...props}` on the drawer or a part may carry any of the above |
|
|
60
|
+
*
|
|
61
|
+
* Elements already on `Sheet` are not touched (idempotency). Local components
|
|
62
|
+
* with the same name are not touched; test files are skipped.
|
|
63
|
+
*/
|
|
64
|
+
import { analyseForms, isWithin } from "../lib/forms.mjs";
|
|
65
|
+
import { applyEdits, openingOf } from "../lib/jsx-edit.mjs";
|
|
66
|
+
|
|
67
|
+
export const meta = {
|
|
68
|
+
id: "CM-09",
|
|
69
|
+
title: "SideDrawer → Sheet (drawers with a header rewritten; the rest reported)",
|
|
70
|
+
class: "R",
|
|
71
|
+
oneShot: false,
|
|
72
|
+
requires: { codemods: [], dsVersion: null },
|
|
73
|
+
parses: ["code"],
|
|
74
|
+
includeTests: false,
|
|
75
|
+
usesTypeScript: true,
|
|
76
|
+
usesPostcss: false,
|
|
77
|
+
registryIds: ["C-SIDEDRAWER"],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const RULES = {
|
|
81
|
+
renamed: { action: "applied", severity: "low" },
|
|
82
|
+
"width-approximate": { action: "applied", severity: "medium" },
|
|
83
|
+
"header-close": { action: "applied", severity: "low" },
|
|
84
|
+
rendering: { action: "applied", severity: "low" },
|
|
85
|
+
"no-header": { action: "review", severity: "high" },
|
|
86
|
+
"width-dynamic": { action: "review", severity: "high" },
|
|
87
|
+
"spread-props": { action: "review", severity: "medium" },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** `width` preset → `size` step. `lg` (50vw) has no step; `xl` is the nearest. */
|
|
91
|
+
export const WIDTH_TO_SIZE = { sm: "sm", md: "lg", lg: "xl" };
|
|
92
|
+
|
|
93
|
+
const PART_TO_SHEET = {
|
|
94
|
+
"SideDrawer.Header": "SheetHeader",
|
|
95
|
+
"SideDrawer.Body": "SheetBody",
|
|
96
|
+
"SideDrawer.Footer": "SheetFooter",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Children text for a title/subtitle attribute: a literal becomes text, an expression stays. */
|
|
100
|
+
function childrenOf(ts, sf, attr) {
|
|
101
|
+
const init = attr.initializer;
|
|
102
|
+
if (!init) return "";
|
|
103
|
+
if (ts.isStringLiteral(init)) {
|
|
104
|
+
return /[{}<>]/.test(init.text) ? `{${init.getText(sf)}}` : init.text;
|
|
105
|
+
}
|
|
106
|
+
return init.getText(sf);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The `onOpenChange` handler that calls the drawer's `onClose` on close. */
|
|
110
|
+
function closeHandler(ts, sf, attr) {
|
|
111
|
+
const init = attr.initializer;
|
|
112
|
+
const inner = init && ts.isJsxExpression(init) ? init.expression : null;
|
|
113
|
+
if (!inner) return null;
|
|
114
|
+
if (ts.isIdentifier(inner) || ts.isPropertyAccessExpression(inner)) {
|
|
115
|
+
return `(open) => !open && ${inner.getText(sf)}()`;
|
|
116
|
+
}
|
|
117
|
+
if (ts.isArrowFunction(inner) && inner.parameters.length === 0 && !ts.isBlock(inner.body)) {
|
|
118
|
+
return `(open) => !open && ${inner.body.getText(sf)}`;
|
|
119
|
+
}
|
|
120
|
+
return `(open) => !open && (${inner.getText(sf)})()`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const attrsOf = (ts, sf, opening) => {
|
|
124
|
+
const map = new Map();
|
|
125
|
+
for (const attr of opening.attributes.properties) {
|
|
126
|
+
if (ts.isJsxAttribute(attr)) map.set(attr.name.getText(sf), attr);
|
|
127
|
+
}
|
|
128
|
+
return map;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const indentOf = (sf, node) => {
|
|
132
|
+
const text = sf.text;
|
|
133
|
+
const lineStart = text.lastIndexOf("\n", node.getStart(sf)) + 1;
|
|
134
|
+
return /^[ \t]*/.exec(text.slice(lineStart, node.getStart(sf)))[0];
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export function transform(file, { ts }) {
|
|
138
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
139
|
+
const sf = facts.sf;
|
|
140
|
+
const findings = [];
|
|
141
|
+
const edits = [];
|
|
142
|
+
const needed = new Set();
|
|
143
|
+
let remaining = 0;
|
|
144
|
+
let renamedAny = false;
|
|
145
|
+
|
|
146
|
+
const drawers = facts.elements.filter(
|
|
147
|
+
(el) => el.isDs && el.base === "SideDrawer" && el.component === "SideDrawer",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
for (const el of drawers) {
|
|
151
|
+
const base = {
|
|
152
|
+
line: el.line,
|
|
153
|
+
registryId: "C-SIDEDRAWER",
|
|
154
|
+
match: `<${el.tag}>`,
|
|
155
|
+
component: el.component,
|
|
156
|
+
gate: null,
|
|
157
|
+
detail: {},
|
|
158
|
+
};
|
|
159
|
+
const add = (rule, detail = {}) =>
|
|
160
|
+
findings.push({ ...base, rule, ...RULES[rule], detail: { ...base.detail, ...detail } });
|
|
161
|
+
|
|
162
|
+
const parts = facts.elements.filter(
|
|
163
|
+
(p) =>
|
|
164
|
+
p.isDs &&
|
|
165
|
+
p.base === "SideDrawer" &&
|
|
166
|
+
p.component !== "SideDrawer" &&
|
|
167
|
+
isWithin(facts.elements, p.index, el.index) &&
|
|
168
|
+
!drawers.some(
|
|
169
|
+
(other) =>
|
|
170
|
+
other !== el &&
|
|
171
|
+
isWithin(facts.elements, p.index, other.index) &&
|
|
172
|
+
isWithin(facts.elements, other.index, el.index),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const header = parts.find((p) => p.component === "SideDrawer.Header") ?? null;
|
|
176
|
+
const width = el.props.get("width");
|
|
177
|
+
|
|
178
|
+
let blocked = false;
|
|
179
|
+
if (el.spread || parts.some((p) => p.spread)) {
|
|
180
|
+
add("spread-props");
|
|
181
|
+
blocked = true;
|
|
182
|
+
}
|
|
183
|
+
if (width && width.expression) {
|
|
184
|
+
add("width-dynamic");
|
|
185
|
+
blocked = true;
|
|
186
|
+
}
|
|
187
|
+
if (!header || ts.isJsxElement(header.node)) {
|
|
188
|
+
add("no-header");
|
|
189
|
+
blocked = true;
|
|
190
|
+
}
|
|
191
|
+
if (blocked || !ts.isJsxElement(el.node)) {
|
|
192
|
+
remaining += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── The drawer's own opening tag → <Sheet open onOpenChange> ─────────
|
|
197
|
+
const opening = el.node.openingElement;
|
|
198
|
+
const attrs = attrsOf(ts, sf, opening);
|
|
199
|
+
const inner = [];
|
|
200
|
+
inner.push({ pos: opening.tagName.getStart(sf), end: opening.tagName.getEnd(), text: "Sheet" });
|
|
201
|
+
const closing = el.node.closingElement.tagName;
|
|
202
|
+
inner.push({ pos: closing.getStart(sf), end: closing.getEnd(), text: "Sheet" });
|
|
203
|
+
|
|
204
|
+
const onClose = attrs.get("onClose");
|
|
205
|
+
if (onClose) {
|
|
206
|
+
const handler = closeHandler(ts, sf, onClose);
|
|
207
|
+
if (handler) {
|
|
208
|
+
inner.push({
|
|
209
|
+
pos: onClose.getStart(sf),
|
|
210
|
+
end: onClose.getEnd(),
|
|
211
|
+
text: `onOpenChange={${handler}}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const removeAttr = (name) => {
|
|
216
|
+
const list = opening.attributes.properties;
|
|
217
|
+
const index = list.findIndex((a) => ts.isJsxAttribute(a) && a.name.getText(sf) === name);
|
|
218
|
+
if (index < 0) return;
|
|
219
|
+
const from = index === 0 ? opening.attributes.pos : list[index - 1].getEnd();
|
|
220
|
+
inner.push({ pos: from, end: list[index].getEnd(), text: "" });
|
|
221
|
+
};
|
|
222
|
+
removeAttr("width");
|
|
223
|
+
removeAttr("side");
|
|
224
|
+
|
|
225
|
+
// ── The content: side, size, showCloseButton ─────────────────────────
|
|
226
|
+
const preset = width ? width.literals[0] : "lg";
|
|
227
|
+
const size = WIDTH_TO_SIZE[preset] ?? "xl";
|
|
228
|
+
const contentAttrs = [];
|
|
229
|
+
const side = attrs.get("side");
|
|
230
|
+
contentAttrs.push(side ? `side=${side.initializer.getText(sf)}` : 'side="right"');
|
|
231
|
+
contentAttrs.push(`size="${size}"`);
|
|
232
|
+
const headerAttrs = attrsOf(ts, sf, header.node);
|
|
233
|
+
if (!headerAttrs.has("onClose")) contentAttrs.push("showCloseButton={false}");
|
|
234
|
+
needed.add("Sheet").add("SheetContent").add("SheetHeader").add("SheetTitle");
|
|
235
|
+
|
|
236
|
+
// ── SideDrawer.Header → SheetHeader with title and description ───────
|
|
237
|
+
const headerIndent = indentOf(sf, header.node);
|
|
238
|
+
const headerRows = [];
|
|
239
|
+
headerRows.push(`<SheetTitle>${childrenOf(ts, sf, headerAttrs.get("title"))}</SheetTitle>`);
|
|
240
|
+
const subtitle = headerAttrs.get("subtitle");
|
|
241
|
+
if (subtitle) {
|
|
242
|
+
needed.add("SheetDescription");
|
|
243
|
+
const value = childrenOf(ts, sf, subtitle);
|
|
244
|
+
headerRows.push(
|
|
245
|
+
subtitle.initializer && ts.isJsxExpression(subtitle.initializer)
|
|
246
|
+
? `{${subtitle.initializer.expression.getText(sf)} ? <SheetDescription>${value}</SheetDescription> : null}`
|
|
247
|
+
: `<SheetDescription>${value}</SheetDescription>`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const headerClass = headerAttrs.get("className");
|
|
251
|
+
const headerOpen = `<SheetHeader${headerClass ? ` ${headerClass.getText(sf)}` : ""}>`;
|
|
252
|
+
inner.push({
|
|
253
|
+
pos: header.node.getStart(sf),
|
|
254
|
+
end: header.node.getEnd(),
|
|
255
|
+
text: [
|
|
256
|
+
headerOpen,
|
|
257
|
+
...headerRows.map((row) => `${headerIndent} ${row}`),
|
|
258
|
+
`${headerIndent}</SheetHeader>`,
|
|
259
|
+
].join("\n"),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Body / Footer → SheetBody / SheetFooter ──────────────────────────
|
|
263
|
+
for (const part of parts) {
|
|
264
|
+
if (part === header) continue;
|
|
265
|
+
const to = PART_TO_SHEET[part.component];
|
|
266
|
+
if (!to) continue;
|
|
267
|
+
needed.add(to);
|
|
268
|
+
const partOpening = openingOf(ts, part.node);
|
|
269
|
+
inner.push({
|
|
270
|
+
pos: partOpening.tagName.getStart(sf),
|
|
271
|
+
end: partOpening.tagName.getEnd(),
|
|
272
|
+
text: to,
|
|
273
|
+
});
|
|
274
|
+
if (ts.isJsxElement(part.node)) {
|
|
275
|
+
const tag = part.node.closingElement.tagName;
|
|
276
|
+
inner.push({ pos: tag.getStart(sf), end: tag.getEnd(), text: to });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Assemble: apply the inner edits to the element's own text, indent
|
|
281
|
+
// the children one level and wrap them in <SheetContent>. ──────────────
|
|
282
|
+
const start = el.node.getStart(sf);
|
|
283
|
+
const end = el.node.getEnd();
|
|
284
|
+
const local = inner.map((e) => ({ ...e, pos: e.pos - start, end: e.end - start }));
|
|
285
|
+
const text = applyEdits(file.source.slice(start, end), local);
|
|
286
|
+
// Where the (edited) opening tag ends: its original length plus what the
|
|
287
|
+
// edits inside it added or removed.
|
|
288
|
+
const openingEnd = opening.getEnd() - start;
|
|
289
|
+
const openEnd = local
|
|
290
|
+
.filter((e) => e.end <= openingEnd)
|
|
291
|
+
.reduce((at, e) => at + e.text.length - (e.end - e.pos), openingEnd);
|
|
292
|
+
const closeStart = text.lastIndexOf("</Sheet>");
|
|
293
|
+
const indent = indentOf(sf, el.node);
|
|
294
|
+
const children = text.slice(openEnd, closeStart).replace(/\n(?=[^\n])/g, "\n ");
|
|
295
|
+
const trimmed = children.replace(/\s+$/, "");
|
|
296
|
+
edits.push({
|
|
297
|
+
pos: start,
|
|
298
|
+
end,
|
|
299
|
+
text: `${text.slice(0, openEnd)}\n${indent} <SheetContent ${contentAttrs.join(" ")}>${trimmed}\n${indent} </SheetContent>\n${indent}${text.slice(closeStart)}`,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
renamedAny = true;
|
|
303
|
+
add("renamed");
|
|
304
|
+
if (WIDTH_TO_SIZE[preset] === "xl" || !WIDTH_TO_SIZE[preset]) {
|
|
305
|
+
add("width-approximate", { width: preset, size });
|
|
306
|
+
}
|
|
307
|
+
if (headerAttrs.has("onClose")) {
|
|
308
|
+
add("header-close", {
|
|
309
|
+
header: headerAttrs.get("onClose").initializer?.getText(sf) ?? null,
|
|
310
|
+
drawer: onClose?.initializer?.getText(sf) ?? null,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
add("rendering");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The import: `SideDrawer` → the Sheet parts the file now uses, when no
|
|
317
|
+
// element is left on the old name.
|
|
318
|
+
if (renamedAny && remaining === 0) {
|
|
319
|
+
for (const stmt of sf.statements) {
|
|
320
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
321
|
+
if (!stmt.moduleSpecifier.text.startsWith("@assure-one/design-system")) continue;
|
|
322
|
+
const bindings = stmt.importClause?.namedBindings;
|
|
323
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
324
|
+
const present = new Set(bindings.elements.map((e) => (e.propertyName ?? e.name).text));
|
|
325
|
+
for (const element of bindings.elements) {
|
|
326
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
327
|
+
if (imported !== "SideDrawer" || element.propertyName) continue;
|
|
328
|
+
const names = [...needed].filter((n) => !present.has(n)).sort();
|
|
329
|
+
if (names.length) {
|
|
330
|
+
edits.push({ pos: element.getStart(sf), end: element.getEnd(), text: names.join(", ") });
|
|
331
|
+
} else {
|
|
332
|
+
const list = bindings.elements;
|
|
333
|
+
const index = list.indexOf(element);
|
|
334
|
+
const from = index === 0 ? element.getStart(sf) : list[index - 1].getEnd();
|
|
335
|
+
const to = index === 0 && list.length > 1 ? list[1].getStart(sf) : element.getEnd();
|
|
336
|
+
edits.push({ pos: from, end: to, text: "" });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
output: edits.length ? applyEdits(file.source, edits) : file.source,
|
|
344
|
+
findings,
|
|
345
|
+
notTransformed: [],
|
|
346
|
+
parseErrors: facts.parseErrors,
|
|
347
|
+
};
|
|
348
|
+
}
|