cleanplate 0.3.19 → 0.3.21

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.
@@ -16,6 +16,26 @@ FormControls is a set of form primitives exported as a namespace: `FormControls.
16
16
  | Toggle | On/off switch | checked, defaultChecked, onChange(checked: boolean) |
17
17
  | Stepper | Numeric value with integrated − / + (integer text field + `min` / `max` / `step`) | placeholder, value, onChange(e), min, max, step, layout |
18
18
 
19
+ ## E2E / test selectors (overview)
20
+
21
+ Pass **`dataTestId="my-field"`** on any control below. Patterns are consistent across the library so package consumers can write the same Playwright habits everywhere.
22
+
23
+ | Control | Root id on | Primary interaction id | Common suffixes |
24
+ | --- | --- | --- | --- |
25
+ | **Input** | native `<input>` | same as root (`.fill()`) | `-clear`, `-prefix`, `-suffix`, `-error` |
26
+ | **TextArea** | native `<textarea>` | same as root | `-error` |
27
+ | **Select** | field wrapper | `-trigger` (open), `-option-{value}` (pick) | `-panel`, `-search`, `-search-clear`, `-listbox`, `-clear`, `-input`, `-error` |
28
+ | **Date** | field wrapper | `-trigger`, `-day-YYYY-MM-DD` | `-panel`, `-grid`, `-done`, `-cancel`, `-clear`, `-input`, … |
29
+ | **Checkbox** | `<fieldset>` | `-label-{value}` or `-input-{value}` | `-options`, `-option-{value}` |
30
+ | **Radio** | `<fieldset>` | `-label-{value}` or `-input-{value}` | same as Checkbox |
31
+ | **File** | hidden `<input type="file">` | same as root (`setInputFiles`) | `-trigger`, `-list`, `-item-{i}`, `-remove-{i}` |
32
+ | **Toggle** | native switch `<input>` | `-label` (click) or root (`.check()`) | `-error` |
33
+ | **Stepper** | numeric `<input>` | same as root | `-increment`, `-decrement`, `-error` |
34
+
35
+ `{value}` keys sanitize option values: non-alphanumeric characters become `-` (e.g. `premium_plus` → `premium-plus`).
36
+
37
+ Detail sections below expand each control. Component source files also document suffixes in **`dataTestId` JSDoc**.
38
+
19
39
  ## Types
20
40
 
21
41
  ### Option (and SelectOption)
@@ -112,6 +132,7 @@ interface InputProps {
112
132
  isFluid?: boolean;
113
133
  className?: string;
114
134
  error?: string;
135
+ /** On the native `<input>`; suffixed `-clear`, `-prefix`, `-suffix`, `-error` — see **Input — E2E / test selectors**. */
115
136
  dataTestId?: string;
116
137
  /** Native `autocomplete` attribute. */
117
138
  autoComplete?: string;
@@ -139,15 +160,16 @@ interface InputProps {
139
160
  ```
140
161
 
141
162
  ### Other control types
