@cahyo-dimas/freeday 1.27.0 → 1.29.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/CHANGELOG.md CHANGED
@@ -3,6 +3,107 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.29.0] — 2026-08-18
7
+ Improvement note 001 from a second consuming app (a 40-screen back office). Eight findings; six
8
+ executed, one already documented, one left as an owner decision.
9
+ ### Added
10
+ - **`clearable` on `FdyCfl`** (Vue · React · Blazor). The value type already said `Row | null`, but
11
+ the emit was `Row` only — a choose-from-list could be **set and never unset**, which breaks every
12
+ *optional* foreign key (a device with no project, an expense with no workflow, a top-level record
13
+ with no parent). A user who picked the wrong row had to reload the form. The clear control is a
14
+ second `.fdy-input-group__btn`, emits `null`, returns focus to the trigger, and never touches the
15
+ dialog.
16
+ - **`.fdy-label--required`** — the marker is painted through `::after` with **CSS alt text**
17
+ (`content:"*" / ""`), so the glyph appears and the accessibility tree gets nothing. The control
18
+ already carries `required`; a `<span>` an app has to remember to mark `aria-hidden` can be
19
+ forgotten, this cannot.
20
+ - **`.fdy-icon`** — a `1em` square, `flex:none` box for the icons the kit deliberately does not
21
+ ship. Every icon slot it *does* ship (`.fdy-btn__icon`, `.fdy-nav__icon`, `.fdy-state__icon`)
22
+ already assumed something sensible inside; a standalone icon had no contract.
23
+ - **`.fdy-text-success` · `.fdy-text-warning` · `.fdy-text-danger`** — the kit had three
24
+ *de-emphasis* text roles and no *state* role, so a consequential sentence ("Amount changed from X
25
+ to Y", "No approver assigned") had to fall back to `.fdy-text-caption`, which de-emphasises
26
+ exactly the line that should stand out. A badge is wrong (prose, not a status) and an alert is
27
+ wrong (a block, not one line in a row).
28
+ ### Fixed
29
+ - **`.fdy-menu__item:focus-visible` gets a real ring.** It marked focus with the same fill as
30
+ `:hover`, and `freeday-menu.js` moves real DOM focus — so arrowing through a menu was invisible,
31
+ and hover and focus were identical to everyone else. `.fdy-nav__item`, in the same kit, has always
32
+ done it correctly. Reported by two different apps before it was fixed.
33
+ - **A grouped `<fieldset>` now has its spacing.** `fieldset.fdy-field` already reset the UA border,
34
+ but the **rendered legend is laid out outside the flex flow**, so `gap` never reached it —
35
+ measured 0px between legend and first control. One declaration on the reset the kit already
36
+ ships, not a new block; `COMPONENTS.md` documents the compose (`.fdy-field` on the fieldset,
37
+ `.fdy-label` on the legend), which is what was actually missing.
38
+ ### Notes on the shape of the fix
39
+ - **§7 (an Indonesian `aria-label`) is not what it looked like.** The report assumed the rest of the
40
+ kit's output is English; it is not — *every* user-visible string the vanilla enhancers write is
41
+ Indonesian (`Sebelumnya`, `Berikutnya`, `Berisi teks`, `Reset`, `Tutup`, `Menampilkan …`,
42
+ `Bulan berikutnya`, `Format tidak valid.`). Translating one would **create** the mixed interface
43
+ the note objects to. The real choice — an override hook, or switching the defaults and breaking
44
+ every Indonesian consumer — is recorded in `NEXT-UP.md` #6 with its trigger; `COMPONENTS.md` now
45
+ states the caveat in full and points English apps at the typed wrappers.
46
+ - **§8 needed no code**, only the line it asked for: the filter button and its dialog share an
47
+ accessible name on purpose, so a test suite wants `getByRole`, not `getByLabel`.
48
+ - Two traps found by measuring rather than reading: `.fdy-icon` as a bare inline element **ignored
49
+ `width` entirely** (it does not apply to non-replaced inline boxes) and rendered 936px wide until
50
+ it got a `display`; and stacking two colour roles (`.fdy-help.fdy-text-warning`) silently loses to
51
+ whichever rule the bundle happens to emit later — documented, and the docs page no longer does it.
52
+ ### Added — guards
53
+ - Adapter specs for `clearable` in both Vue and React (real clicks: emits `null`, empties the field,
54
+ the control disappears with the value it cleared, focus lands on the trigger, dialog stays shut).
55
+ - CSS gate: menu focus must render as more than the hover fill; the required marker must keep its
56
+ alt text; `.fdy-icon` must keep a display that accepts a width; the state roles must spend the
57
+ exact inks the contrast gate proves readable — that last one closes the seam between the two test
58
+ files, where a class could drift to a weaker token while every token assertion stayed green.
59
+ - `contrast.test.mjs` now also asserts the state inks on **plain** surfaces, not just over their own
60
+ `-soft` fills.
61
+
62
+ ## [1.28.0] — 2026-08-18
63
+ Improvement note 007, written while fixing a workspace picker whose cards showed a pointer cursor
64
+ and swallowed every click.
65
+ ### Added
66
+ - **`.fdy-btn--stretch`** — the pattern for **one card, one primary action, one escape hatch**, which
67
+ the kit had no shape for: `--button` is a card that *is* one control, `--interactive` a card that
68
+ merely *has* one, and a card with two actions can be neither (interactive content nested in a
69
+ `<button>` is invalid HTML). The primary control keeps its real `<button>`/`<a>` semantics and
70
+ spreads its hit area over the card with a pseudo-element.
71
+ This is shipped rather than documented because it **cannot be hand-rolled on a `.fdy-btn`**: the
72
+ button nudges itself with a transform on `:hover`/`:active`, a transformed element becomes the
73
+ containing block for its own absolutely positioned descendants, and the overlay therefore
74
+ re-anchors from the card to the button's own box mid-gesture. `mousedown` lands on the button,
75
+ `mouseup` somewhere else, and the browser fires `click` on their common ancestor — the button never
76
+ gets one. The symptom is identical to the bug being fixed.
77
+ The report found this on `--text`/`--ghost`, which break on press. The kit's **base** rule is
78
+ `.fdy-btn:hover{transform:translateY(-1px)}`, so the default and `--danger` fills break one step
79
+ earlier, on hover: neutralising only `:active` — the obvious half-fix — still leaks.
80
+ - **Escape hatches are raised automatically.** Every focusable element in a card holding a stretched
81
+ target sits above the overlay without markup or CSS from the consumer, because forgetting a
82
+ `z-index` here fails silently — the control looks and hovers exactly as before and simply never
83
+ receives the click.
84
+ ### Fixed
85
+ - `--interactive` is now documented as **presentational**: the only affordance in the kit whose
86
+ correctness lives outside it. The CSS cannot know whether a handler exists, so the modifier without
87
+ a control and a control without the modifier both fail silently. It now points at the shape that
88
+ makes the promise true.
89
+ ### Notes on the shape of the fix
90
+ - **Raising the escape hatch must not outweigh the app.** The first version declared
91
+ `position:relative` at normal specificity and *measurably* dragged an absolutely positioned corner
92
+ dismiss button back into the flow — a control the app had pinned itself, moved by the kit, with no
93
+ error. `z-index` still wins, but the `position` it needs is now a zero-specificity `:where()`
94
+ default that any app rule beats. The two rules must stay split; the gate asserts it.
95
+ - **Cards only.** The overlay anchors to the nearest *positioned* ancestor. `.fdy-list__row` is not
96
+ positioned (`.fdy-list` is), so a stretched target in a list row would cover the whole list —
97
+ documented, with `.fdy-list__row--button` as the answer for clickable rows.
98
+ ### Added — guards
99
+ - `browser/card-stretch.mjs` drives real presses: the target receives clicks from anywhere on the
100
+ card, the click is dispatched **on the button** rather than on a common ancestor, the secondary
101
+ action and an inline link keep their own clicks, and an app-pinned control keeps its own
102
+ `position`. A synthetic `.click()` never enters `:active` and passes against the broken CSS, which
103
+ is why this spec is worth its weight.
104
+ - `test/css.test.mjs` guards all of it at the CI gate. Mutation-checked against six defects,
105
+ including both of the ones written during this change.
106
+
6
107
  ## [1.27.0] — 2026-08-14
7
108
  Improvement note #44, found while building a settings screen whose only numeric field looked like it
8
109
  belonged to a different application.
package/COMPONENTS.md CHANGED
@@ -212,6 +212,8 @@ One role per level of hierarchy — never re-use a card title for a page title.
212
212
  | `.fdy-title-card` | `<h3>` | A title inside a card or row (`.fdy-card__title` is equivalent). |
213
213
  | `.fdy-text-muted` · `.fdy-text-subtle` | any | Secondary / tertiary text colour. |
214
214
  | `.fdy-text-caption` | `<p>` | Small muted text: timestamps, help. |
215
+ | `.fdy-text-success` · `.fdy-text-warning` · `.fdy-text-danger` | any | Inline text that carries **state** — a sentence, not a status. Use when a badge is wrong (it is prose) and an alert is wrong (it is one line inside a row): "Amount changed from X to Y", "No approver assigned". **Do not stack two colour roles** (`.fdy-help.fdy-text-warning`): both are single classes, so the one that happens to come later in the stylesheet wins — put the state class on its own element, and use `.fdy-help--error` for help text. |
216
+ | `.fdy-icon` | `<svg>` or a wrapper | Icon box: `1em` square, `flex:none`, so the glyph tracks the text beside it at every scale. The kit ships no paths — bring your own, use the box. |
215
217
  | `.fdy-mono` | any | Tabular/monospace data (codes, amounts, ids). |
216
218
 
217
219
  ## Utilities
@@ -291,7 +293,23 @@ native control, otherwise a `<div>` + explicitly associated label.
291
293
 
292
294
  - `.fdy-field` (+ `--full` inside `.fdy-form-grid`; widths `--w-sm` `--w-lg` `--w-xl` `--w-2xl`
293
295
  `--w-grow` inside `.fdy-filterbar`)
294
- - `.fdy-label` · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
296
+ - `.fdy-label` (+`--required`) · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
297
+
298
+ **Grouped controls are a `<fieldset>`, not a new block.** Put `.fdy-field` on the fieldset and
299
+ `.fdy-label` on the legend — the kit already resets the UA border/padding and supplies the spacing:
300
+
301
+ ```html
302
+ <fieldset class="fdy-field">
303
+ <legend class="fdy-label">Working week</legend>
304
+ <label class="fdy-check"><input type="checkbox"> Monday</label>
305
+ <label class="fdy-check"><input type="checkbox"> Tuesday</label>
306
+ </fieldset>
307
+ ```
308
+
309
+ **`--required` marks the label, not the accessibility tree.** The control already carries
310
+ `required`; the asterisk is painted through `::after` with CSS alt text, so a screen reader never
311
+ reads "star" after the label — something a `<span>` you have to remember to mark `aria-hidden`
312
+ cannot guarantee.
295
313
  - `[readonly]` is styled on input/textarea: full contrast, focusable, copyable.
296
314
 
297
315
  ```html
@@ -472,6 +490,13 @@ the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
472
490
  </dialog>
473
491
  ```
474
492
 
493
+ **`clearable` makes the value removable.** Without it a choose-from-list can be set but never unset,
494
+ which breaks every *optional* foreign key — the value type already allows null, only the component
495
+ could not produce it. With it, a clear button appears beside the trigger whenever a row is picked
496
+ and the field is editable; it emits `null` (Vue `update:modelValue`/`change`, React `onChange`,
497
+ Blazor `ValueChanged`) and returns focus to the trigger. It is a second `.fdy-input-group__btn`, not
498
+ a new class.
499
+
475
500
  ## Date picker — `data-fdy-datepicker`
476
501
  > **Typed wrapper: `<FdyDatepicker>`** — Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
477
502
 
@@ -660,10 +685,19 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
660
685
  - Footer: `.fdy-table-footer` · `__info` (`data-fdy-table-info`) + `<nav class="fdy-pagination"
661
686
  data-fdy-table-pagination>`
662
687
  - Sort values: put the raw value in `data-sort-value` when the cell text is formatted.
663
- - **Language caveat:** the vanilla enhancer writes the footer and bulk-count strings in Indonesian
664
- (`Menampilkan 1–5 dari 7`, `N dipilih`) and has no override hook. The Vue/React/Blazor `FdyTable`
665
- components are English. For an English static page, render those two nodes yourself from the
666
- `fdy-table-change` event instead of using `data-fdy-table-info` / `data-fdy-table-bulk-count`.
688
+ - **Language caveat:** every user-visible string the **vanilla enhancers** write is Indonesian
689
+ the table's footer and bulk count (`Menampilkan 1–5 dari 7`, `N dipilih`), its pager and filter
690
+ UI (`Sebelumnya`, `Berikutnya`, `Filter kolom`, `Berisi teks`, `Reset`, `Tutup`), and the same
691
+ applies elsewhere (`Bulan berikutnya`, `Format tidak valid.`). There is no override hook. The
692
+ Vue/React/Blazor components are English throughout, so **an English app should use the typed
693
+ wrapper**, not the enhancer. For an English static page, render the footer/bulk nodes yourself
694
+ from the `fdy-table-change` event instead of using `data-fdy-table-info` /
695
+ `data-fdy-table-bulk-count`; the filter button's `aria-label` has no such escape today.
696
+
697
+ **Testing note:** a column's filter button and the dialog it opens deliberately share one
698
+ accessible name (`Filter <column>`) — a dialog named after its trigger is the normal pattern. In a
699
+ Playwright/Testing-Library suite that means `getByLabel('Filter Name')` resolves to two elements;
700
+ reach for `getByRole('button', { name: 'Filter Name' })` instead.
667
701
 
668
702
  ## Pagination — `.fdy-pagination`
669
703
  The block class on the `<nav>` is a **structural hook only** — it carries no rule of its own; the
@@ -885,7 +919,11 @@ is `role="group" aria-roledescription="slide"`.
885
919
  Parts `__body` `__title` `__desc` `__footer`. Modifiers:
886
920
 
887
921
  - `--elevated` — raise it (use sparingly; most surfaces are flat)
888
- - `--interactive` — cursor + hover-lift for a card that *has* a click handler
922
+ - `--interactive` — cursor + hover-lift for a card that *has* a click handler. **Presentational
923
+ only:** it is the one affordance in the kit whose correctness lives outside it — the CSS cannot
924
+ know whether a handler exists, so the modifier without a control (a card that looks clickable and
925
+ swallows every click) and a control without the modifier both fail silently. Pair it with a real
926
+ control — normally the stretched target below.
889
927
  - `--button` — for a card that **is** the control: render it as `<button>` and add this to reset
890
928
  the UA button box **without** losing the card surface/border
891
929
  - `.fdy-card--button` never replaces keyboard semantics — a clickable card must be a real
@@ -895,6 +933,44 @@ Parts `__body` `__title` `__desc` `__footer`. Modifiers:
895
933
  - The card is `position:relative`, so a badge or ribbon you absolutely position inside it anchors
896
934
  to the card. That is also what keeps hidden labels inside it from escaping — see *Containment*.
897
935
 
936
+ ### One card, one primary action, one escape hatch
937
+
938
+ A card with **two** actions cannot be `--button`: interactive content nested in a `<button>` is
939
+ invalid HTML. Give the primary control `.fdy-btn--stretch` — its hit area covers the whole card,
940
+ while it stays a real `<button>`/`<a>` with real keyboard semantics.
941
+
942
+ ```html
943
+ <article class="fdy-card fdy-card--interactive">
944
+ <div class="fdy-card__body">
945
+ <h3 class="fdy-card__title">Workspace Alpha</h3>
946
+ <p class="fdy-card__desc">12 members · 4 boards</p>
947
+ </div>
948
+ <div class="fdy-card__footer">
949
+ <button class="fdy-btn fdy-btn--text" type="button">Details</button> <!-- escape hatch -->
950
+ <button class="fdy-btn fdy-btn--stretch" type="button">Open workspace Alpha</button>
951
+ </div>
952
+ </article>
953
+ ```
954
+
955
+ - **The escape hatch needs nothing.** Every focusable element in a card that holds a stretched
956
+ target is raised above the overlay automatically — forgetting a `z-index` here fails silently, and
957
+ a silent failure is what this pattern exists to remove. The `position` that raising needs is only a
958
+ **default** (zero-specificity), so a control you pin yourself — a corner dismiss, a favourite star —
959
+ keeps its own `position:absolute` and is raised anyway.
960
+ - **Name the stretched button for what the whole card does** ("Open workspace Alpha", not "Open").
961
+ Its label is the accessible name of the entire hit area.
962
+ - **Do not add `.fdy-btn--stretch` to more than one control per card** — the last one wins the
963
+ overlap, and which one that is depends on source order.
964
+ - Text under the overlay is no longer selectable; that is the cost of the pattern, not a bug.
965
+ - **Cards only.** The overlay anchors to the nearest *positioned* ancestor, and in the kit that is
966
+ `.fdy-card`. A `.fdy-list__row` is **not** positioned (`.fdy-list` is), so a stretched target in a
967
+ list row would cover the **whole list** — for clickable rows use `.fdy-list__row--button`.
968
+ - `.fdy-btn` nudges itself on `:hover`/`:active`, and a transformed element becomes the containing
969
+ block for its own absolutely positioned descendants — so a hand-rolled `::after{inset:0}` overlay
970
+ **re-anchors to the button mid-gesture** and the click never lands. `--stretch` neutralises those
971
+ transforms and moves the press feedback to the card. This is why the pattern is shipped rather
972
+ than documented.
973
+
898
974
  ## Badge — `.fdy-badge`
899
975
  Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only —
900
976
  the text carries the meaning.
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.29.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.29.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.29.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.29.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit — one source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -11,6 +11,15 @@
11
11
  <input class="fdy-input" type="text" readonly value="@DisplayValue"
12
12
  placeholder="@Placeholder" aria-labelledby="@AriaLabelledby"
13
13
  aria-invalid="@(Invalid ? "true" : null)" aria-readonly="@(Readonly ? "true" : null)" />
14
+ @if (Clearable && Value is not null && !Disabled && !Readonly)
15
+ {
16
+ <button class="fdy-input-group__btn" type="button" @onclick="ClearAsync" aria-label="@ClearLabel">
17
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
18
+ stroke-width="2" stroke-linecap="round" aria-hidden="true">
19
+ <path d="M6 6l12 12M18 6L6 18"></path>
20
+ </svg>
21
+ </button>
22
+ }
14
23
  <button class="fdy-input-group__btn" type="button" @onclick="OpenAsync"
15
24
  disabled="@(Disabled || Readonly)" aria-label="@SearchPlaceholder">
16
25
  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
@@ -37,6 +37,11 @@ public partial class FdyCfl<TRow>
37
37
  /// <summary>Locked/view mode: shows the value but the search dialog can't be opened.</summary>
38
38
  [Parameter] public bool Readonly { get; set; }
39
39
  [Parameter] public bool Invalid { get; set; }
40
+ /// <summary>Render a clear button when a row is picked, so an OPTIONAL foreign key can be unset.
41
+ /// <c>Value</c> is already <c>TRow?</c>; without this the component can never produce null.</summary>
42
+ [Parameter] public bool Clearable { get; set; }
43
+ /// <summary>aria-label for the clear button (when <see cref="Clearable"/>).</summary>
44
+ [Parameter] public string ClearLabel { get; set; } = "Clear selection";
40
45
  [Parameter] public string? AriaLabelledby { get; set; }
41
46
 
42
47
  /// <summary>Debounce (ms) between a keystroke and the search request.</summary>
@@ -129,6 +134,14 @@ public partial class FdyCfl<TRow>
129
134
 
130
135
  private Task RetryAsync() => LoadAsync(reset: true);
131
136
 
137
+ /// <summary>Unset the value. Not "choosing nothing": the dialog is not involved, so this neither
138
+ /// opens nor closes it.</summary>
139
+ private async Task ClearAsync()
140
+ {
141
+ Value = default;
142
+ await ValueChanged.InvokeAsync(default);
143
+ }
144
+
132
145
  private async Task ChooseAsync(TRow row)
133
146
  {
134
147
  Value = row;
@@ -22,7 +22,7 @@ export interface CflPage<Row> {
22
22
 
23
23
  export interface FdyCflProps<Row extends Record<string, unknown>> {
24
24
  value: Row | null;
25
- onChange: (value: Row) => void;
25
+ onChange: (value: Row | null) => void;
26
26
  fetchPage: (query: string, page: number) => Promise<CflPage<Row>>;
27
27
  columns: ReadonlyArray<CflColumn<Row>>;
28
28
  display: (row: Row) => string;
@@ -34,6 +34,11 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
34
34
  /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
35
35
  readonly?: boolean;
36
36
  invalid?: boolean;
37
+ /** Render a clear button when a row is picked, so an OPTIONAL foreign key can be unset. Without it
38
+ * `value` accepts `Row | null` but the component can only ever produce a `Row`. */
39
+ clearable?: boolean;
40
+ /** aria-label for the clear button (when `clearable`). Default 'Clear selection'. */
41
+ clearLabel?: string;
37
42
  describedby?: string;
38
43
  id?: string;
39
44
  ariaLabelledby?: string;
@@ -46,6 +51,14 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
46
51
  const resultsId: string = `${baseId}-results`;
47
52
  const rowId = (index: number): string => `${baseId}-row-${index}`;
48
53
 
54
+ /* Unsetting is not "picking nothing": it must not touch the dialog, and focus must land on a
55
+ control that still exists — the trigger beside it, since this button disappears with the value. */
56
+ const clearLabelText: string = props.clearLabel ?? 'Clear selection';
57
+ const clearValue = (): void => {
58
+ props.onChange(null);
59
+ triggerRef.current?.focus();
60
+ };
61
+
49
62
  const dialogRef = useRef<HTMLDialogElement>(null);
50
63
  const triggerRef = useRef<HTMLButtonElement>(null);
51
64
  const searchRef = useRef<HTMLInputElement>(null);
@@ -71,6 +84,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
71
84
 
72
85
  const isDisabled: boolean = props.disabled === true;
73
86
  const isReadonly: boolean = props.readonly === true;
87
+ const showClear: boolean =
88
+ props.clearable === true && props.value !== null && isDisabled === false && isReadonly === false;
74
89
  const isInvalid: boolean = props.invalid === true;
75
90
  const displayValue: string = props.value !== null ? props.display(props.value) : '';
76
91
  // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
@@ -253,6 +268,18 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
253
268
  aria-describedby={props.describedby}
254
269
  disabled={isDisabled}
255
270
  />
271
+ {showClear ? (
272
+ <button
273
+ type="button"
274
+ className="fdy-input-group__btn"
275
+ aria-label={clearLabelText}
276
+ onClick={clearValue}
277
+ >
278
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
279
+ <path d="M6 6l12 12M18 6L6 18" />
280
+ </svg>
281
+ </button>
282
+ ) : null}
256
283
  <button
257
284
  ref={triggerRef}
258
285
  type="button"
@@ -35,14 +35,19 @@ const props = defineProps<{
35
35
  /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
36
36
  readonly?: boolean;
37
37
  invalid?: boolean;
38
+ /** Render a clear button when a row is picked, so an OPTIONAL foreign key can be unset. Without it
39
+ * `modelValue` accepts `Row | null` but the component can only ever produce a `Row`. */
40
+ clearable?: boolean;
41
+ /** aria-label for the clear button (when `clearable`). Default 'Clear selection'. */
42
+ clearLabel?: string;
38
43
  describedby?: string;
39
44
  id?: string;
40
45
  ariaLabelledby?: string;
41
46
  }>();
42
47
 
43
48
  const emit = defineEmits<{
44
- 'update:modelValue': [value: Row];
45
- change: [value: Row];
49
+ 'update:modelValue': [value: Row | null];
50
+ change: [value: Row | null];
46
51
  }>();
47
52
 
48
53
  const baseId: string = useId();
@@ -67,6 +72,12 @@ const activeIndex: Ref<number> = ref(-1);
67
72
 
68
73
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
69
74
  const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
75
+
76
+ const showClear: ComputedRef<boolean> = computed(
77
+ (): boolean =>
78
+ props.clearable === true && props.modelValue != null && isDisabled.value === false && isReadonly.value === false,
79
+ );
80
+ const clearLabelText: ComputedRef<string> = computed((): string => props.clearLabel ?? 'Clear selection');
70
81
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
71
82
  const displayValue: ComputedRef<string> = computed((): string =>
72
83
  props.modelValue !== null ? props.display(props.modelValue) : '',
@@ -154,12 +165,20 @@ function setActive(index: number): void {
154
165
  });
155
166
  }
156
167
 
157
- function commit(row: Row): void {
168
+ function commit(row: Row | null): void {
158
169
  emit('update:modelValue', row);
159
170
  emit('change', row);
160
171
  closeDialog();
161
172
  }
162
173
 
174
+ /* Unsetting is not "picking nothing" — it must not open or close the dialog, and it must leave focus
175
+ on a control that still exists, so focus returns to the trigger beside it. */
176
+ function clearValue(): void {
177
+ emit('update:modelValue', null);
178
+ emit('change', null);
179
+ triggerEl.value?.focus();
180
+ }
181
+
163
182
  function onKeydown(e: KeyboardEvent): void {
164
183
  switch (e.key) {
165
184
  case 'ArrowDown':
@@ -256,6 +275,17 @@ onBeforeUnmount((): void => {
256
275
  :aria-describedby="describedby"
257
276
  :disabled="isDisabled"
258
277
  />
278
+ <button
279
+ v-if="showClear"
280
+ type="button"
281
+ class="fdy-input-group__btn"
282
+ :aria-label="clearLabelText"
283
+ @click="clearValue"
284
+ >
285
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
286
+ <path d="M6 6l12 12M18 6L6 18"></path>
287
+ </svg>
288
+ </button>
259
289
  <button
260
290
  ref="triggerEl"
261
291
  type="button"
@@ -711,6 +711,22 @@ a { color: var(--color-primary); }
711
711
  .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
712
712
  .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
713
713
 
714
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
715
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
716
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
717
+ * `.fdy-card` is already position:relative, so it is the anchor.
718
+ *
719
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
720
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
721
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
722
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
723
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
724
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
725
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
726
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
727
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
728
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
729
+
714
730
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
715
731
  .fdy-btn--icon{width:var(--control-h);padding:0;}
716
732
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -744,9 +760,13 @@ a { color: var(--color-primary); }
744
760
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
745
761
  .fdy-card--interactive{cursor:pointer;}
746
762
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
763
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
764
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
765
+ block and the disabled rules so both still win at equal specificity. */
766
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
747
767
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
748
768
  * doesn't jump. The shadow still communicates the affordance. */
749
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
769
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
750
770
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
751
771
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
752
772
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -755,6 +775,17 @@ a { color: var(--color-primary); }
755
775
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
756
776
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
757
777
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
778
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
779
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
780
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
781
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
782
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
783
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
784
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
785
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
786
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
787
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
788
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
758
789
  .fdy-card__body{padding:var(--space-5);}
759
790
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
760
791
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -1020,6 +1051,22 @@ a { color: var(--color-primary); }
1020
1051
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
1021
1052
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
1022
1053
 
1054
+ /* Icon box (#001 §4). The kit ships no icons on purpose, but every icon slot it DOES ship
1055
+ (.fdy-btn__icon, .fdy-nav__icon, .fdy-state__icon) assumes something sensible inside, so a
1056
+ standalone icon had no contract and every app invented one. 1em keeps the glyph matched to the
1057
+ text beside it at every scale — bring your own paths, use our box. Works on the <svg> itself or
1058
+ on a wrapper around it. */
1059
+ .fdy-icon{display:inline-block;width:1em;height:1em;flex:none;vertical-align:-0.125em;}
1060
+ .fdy-icon>svg{display:block;width:100%;height:100%;}
1061
+
1062
+ /* Inline text that carries STATE (#001 §5). The three roles above all de-emphasise; a sentence like
1063
+ "Amount changed from X to Y" or "No approver assigned" needs the opposite, and is neither a badge
1064
+ (those are statuses, not sentences) nor an .fdy-alert (those are blocks, not one line inside a
1065
+ row). Same -strong inks the badges use, gated by contrast.test.mjs on plain surfaces too. */
1066
+ .fdy-text-success{color:var(--color-success-strong);}
1067
+ .fdy-text-warning{color:var(--color-warning-strong);}
1068
+ .fdy-text-danger{color:var(--color-danger-strong);}
1069
+
1023
1070
  /* Freeday — Date picker (input-styled trigger + calendar popover).
1024
1071
  * Built by freeday-datepicker.js from an empty [data-fdy-datepicker] wrapper. */
1025
1072
  .fdy-datepicker{position:relative;display:inline-block;width:100%;max-width:14rem;}
@@ -1246,7 +1293,10 @@ a { color: var(--color-primary); }
1246
1293
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
1247
1294
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
1248
1295
  fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
1249
- fieldset.fdy-field>legend{padding:0;float:none;}
1296
+ /* The rendered legend is laid out outside the fieldset's flex flow, so `gap` never reaches it —
1297
+ measured 0px between legend and first control. This is the spacing contract a grouped field
1298
+ needs; it matches .fdy-field's own gap so the group reads as one rhythm. */
1299
+ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1250
1300
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
1251
1301
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
1252
1302
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -1270,6 +1320,11 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1270
1320
  .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
1271
1321
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
1272
1322
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
1323
+ /* Required marker. The control already carries `required`, so the asterisk is decoration: the
1324
+ `/ ""` is CSS alt text — the glyph is painted but exposes NOTHING to the accessibility tree, so a
1325
+ screen reader does not read "star" after every label. That is stricter than a <span> an app has to
1326
+ remember to mark aria-hidden, and it cannot be forgotten. */
1327
+ .fdy-label--required::after{content:"*" / "";margin-left:var(--space-1);color:var(--color-danger);}
1273
1328
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
1274
1329
  .fdy-help--error{color:var(--color-danger);}
1275
1330
 
@@ -1320,7 +1375,10 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1320
1375
  .fdy-menu--end{left:auto;right:0;}
1321
1376
  .fdy-menu__item{display:flex;align-items:center;gap:var(--space-2);width:100%;padding:var(--space-2) var(--space-3);border:0;background:transparent;border-radius:var(--radius-sm);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);text-align:left;cursor:pointer;white-space:nowrap;}
1322
1377
  .fdy-menu__item:hover,.fdy-menu__item.is-highlighted{background:var(--color-surface-3);}
1323
- .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);}
1378
+ /* Focus is not hover. The enhancer moves real DOM focus (freeday-menu.js `.focus()`), so this is
1379
+ the only thing a keyboard user has to go on — with the hover fill alone, arrowing through the
1380
+ menu is invisible. Same ring as .fdy-nav__item, which got this right. Reported twice. */
1381
+ .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
1324
1382
  .fdy-menu__item:disabled{opacity:.5;cursor:not-allowed;background:transparent;}
1325
1383
  .fdy-menu__item--danger{color:var(--color-danger-strong);}
1326
1384
  .fdy-menu__item svg{display:block;width:1rem;height:1rem;flex:none;}
package/dist/freeday.css CHANGED
@@ -341,6 +341,22 @@ a { color: var(--color-primary); }
341
341
  .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
342
342
  .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
343
343
 
344
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
345
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
346
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
347
+ * `.fdy-card` is already position:relative, so it is the anchor.
348
+ *
349
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
350
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
351
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
352
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
353
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
354
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
355
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
356
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
357
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
358
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
359
+
344
360
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
345
361
  .fdy-btn--icon{width:var(--control-h);padding:0;}
346
362
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -374,9 +390,13 @@ a { color: var(--color-primary); }
374
390
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
375
391
  .fdy-card--interactive{cursor:pointer;}
376
392
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
393
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
394
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
395
+ block and the disabled rules so both still win at equal specificity. */
396
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
377
397
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
378
398
  * doesn't jump. The shadow still communicates the affordance. */
379
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
399
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
380
400
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
381
401
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
382
402
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -385,6 +405,17 @@ a { color: var(--color-primary); }
385
405
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
386
406
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
387
407
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
408
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
409
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
410
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
411
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
412
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
413
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
414
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
415
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
416
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
417
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
418
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
388
419
  .fdy-card__body{padding:var(--space-5);}
389
420
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
390
421
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -650,6 +681,22 @@ a { color: var(--color-primary); }
650
681
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
651
682
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
652
683
 
684
+ /* Icon box (#001 §4). The kit ships no icons on purpose, but every icon slot it DOES ship
685
+ (.fdy-btn__icon, .fdy-nav__icon, .fdy-state__icon) assumes something sensible inside, so a
686
+ standalone icon had no contract and every app invented one. 1em keeps the glyph matched to the
687
+ text beside it at every scale — bring your own paths, use our box. Works on the <svg> itself or
688
+ on a wrapper around it. */
689
+ .fdy-icon{display:inline-block;width:1em;height:1em;flex:none;vertical-align:-0.125em;}
690
+ .fdy-icon>svg{display:block;width:100%;height:100%;}
691
+
692
+ /* Inline text that carries STATE (#001 §5). The three roles above all de-emphasise; a sentence like
693
+ "Amount changed from X to Y" or "No approver assigned" needs the opposite, and is neither a badge
694
+ (those are statuses, not sentences) nor an .fdy-alert (those are blocks, not one line inside a
695
+ row). Same -strong inks the badges use, gated by contrast.test.mjs on plain surfaces too. */
696
+ .fdy-text-success{color:var(--color-success-strong);}
697
+ .fdy-text-warning{color:var(--color-warning-strong);}
698
+ .fdy-text-danger{color:var(--color-danger-strong);}
699
+
653
700
  /* Freeday — Date picker (input-styled trigger + calendar popover).
654
701
  * Built by freeday-datepicker.js from an empty [data-fdy-datepicker] wrapper. */
655
702
  .fdy-datepicker{position:relative;display:inline-block;width:100%;max-width:14rem;}
@@ -876,7 +923,10 @@ a { color: var(--color-primary); }
876
923
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
877
924
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
878
925
  fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
879
- fieldset.fdy-field>legend{padding:0;float:none;}
926
+ /* The rendered legend is laid out outside the fieldset's flex flow, so `gap` never reaches it —
927
+ measured 0px between legend and first control. This is the spacing contract a grouped field
928
+ needs; it matches .fdy-field's own gap so the group reads as one rhythm. */
929
+ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
880
930
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
881
931
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
882
932
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -900,6 +950,11 @@ fieldset.fdy-field>legend{padding:0;float:none;}
900
950
  .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
901
951
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
902
952
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
953
+ /* Required marker. The control already carries `required`, so the asterisk is decoration: the
954
+ `/ ""` is CSS alt text — the glyph is painted but exposes NOTHING to the accessibility tree, so a
955
+ screen reader does not read "star" after every label. That is stricter than a <span> an app has to
956
+ remember to mark aria-hidden, and it cannot be forgotten. */
957
+ .fdy-label--required::after{content:"*" / "";margin-left:var(--space-1);color:var(--color-danger);}
903
958
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
904
959
  .fdy-help--error{color:var(--color-danger);}
905
960
 
@@ -950,7 +1005,10 @@ fieldset.fdy-field>legend{padding:0;float:none;}
950
1005
  .fdy-menu--end{left:auto;right:0;}
951
1006
  .fdy-menu__item{display:flex;align-items:center;gap:var(--space-2);width:100%;padding:var(--space-2) var(--space-3);border:0;background:transparent;border-radius:var(--radius-sm);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);text-align:left;cursor:pointer;white-space:nowrap;}
952
1007
  .fdy-menu__item:hover,.fdy-menu__item.is-highlighted{background:var(--color-surface-3);}
953
- .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);}
1008
+ /* Focus is not hover. The enhancer moves real DOM focus (freeday-menu.js `.focus()`), so this is
1009
+ the only thing a keyboard user has to go on — with the hover fill alone, arrowing through the
1010
+ menu is invisible. Same ring as .fdy-nav__item, which got this right. Reported twice. */
1011
+ .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
954
1012
  .fdy-menu__item:disabled{opacity:.5;cursor:not-allowed;background:transparent;}
955
1013
  .fdy-menu__item--danger{color:var(--color-danger-strong);}
956
1014
  .fdy-menu__item svg{display:block;width:1rem;height:1rem;flex:none;}
@@ -184,7 +184,7 @@ live docs also have a copy button per component.
184
184
  ```bash
185
185
  npm i @cahyo-dimas/freeday
186
186
  ```
187
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.27.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.29.0"` (public npm package). `dist/` is
188
188
  committed and published → no build step; `npm ci` runs without auth.
189
189
 
190
190
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "scripts": {
72
72
  "build": "node tokens/build.mjs",
73
73
  "test": "node --test",
74
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs",
74
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs",
75
75
  "prepack": "node tokens/build.mjs",
76
76
  "version": "node tokens/build.mjs && git add dist",
77
77
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"
@@ -45,6 +45,22 @@
45
45
  .fdy-btn--ghost[aria-pressed="true"],.fdy-btn--text[aria-pressed="true"]{background:var(--color-primary-soft);color:var(--color-primary-strong);border-color:var(--color-primary-border);box-shadow:none;}
46
46
  .fdy-btn--ghost[aria-pressed="true"]:hover,.fdy-btn--text[aria-pressed="true"]:hover{background:var(--color-primary-soft);transform:none;box-shadow:none;}
47
47
 
48
+ /* Stretched hit area (#007) — spread this button's click target over its nearest positioned
49
+ * ancestor, so "one card, one primary action, one escape hatch" needs no click handler on the
50
+ * <article>: the real control stays a real <button>/<a> and keeps its keyboard semantics.
51
+ * `.fdy-card` is already position:relative, so it is the anchor.
52
+ *
53
+ * The transforms MUST go, and this is the whole reason the modifier exists rather than a docs line:
54
+ * a transformed element becomes the containing block for its own absolutely-positioned descendants,
55
+ * so `inset:0` re-anchors from the card to the button's own box the moment the button is hovered or
56
+ * pressed. Measured: mousedown lands on the button, the overlay collapses under the pointer, mouseup
57
+ * lands on some other element, and the browser fires `click` on their common ancestor — the button
58
+ * never gets one. It demos fine on hover for --text/--ghost (their hover is already transform:none)
59
+ * and only a real press reveals it; the default and --danger fills break one step earlier, on hover.
60
+ * Press feedback moves to the card, which is the object actually being pressed (see card.css). */
61
+ .fdy-btn--stretch::after{content:'';position:absolute;inset:0;}
62
+ .fdy-btn--stretch:hover,.fdy-btn--stretch:active{transform:none;}
63
+
48
64
  /* Icon-only — square; combine with --ghost/--text/--sm/--lg. Requires aria-label. */
49
65
  .fdy-btn--icon{width:var(--control-h);padding:0;}
50
66
  .fdy-btn--icon.fdy-btn--sm{width:calc(var(--control-h) - var(--space-2));}
@@ -5,9 +5,13 @@
5
5
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
6
6
  .fdy-card--interactive{cursor:pointer;}
7
7
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
8
+ /* Pressing a stretched target (see .fdy-btn--stretch) is pressing the CARD — the button's own
9
+ nudge had to go, and the object under the pointer is the card. Ordered before the reduced-motion
10
+ block and the disabled rules so both still win at equal specificity. */
11
+ .fdy-card--interactive:has(.fdy-btn--stretch:active){transform:translateY(-1px);}
8
12
  /* The global reduced-motion reset kills the ANIMATION; drop the -3px shift too so the card
9
13
  * doesn't jump. The shadow still communicates the affordance. */
10
- @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover{transform:none;}}
14
+ @media (prefers-reduced-motion:reduce){.fdy-card--interactive:hover,.fdy-card--interactive:has(.fdy-btn--stretch:active){transform:none;}}
11
15
  /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
12
16
  * announced — reset the UA button box. Note: it does NOT reset background/border, because
13
17
  * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
@@ -16,6 +20,17 @@
16
20
  --interactive lift is withdrawn so a refusing control stops answering the pointer. */
17
21
  .fdy-card--button:disabled,.fdy-card--button[aria-disabled="true"],.fdy-card--interactive[aria-disabled="true"]{opacity:.5;cursor:not-allowed;}
18
22
  .fdy-card--interactive:disabled:hover,.fdy-card--interactive[aria-disabled="true"]:hover{box-shadow:var(--shadow-lift);transform:none;}
23
+ /* Escape hatches stay clickable above a stretched target — automatic, not a documented convention,
24
+ because forgetting it fails SILENTLY: the secondary control looks and hovers exactly as before and
25
+ simply never receives the click, which is the very symptom the pattern exists to cure. Matched by
26
+ shape (anything focusable) so a control the kit does not ship yet is covered too. */
27
+ .fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch){z-index:1;}
28
+ /* The position is a DEFAULT, not an override: z-index needs a positioned box (or a flex/grid item),
29
+ but an app that deliberately pins a control inside the card — a corner dismiss, a favourite star —
30
+ must keep its own `position:absolute`. Wrapped in :where() so this whole selector weighs nothing
31
+ and ANY app rule wins it; measured, because at normal specificity the kit silently dragged an
32
+ absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
33
+ :where(.fdy-card:has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
19
34
  .fdy-card__body{padding:var(--space-5);}
20
35
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
21
36
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -47,3 +47,19 @@
47
47
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
48
48
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
49
49
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
50
+
51
+ /* Icon box (#001 §4). The kit ships no icons on purpose, but every icon slot it DOES ship
52
+ (.fdy-btn__icon, .fdy-nav__icon, .fdy-state__icon) assumes something sensible inside, so a
53
+ standalone icon had no contract and every app invented one. 1em keeps the glyph matched to the
54
+ text beside it at every scale — bring your own paths, use our box. Works on the <svg> itself or
55
+ on a wrapper around it. */
56
+ .fdy-icon{display:inline-block;width:1em;height:1em;flex:none;vertical-align:-0.125em;}
57
+ .fdy-icon>svg{display:block;width:100%;height:100%;}
58
+
59
+ /* Inline text that carries STATE (#001 §5). The three roles above all de-emphasise; a sentence like
60
+ "Amount changed from X to Y" or "No approver assigned" needs the opposite, and is neither a badge
61
+ (those are statuses, not sentences) nor an .fdy-alert (those are blocks, not one line inside a
62
+ row). Same -strong inks the badges use, gated by contrast.test.mjs on plain surfaces too. */
63
+ .fdy-text-success{color:var(--color-success-strong);}
64
+ .fdy-text-warning{color:var(--color-warning-strong);}
65
+ .fdy-text-danger{color:var(--color-danger-strong);}
@@ -4,7 +4,10 @@
4
4
  Neutralise the UA groove border / padding / min-inline-size so it lays out identically to the
5
5
  <div> form (min-inline-size:min-content would otherwise block the filterbar column widths). */
6
6
  fieldset.fdy-field{border:0;margin:0;padding:0;min-inline-size:0;}
7
- fieldset.fdy-field>legend{padding:0;float:none;}
7
+ /* The rendered legend is laid out outside the fieldset's flex flow, so `gap` never reaches it —
8
+ measured 0px between legend and first control. This is the spacing contract a grouped field
9
+ needs; it matches .fdy-field's own gap so the group reads as one rhythm. */
10
+ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
8
11
  .fdy-label{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--color-text);}