142
- - **TextAreaProps**: label, value/defaultValue, onChange, isDisabled, isRequired, isFluid, className, error, dataTestId.
143
- - **FileProps**: `name`, `label`, `variant` (`"button" | "card"`, default `"button"`), `multiple`, `accept`, `value: File[]` (controlled), `defaultValue: File[]` (uncontrolled initial visual list), `onChange(files: File[], e?)`, `buttonLabel` (default `"Browse file"`), `dropZoneText` (default `"Drag files to upload"`, card variant only), plus the common `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, `dataTestId`. The card variant supports drag-and-drop. **FileVariant** = `"button" | "card"`.
144
- - **RadioProps**: `options` (non-empty `RadioOption[]`), `name`, `label` (group `<legend>`), optional `id`, `value`, `defaultValue`, `onChange(value, e)`, `orientation` (`"vertical" | "horizontal"`), `variant` (`"default" | "card"`), `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, `dataTestId`.
145
- - **RadioOption**: `{ label, value, isDisabled?, description?, icon?, dataTestId?, id? }`. `description` is rendered under the option label as muted secondary text and linked via `aria-describedby`. `icon` accepts any `ReactNode` (e.g. `<Icon />`, `<img />`, custom SVG) and renders to the left of the label/description.
146
- - **ToggleProps**: checked, defaultChecked, onChange(checked: boolean), label, isDisabled, isRequired, isFluid, className, error, dataTestId.
147
- - **CheckboxProps**: `options` (non-empty `CheckboxOption[]`), `name`, `label` (group `<legend>`), optional `id`, `value` (`CheckboxValue[]`), `defaultValue` (`CheckboxValue[]`), `onChange(values, e)`, `orientation` (`"vertical" | "horizontal"`), `variant` (`"default" | "card"`), `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, `dataTestId`.
148
- - **CheckboxOption**: `{ label, value, isDisabled?, description?, icon?, dataTestId?, id? }`. `description` is rendered under the option label as muted secondary text and linked via `aria-describedby`. `icon` accepts any `ReactNode` (e.g. `<Icon />`, `<img />`, custom SVG) and renders to the left of the label/description. `CheckboxValue = string | number`.
149
- - **DateProps**: `value` / `defaultValue` (`Date | null`), `onChange(date: Date | null)`, `placeholder`, **`dateFormat`** (display string via `date-fns` + `locale`, default `MMM dd, yyyy`), **`name`** (renders a hidden `<input>` that submits **`yyyy-MM-dd`** for the committed calendar date), **`minDate`** / **`maxDate`** (inclusive navigation + selection bounds), **`disabledDates`** / **`disabledDaysOfWeek`** (greyed cells), **`locale`** (`date-fns` `Locale` — grid, subview copy, and field text), **`weekStartsOn`** (`0`–`6`, default `0` = Sunday), **`clearable`** (default `true`; shows clear control when a value exists), **`readOnly`** (no picker; value fixed), **`popoverPlacement`** (Floating UI placement for desktop; default `bottom-start`), **`onOpen`** / **`onClose`**, plus shared `label`, `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, `dataTestId`.
150
- - **FormControlsStepperProps**: label, placeholder, value/defaultValue, onChange(e), min, max, step, layout (`"default" | "split-controls" | "trailing-stacked-chevrons"`), isDisabled, isRequired, isFluid, className, error, dataTestId.
163
+ - **TextAreaProps**: label, value/defaultValue, onChange, isDisabled, isRequired, isFluid, className, error, **`dataTestId`** (on `<textarea>`; `-error` — see overview).
164
+ - **SelectProps**: **`dataTestId`** (wrapper root; `-trigger`, `-panel`, `-option-{value}`, see **Select E2E / test selectors**).
165
+ - **FileProps**: `name`, `label`, `variant` (`"button" | "card"`, default `"button"`), `multiple`, `accept`, `value: File[]` (controlled), `defaultValue: File[]` (uncontrolled initial visual list), `onChange(files: File[], e?)`, `buttonLabel` (default `"Browse file"`), `dropZoneText` (default `"Drag files to upload"`, card variant only), plus the common `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, **`dataTestId`** (on file input; `-trigger`, `-list`, `-item-{i}`, `-remove-{i}` — see **File — E2E / test selectors**). The card variant supports drag-and-drop. **FileVariant** = `"button" | "card"`.
166
+ - **RadioProps**: `options` (non-empty `RadioOption[]`), `name`, `label` (group `<legend>`), optional `id`, `value`, `defaultValue`, `onChange(value, e)`, `orientation` (`"vertical" | "horizontal"`), `variant` (`"default" | "card"`), `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, **`dataTestId`** (root on `<fieldset>`; suffixed ids on options container, rows, inputs, and labels see **Checkbox and Radio — E2E / test selectors**).
167
+ - **RadioOption**: `{ label, value, isDisabled?, description?, icon?, dataTestId?, id? }`. `description` is rendered under the option label as muted secondary text and linked via `aria-describedby`. `icon` accepts any `ReactNode` (e.g. `<Icon />`, `<img />`, custom SVG) and renders to the left of the label/description. **`dataTestId`** on an option overrides the group-derived `-input-{value}` id for that option's native `<input>`.
168
+ - **CheckboxProps**: `options` (non-empty `CheckboxOption[]`), `name`, `label` (group `<legend>`), optional `id`, `value` (`CheckboxValue[]`), `defaultValue` (`CheckboxValue[]`), `onChange(values, e)`, `orientation` (`"vertical" | "horizontal"`), `variant` (`"default" | "card"`), `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, **`dataTestId`** (root on `<fieldset>`; suffixed ids on options container, rows, inputs, and labels — see **Checkbox and Radio — E2E / test selectors**).
169
+ - **CheckboxOption**: `{ label, value, isDisabled?, description?, icon?, dataTestId?, id? }`. `description` is rendered under the option label as muted secondary text and linked via `aria-describedby`. `icon` accepts any `ReactNode` (e.g. `<Icon />`, `<img />`, custom SVG) and renders to the left of the label/description. **`dataTestId`** on an option overrides the group-derived `-input-{value}` id for that option's native `<input>`. `CheckboxValue = string | number`.
170
+ - **DateProps**: `value` / `defaultValue` (`Date | null`), `onChange(date: Date | null)`, `placeholder`, **`dateFormat`** (display string via `date-fns` + `locale`, default `MMM dd, yyyy`), **`name`** (renders a hidden `<input>` that submits **`yyyy-MM-dd`** for the committed calendar date), **`minDate`** / **`maxDate`** (inclusive navigation + selection bounds), **`disabledDates`** / **`disabledDaysOfWeek`** (greyed cells), **`locale`** (`date-fns` `Locale` — grid, subview copy, and field text), **`weekStartsOn`** (`0`–`6`, default `0` = Sunday), **`clearable`** (default `true`; shows clear control when a value exists), **`readOnly`** (no picker; value fixed), **`popoverPlacement`** (Floating UI placement for desktop; default `bottom-start`), **`onOpen`** / **`onClose`**, plus shared `label`, `isDisabled`, `isRequired`, `isFluid`, `className`, `error`, **`dataTestId`** (see **Date — E2E / test selectors**).
171
+ - **ToggleProps**: checked, defaultChecked, onChange(checked: boolean), label, isDisabled, isRequired, isFluid, className, error, **`dataTestId`** (on switch input; `-label`, `-error`).
172
+ - **FormControlsStepperProps**: label, placeholder, value/defaultValue, onChange(e), min, max, step, layout (`"default" | "split-controls" | "trailing-stacked-chevrons"`), isDisabled, isRequired, isFluid, className, error, **`dataTestId`** (on input; `-increment`, `-decrement`, `-error`).
151
173
 
152
174
  ## Usage Examples
153
175
 
@@ -200,6 +222,82 @@ const [tags, setTags] = useState([{ label: "A", value: "a" }]);
200
222
 
201
223
  **Mobile:** At **viewport width ≤768px**, the panel opens as a **bottom sheet** (fixed to the lower viewport) with dialog semantics when a **label** is present, instead of a floating anchored list.
202
224
 
225
+ ### Input — E2E / test selectors
226
+
227
+ Unlike grouped controls (Checkbox, Radio), **`dataTestId` is placed on the native `<input>`** so Playwright can target it directly for `.fill()` / `.type()` without an extra `-input` suffix.
228
+
229
+ Pass **`dataTestId="email"`**:
230
+
231
+ | Target | `data-testid` |
232
+ | --- | --- |
233
+ | Native `<input>` | `email` |
234
+ | Search clear button | `email-clear` (only when `type="search"` and the field has a value) |
235
+ | Prefix affix | `email-prefix` (when `prefix` is set) |
236
+ | Suffix affix | `email-suffix` (when `suffix` is set) |
237
+ | Error message | `email-error` (when `error` is set) |
238
+
239
+ Affixes are ignored for `type="search"` (search uses icon + clear instead).
240
+
241
+ #### Playwright examples
242
+
243
+ ```ts
244
+ // Default / text / number / password
245
+ await page.getByTestId("email").fill("user@example.com");
246
+
247
+ // Search + clear
248
+ await page.getByTestId("search-input").fill("query");
249
+ await page.getByTestId("search-input-clear").click();
250
+
251
+ // Amount with affixes
252
+ await page.getByTestId("amount-input").fill("42");
253
+ await expect(page.getByTestId("amount-input-error")).toHaveText("Required");
254
+ ```
255
+
256
+ ### TextArea, Toggle, and Stepper — E2E / test selectors
257
+
258
+ **TextArea:** `dataTestId` on the `<textarea>`; `-error` when `error` is set.
259
+
260
+ ```ts
261
+ await page.getByTestId("message-textarea").fill("Hello");
262
+ ```
263
+
264
+ **Toggle:** `dataTestId` on the switch input; prefer `-label` for clicks.
265
+
266
+ ```ts
267
+ await page.getByTestId("notifications-toggle-label").click();
268
+ await expect(page.getByTestId("notifications-toggle")).toBeChecked();
269
+ ```
270
+
271
+ **Stepper:** `dataTestId` on the numeric input; `-increment` / `-decrement` on step buttons.
272
+
273
+ ```ts
274
+ await page.getByTestId("qty-stepper-increment").click();
275
+ await expect(page.getByTestId("qty-stepper")).toHaveValue("2");
276
+ ```
277
+
278
+ ### Select — E2E / test selectors
279
+
280
+ Pass **`dataTestId="fruit-select"`** on the field. The **root** id is on the field wrapper; open and pick via suffixed ids:
281
+
282
+ | Suffix | Element |
283
+ | --- | --- |
284
+ | *(root)* | Field wrapper |
285
+ | `-trigger` | Combobox (open panel) |
286
+ | `-clear` | Clear selection (when `clearable`) |
287
+ | `-panel` | Floating panel / mobile sheet |
288
+ | `-search` | Panel search input (`searchable`) |
289
+ | `-search-clear` | Clear panel search |
290
+ | `-listbox` | Options list |
291
+ | `-option-{value}` | Option row |
292
+ | `-input` | Hidden `name` field (form submit) |
293
+ | `-error` | Validation message |
294
+
295
+ ```ts
296
+ await page.getByTestId("fruit-select-trigger").click();
297
+ await page.getByTestId("fruit-select-option-mango").click();
298
+ await expect(page.getByTestId("fruit-select-input")).toHaveValue("mango");
299
+ ```
300
+
203
301
  ### Input with prefix / suffix
204
302
 
205
303
  ```jsx