9
12
  .fdy-input,.fdy-textarea{width:100%;height:var(--control-h);padding:0 var(--space-3);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);background:var(--color-surface);border:1.5px solid var(--color-control-border);border-radius:var(--radius-md);box-shadow:inset 0 1px 2px rgba(16,14,30,.06);transition:border-color var(--dur-fast) var(--ease-standard),box-shadow var(--dur-fast) var(--ease-standard);}
10
13
  .fdy-input::placeholder,.fdy-textarea::placeholder{color:var(--color-text-subtle);}
@@ -28,5 +31,10 @@ fieldset.fdy-field>legend{padding:0;float:none;}
28
31
  .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
29
32
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
30
33
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
34
+ /* Required marker. The control already carries `required`, so the asterisk is decoration: the
35
+ `/ ""` is CSS alt text — the glyph is painted but exposes NOTHING to the accessibility tree, so a
36
+ screen reader does not read "star" after every label. That is stricter than a <span> an app has to
37
+ remember to mark aria-hidden, and it cannot be forgotten. */
38
+ .fdy-label--required::after{content:"*" / "";margin-left:var(--space-1);color:var(--color-danger);}
31
39
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
32
40
  .fdy-help--error{color:var(--color-danger);}
@@ -5,7 +5,10 @@
5
5
  .fdy-menu--end{left:auto;right:0;}
6
6
  .fdy-menu__item{display:flex;align-items:center;gap:var(--space-2);width:100%;padding:var(--space-2) var(--space-3);border:0;background:transparent;border-radius:var(--radius-sm);font-family:var(--font-body);font-size:var(--text-sm);color:var(--color-text);text-align:left;cursor:pointer;white-space:nowrap;}
7
7
  .fdy-menu__item:hover,.fdy-menu__item.is-highlighted{background:var(--color-surface-3);}
8
- .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);}
8
+ /* Focus is not hover. The enhancer moves real DOM focus (freeday-menu.js `.focus()`), so this is
9
+ the only thing a keyboard user has to go on — with the hover fill alone, arrowing through the
10
+ menu is invisible. Same ring as .fdy-nav__item, which got this right. Reported twice. */
11
+ .fdy-menu__item:focus-visible{outline:none;background:var(--color-surface-3);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 45%,transparent);}
9
12
  .fdy-menu__item:disabled{opacity:.5;cursor:not-allowed;background:transparent;}
10
13
  .fdy-menu__item--danger{color:var(--color-danger-strong);}
11
14
  .fdy-menu__item svg{display:block;width:1rem;height:1rem;flex:none;}