@@ -249,6 +347,27 @@ import { de } from "date-fns/locale/de";
249
347
  />
250
348
  ```
251
349
 
350
+ ### Date — E2E / test selectors
351
+
352
+ Pass **`dataTestId="dob"`** on the field:
353
+
354
+ | Suffix | Element |
355
+ | --- | --- |
356
+ | *(root)* | Field wrapper |
357
+ | `-trigger` | Open picker |
358
+ | `-clear` | Clear committed value (`clearable`) |
359
+ | `-panel` | Calendar dialog |
360
+ | `-grid` | Day grid |
361
+ | `-day-YYYY-MM-DD` | Day button |
362
+ | `-cancel` / `-done` | Footer actions |
363
+ | `-input` | Hidden `name` field (`yyyy-MM-dd`) |
364
+
365
+ ```ts
366
+ await page.getByTestId("dob-trigger").click();
367
+ await page.getByTestId("dob-day-2026-05-18").click();
368
+ await page.getByTestId("dob-done").click();
369
+ ```
370
+
252
371
  ### Checkbox group
253
372
 
254
373
  Pass an `options` array. The component renders the entire group inside a `<fieldset>` + `<legend>` and emits `onChange(values, event)` with the next array of selected values. The required `*` is rendered on the group label, not on individual options.
@@ -258,6 +377,7 @@ const [interests, setInterests] = useState(["product"]);
258
377
  <FormControls.Checkbox
259
378
  label="Email me about"
260
379
  name="interests"
380
+ dataTestId="interests"
261
381
  value={interests}
262
382
  onChange={(v) => setInterests(v)}
263
383
  options={[
@@ -312,6 +432,7 @@ const [plan, setPlan] = useState("std");
312
432
  <FormControls.Radio
313
433
  label="Shipping"
314
434
  name="ship"
435
+ dataTestId="shipping"
315
436
  value={plan}
316
437
  onChange={(v) => setPlan(String(v))}
317
438
  isRequired
@@ -356,6 +477,63 @@ import { FormControls, Icon } from "cleanplate";
356
477
  />
357
478
  ```
358
479
 
480
+ ### Checkbox and Radio — E2E / test selectors
481
+
482
+ Both **Checkbox** and **Radio** share the same suffix scheme. Pass **`dataTestId="interests"`** on the group to get stable Playwright / Testing Library hooks:
483
+
484
+ | Suffix | Element |
485
+ | --- | --- |
486
+ | *(root)* | `<fieldset>` wrapper |
487
+ | `-options` | Options container (`role="group"` / `role="radiogroup"`) |
488
+ | `-option-{value}` | Option row |
489
+ | `-input-{value}` | Native `<input>` (visually hidden) |
490
+ | `-label-{value}` | Clickable label (preferred for Playwright clicks) |
491
+
492
+ `{value}` is derived from each option's `value` prop: non-alphanumeric characters become `-` (e.g. `premium_plus` → `premium-plus`, `std` → `std`).
493
+
494
+ **Per-option override:** set **`dataTestId`** on a **`CheckboxOption`** / **`RadioOption`** to replace only that option's `-input-{value}` id. Row and label suffixes still use the group base + value key.
495
+
496
+ #### Playwright — Checkbox (multi-select)
497
+
498
+ ```ts
499
+ await page.getByTestId("interests-label-newsletter").click();
500
+ await expect(page.getByTestId("interests-input-newsletter")).toBeChecked();
501
+ await page.getByTestId("interests-label-product").click();
502
+ await expect(page.getByTestId("interests-input-product")).toBeChecked();
503
+ ```
504
+
505
+ #### Playwright — Radio (single-select)
506
+
507
+ ```ts
508
+ await page.getByTestId("shipping-label-express").click();
509
+ await expect(page.getByTestId("shipping-input-express")).toBeChecked();
510
+ await expect(page.getByTestId("shipping-input-std")).not.toBeChecked();
511
+ ```
512
+
513
+ #### Direct input check (also works)
514
+
515
+ ```ts
516
+ await page.getByTestId("shipping-input-std").check();
517
+ ```
518
+
519
+ ### File — E2E / test selectors
520
+
521
+ Pass **`dataTestId="upload"`**. The **root** id is on the hidden file input (use `setInputFiles`); related UI uses suffixes:
522
+
523
+ | Suffix | Element |
524
+ | --- | --- |
525
+ | *(root)* | Hidden `<input type="file">` |
526
+ | `-trigger` | Browse / drop-zone label |
527
+ | `-list` | Selected files list |
528
+ | `-item-{i}` | File row |
529
+ | `-remove-{i}` | Remove button |
530
+
531
+ ```ts
532
+ await page.getByTestId("upload").setInputFiles("fixtures/doc.pdf");
533
+ await expect(page.getByTestId("upload-item-0")).toContainText("doc.pdf");
534
+ await page.getByTestId("upload-remove-0").click();
535
+ ```
536
+
359
537
  ### File (button variant)
360
538
 
361
539
  Compact trigger that looks like a primary button. Selected files render below the trigger as small cards with a type-specific thumbnail icon, name, size, and a remove button.
@@ -399,10 +577,15 @@ Pagination uses `FormControls.Select` for rows-per-page. Pills uses `FormControl
399
577
  - **Input (`prefix` / `suffix`):** Inline leading/trailing text affix for currency (`$`), country code (`+91`), unit (`kg`, `%`), TLD (`.com`), etc. Soft-capped at 4 characters so the layout stays predictable; longer strings are truncated. When set, the field's outer wrapper takes over the visible border / padding / focus ring so the affixes read as part of the same input. Affixes are linked to the input via `aria-describedby`, so screen readers announce e.g. "Amount, dollars, $500" when the visible affix is `$`. For symbols/abbreviations that don't read well, pass `prefixA11yLabel` / `suffixA11yLabel` (e.g. `prefix="$"`, `prefixA11yLabel="dollars"`). Ignored when `type="search"` (search already uses both edges) — for any other `type`, including `number`, affixes work as expected.
400
578
  - **Input (validation / constraints):** `maxLength` is passed straight to the native attribute (works for any `type`). `min` / `max` are passed to the native attribute (HTML5 form-validation hints) and, for `type="number"` only, also clamped on `blur` — the user can finish typing freely and the value snaps to the bound when they leave the field.
401
579
  - **Input (`autoComplete` / `onBlur`):** `autoComplete` maps to the native attribute (`"email"`, `"current-password"`, `"off"`, …). `onBlur` runs after any internal numeric clamp so consumers see the final value.
580
+ - **Input (`dataTestId`):** Applied on the native `<input>` for direct E2E fill/type. Optional suffixed ids: `-clear` (search), `-prefix`, `-suffix` (affix layout), `-error` (validation message). See **Input — E2E / test selectors**.
581
+ - **TextArea / Toggle / Stepper (`dataTestId`):** On the native control; see **TextArea, Toggle, and Stepper — E2E / test selectors**.
582
+ - **Select (`dataTestId`):** Wrapper root plus `-trigger`, `-panel`, `-option-{value}`, etc. See **Select — E2E / test selectors**.
583
+ - **Date (`dataTestId`):** Wrapper root plus calendar suffixes. See **Date — E2E / test selectors**.
584
+ - **File (`dataTestId`):** On file input plus `-trigger`, `-list`, `-item-{i}`, `-remove-{i}`. See **File — E2E / test selectors**.
402
585
  - **Select:** Built on **Floating UI** — desktop uses a **portalled** panel with flip/shift to stay in the viewport; panel **width** matches the trigger, with optional **`panelMinWidth`** when options need more horizontal space; **`searchable={false}`** hides the panel search field (full static list, or async `onSearch("")` on open); **≤768px** uses a **bottom sheet** (`role="dialog"`, `aria-modal`, `aria-labelledby` to the field label when the label exists). **Option** shape supports `group`, `icon`, `avatar`, `meta`, `disabled`. **`mode`** (`'single' | 'multi'`) replaces **`isMulti`** (still supported, deprecated). Single mode: **`onChange(Option | null)`** — `null` when cleared. Multi mode: **`onChange(Option[])`** — use **`[]`** for clear. **`name` + hidden `<input>`:** native form submit posts the selected **`value`**(s); **multi** joins with **commas** — avoid comma characters inside `value` if you rely on `FormData`, or parse manually. **`options={null}` + `onSearch`:** async loading; show loading/empty/error states in the panel. **`groups`:** sticky headings for shared `Option.group`. **`maxSelect`:** multi only; **`triggerMaxItems`:** chip overflow **`+N`**. **`aria-controls`** on the combobox trigger and panel search point at the listbox **only while open**. **`aria-invalid`** reflects **`error`** on trigger, search field, and listbox. Validation message uses **`role="alert"`** (via shared field error pattern).
403
586
  - **Date:** **`Date | null`** with **`onChange`**. Opens a **`role="dialog"`** calendar: **staging** applies on day tap; **Cancel** reverts to the last committed value; **OK** commits (and clears staging). **Desktop:** portalled Floating UI panel with flip/shift, fixed **max width ~400px** (capped by viewport). **≤768px:** bottom sheet fixed to the lower viewport + dimmed backdrop, `aria-modal`, body scroll lock while open (same breakpoint idea as Select). **Header:** month cluster + year cluster (44px arrow hits); tapping month/year opens **scrollable subviews** with **back (`arrow_back`)** and headings **“Select a month of {yyyy}”** / **“Select a year for {MMMM}”** (locale-aware via `locale`). **Trigger:** **`calendar_month`** trailing icon (not Select chevrons); optional **clear** when `clearable`. **`readOnly`** and **`isDisabled`** block interaction. Constraints: **`minDate`/`maxDate`** (inclusive), **`disabledDates`**, **`disabledDaysOfWeek`**. **`dateFormat`** + **`locale`** control the field string; grid labels follow **`locale`** and **`weekStartsOn`**. **`name`:** hidden input posts **`yyyy-MM-dd`** for the **committed** value only. **`onOpen`/`onClose`** fire when the panel opens/closes. **`popoverPlacement`** adjusts desktop anchor (default `bottom-start`). **`error`** / **`isRequired`** use the shared field error pattern (`aria-invalid`, message under the field).
404
- - **Radio:** Group-first API — pass `options: RadioOption[]`. Renders `<fieldset>` + `<legend>` with a single `value` and `onChange(value, e)`. `isRequired` puts `*` on the legend and adds `required`/`aria-required` to the first enabled option (HTML5 only requires one input in the group to carry it). Custom ring/dot follows the native `:checked` state so uncontrolled groups stay visually correct. Pass `variant="card"` for tile-style options (ring in top-right, optional `icon` on the left, primary-brand border + tint when selected).
405
- - **Checkbox:** Group-first API — pass `options: CheckboxOption[]`. Renders `<fieldset>` + `<legend>` with a `value: CheckboxValue[]` and `onChange(values, e)`. `isRequired` puts `*` on the legend and sets `aria-required` on the group; native HTML5 doesn't enforce "at least one" for checkbox groups, so add custom validation at the form layer. Custom box/tick follows the native `:checked` state. Pass `variant="card"` for tile-style options (box in top-right, optional `icon` on the left, primary-brand border + tint when checked). For a single checkbox, pass a one-element `options` array — `value=[]` is unchecked, `value=[opt.value]` is checked.
587
+ - **Radio:** Group-first API — pass `options: RadioOption[]`. Renders `<fieldset>` + `<legend>` with a single `value` and `onChange(value, e)`. `isRequired` puts `*` on the legend and adds `required`/`aria-required` to the first enabled option (HTML5 only requires one input in the group to carry it). Custom ring/dot follows the native `:checked` state so uncontrolled groups stay visually correct. Pass `variant="card"` for tile-style options (ring in top-right, optional `icon` on the left, primary-brand border + tint when selected). **`dataTestId`** on the group maps to the fieldset and emits suffixed ids (`-options`, `-option-{value}`, `-input-{value}`, `-label-{value}`); per-option `dataTestId` overrides the input suffix only.
588
+ - **Checkbox:** Group-first API — pass `options: CheckboxOption[]`. Renders `<fieldset>` + `<legend>` with a `value: CheckboxValue[]` and `onChange(values, e)`. `isRequired` puts `*` on the legend and sets `aria-required` on the group; native HTML5 doesn't enforce "at least one" for checkbox groups, so add custom validation at the form layer. Custom box/tick follows the native `:checked` state. Pass `variant="card"` for tile-style options (box in top-right, optional `icon` on the left, primary-brand border + tint when checked). For a single checkbox, pass a one-element `options` array — `value=[]` is unchecked, `value=[opt.value]` is checked. **`dataTestId`** uses the same suffix scheme as Radio.
406
589
  - **File:** Native `<input type="file">` is visually hidden but stays in the a11y tree. Manages a `File[]` selection internally; `onChange(files, e)` fires for picker selections, drops, and removals (the underlying event is `undefined` for non-picker triggers). With `multiple`, subsequent picks/drops append; without, the new selection replaces the old. The card variant supports drag-and-drop and tints primary-brand on hover. Removing a file resets the native input so re-selecting the same file still emits a change. `defaultValue` seeds the visual list only — browsers don't allow programmatic pre-population of file inputs.
407
590
  - **isFluid:** Full-width field wrapper.
408
591
 
package/docs/Modal.md CHANGED
@@ -22,6 +22,7 @@ Purpose: A full-featured modal overlay for forms, long content, or custom dialog
22
22
  | onPrimaryButtonClick | () => void | no | — | Called when the primary footer button is clicked. |
23
23
  | secondaryButtonLabel | string | no | "" | Label for the secondary footer button; empty hides it. |
24
24
  | onSecondaryButtonClick | () => void | no | — | Called when the secondary footer button is clicked. |
25
+ | dataTestId | string | no | — | Root `data-testid` on the dialog panel; suffixed ids on overlay, header, title, close, body, footer, and action buttons (see **E2E / test selectors**). |
25
26
 
26
27
  ## Types
27
28
 
@@ -59,9 +60,39 @@ interface ModalProps {
59
60
  onPrimaryButtonClick?: () => void;
60
61
  secondaryButtonLabel?: string;
61
62
  onSecondaryButtonClick?: () => void;
63
+ dataTestId?: string;
62
64
  }
63
65
  ```
64
66
 
67
+ ## E2E / test selectors
68
+
69
+ Pass `dataTestId="save-modal"` to get stable Playwright / Testing Library hooks:
70
+
71
+ | Suffix | Element |
72
+ | --- | --- |
73
+ | *(root)* | Dialog panel (`role="dialog"`) |
74
+ | `-overlay` | Backdrop overlay |
75
+ | `-header` | Header row (title + close button) |
76
+ | `-title` | Title text |
77
+ | `-close` | Header close (X) button |
78
+ | `-body` | Main content area |
79
+ | `-footer` | Footer action row |
80
+ | `-primary` | Primary footer button |
81
+ | `-secondary` | Secondary footer button |
82
+
83
+ Header, title, close, footer, and button suffixes are omitted when those regions are not rendered (e.g. no `title` and `showCloseButton={false}` hides `-header`, `-title`, and `-close`).
84
+
85
+ ### Playwright example
86
+
87
+ ```ts
88
+ await page.getByRole("button", { name: "Open settings" }).click();
89
+ await expect(page.getByTestId("settings-modal")).toBeVisible();
90
+ await expect(page.getByTestId("settings-modal-title")).toHaveText("Settings");
91
+ await page.getByTestId("settings-modal-body").getByLabel("Display name").fill("Ada");
92
+ await page.getByTestId("settings-modal-primary").click();
93
+ await expect(page.getByTestId("settings-modal")).toBeHidden();
94
+ ```
95
+
65
96
  ## Usage Examples
66
97
 
67
98
  ### Basic
@@ -135,6 +166,23 @@ const App = () => {
135
166
  </Modal>
136
167
  ```
137
168
 
169
+ ### With test selectors
170
+
171
+ ```jsx
172
+ <Modal
173
+ isOpen={isOpen}
174
+ onClose={() => setIsOpen(false)}
175
+ title="Delete item"
176
+ dataTestId="delete-item-modal"
177
+ primaryButtonLabel="Delete"
178
+ onPrimaryButtonClick={handleDelete}
179
+ secondaryButtonLabel="Cancel"
180
+ onSecondaryButtonClick={() => setIsOpen(false)}
181
+ >
182
+ <Typography variant="p">This action cannot be undone.</Typography>
183
+ </Modal>
184
+ ```
185
+
138
186
  ## Behavior Notes
139
187
 
140
188
  - **Rendering:** The modal mounts when `isOpen` is true and unmounts shortly after close so the exit transition can finish.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cleanplate",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "CleanPlate - A Headless React UI Framework",
5
5
  "files": [
6
6
  "dist",