@cahyo-dimas/freeday 3.2.0 → 3.3.1

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
@@ -9,6 +9,115 @@ benar. Perubahan seperti itu ditulis di bawah `### Changed: BREAKING (types)`
9
9
  lama → tipe baru, dan cara menyempitkannya — bukan di bawah `### Added`, betapapun aditifnya dari
10
10
  sisi kit.
11
11
 
12
+ ## [3.3.1] - 2026-09-07
13
+
14
+ **Tautan lewati yang tak punya tujuan, di tiga stack sekaligus.** `#056` (dari `keamanan-cluster`)
15
+ menunjuk satu kalimat di COMPONENTS.md — *"the markup below is for stacks without an adapter (and is
16
+ what the wrapper renders)"* — lalu menunjukkan bahwa untuk `.fdy-app__main` kalimat itu tidak benar.
17
+ Markup mentahnya membawa `id="main"`; ketiga wrapper typed tidak membawa apa pun. Jadi `.fdy-skip`
18
+ yang didokumentasikan dengan `href="#main"` merender tautan yang menerima fokus lalu tidak
19
+ memindahkan siapa pun. Ini bentuk kegagalan yang paling sulit terlihat: tak ada galat, tak ada yang
20
+ tampak salah, dan pemeriksaan visual tak akan pernah menangkapnya — app yang melaporkannya memilih
21
+ **tidak memasang tautan lewati sama sekali**, karena tak ada yang bisa dituju.
22
+
23
+ ### Fixed
24
+ - **`FdyAppShell` merender `<main id="main" tabindex="-1">` di Vue, React dan Blazor** (`#056` §1).
25
+ `id` menyamakan wrapper dengan markup mentah yang sudah didokumentasikan; `tabindex="-1"` diambil
26
+ dari `docs/reference-screen.html`, yang selama ini lebih benar daripada skeleton di COMPONENTS.md —
27
+ tanpa itu tautan fragmen menggulung halaman tanpa memindahkan fokus, sehingga Tab berikutnya
28
+ melanjutkan dari tempat pembaca tadi, bukan dari tempat ia dikirim. Skeleton COMPONENTS.md ikut
29
+ disamakan. Dijaga uji paritas baru di `browser/adapter.mjs` (Vue, React) dan
30
+ `test/blazor/FdyAppShellTests.cs` (Blazor).
31
+
32
+ ### Docs
33
+ - **`.fdy-title-page` menyebut level headingnya di dalam shell** (`#056` §2). Tabel tipografi
34
+ menuliskan `<h1>`, "One per screen", sementara `docs/reference-screen.html` memakai
35
+ `<h2 class="fdy-title-page">` karena `.fdy-app__title` di topbar sudah menjadi `<h1>` layar itu.
36
+ Menyusun keduanya persis seperti dokumentasi menghasilkan dua `<h1>` — yang justru dilarang
37
+ kalimat di sebelahnya. Tabelnya kini menyebut kedua kasus; keputusan yang dipakai adalah keputusan
38
+ yang sudah diambil reference screen, bukan keputusan baru.
39
+
40
+ ## [3.3.0] - 2026-08-28
41
+
42
+ **Tiga kemampuan yang sebenarnya sudah ada di kit ini, dan tak satu pun bisa diminta.** `#054` dan
43
+ `#055` datang dari tiga app berbeda dan bermuara pada satu bentuk kegagalan yang sama: kemampuannya
44
+ ada — di jalur mentah, di satu container, di satu varian — tapi **kosakata untuk memintanya tidak**.
45
+ Itu jenis gap yang paling mahal, karena tak terlihat seperti gap: yang membacanya menyimpulkan kit
46
+ tak bisa, lalu membangun ulang. Satu di antaranya membuat dua console menulis 23 override yang sama,
47
+ satu lagi membuat sebuah panel menyimpan dialog 420 baris di sebelah komponen yang melakukan hal itu
48
+ persis, dan yang ketiga bertahan empat bulan hanya karena satu kalimat dokumentasi salah.
49
+
50
+ ### Added
51
+ - **`FdyCfl` bisa dibuka oleh sesuatu yang bukan field-nya sendiri** (`#054`, dari
52
+ `IDU_AI_DOC_SAPB1_ADDON_DESKTOP_APP`). Enhancer punya `[data-fdy-cfl-open]` — *"opens the dialog
53
+ with no bound field"* — sejak ia terbit, dan COMPONENTS.md mendaftarnya. Ketiga wrapper typed
54
+ adalah **field plus dialog** dengan `openDialog` terkunci di dalam: tak ada ref, tak ada prop, tak
55
+ ada pintu. Jadi jalur mentah bisa melakukan hal yang jalur typed tidak — satu-satunya asimetri yang
56
+ dilarang klaim paritas kit ini. Panel 420px yang melaporkannya memicu picker dari **chip 12px**,
57
+ dua per baris dokumen; ia tak bisa membelanjakan satu readonly input 22rem per pemicu, jadi ia
58
+ menyimpan `CflGridDialog.vue` 420 baris. Sekarang: prop **`dialogOnly`** merender dialog tanpa
59
+ field, dan **`open()`/`close()`** terjangkau — Vue lewat `defineExpose`, React lewat
60
+ `FdyCflHandle` di `ref`, Blazor lewat `OpenAsync()`/`CloseAsync()` yang kini `public`. Host-nya
61
+ `display:contents` (`.fdy-cfl__host`), jadi ia **tak menghasilkan box sama sekali**: div berukuran
62
+ nol pun tetap jadi flex item atau sel grid di tempat pemicunya diletakkan, dan itu persis yang
63
+ akan merusak barisan chip. Blazor tak butuh host — ia memang sudah merender field dan dialog
64
+ sebagai sibling. `disabled`/`readonly` tetap menolak membuka, karena tanpa field tak ada tombol
65
+ disabled yang menahan klik: penjaganya harus ada di `open()` sendiri, dan itu yang diuji.
66
+ - **`.fdy-field--full` melepas cap 22rem di mana pun field itu berada** (`#055` §1, dari
67
+ `IDU_AI_DOC_SAPB1_CLIENT` **dan** `IDU_AI_DOC_SAAS` — dua consumer independen yang meraih override
68
+ yang sama adalah buktinya, jumlahnya adalah argumennya). Kit sudah mengirim idenya:
69
+ `.fdy-form-grid > .fdy-field { max-width: none }`. Tapi setiap form nyata adalah **flex column**,
70
+ bukan grid itu — `.fdy-form-grid` adalah `repeat(auto-fit,minmax(14rem,1fr))`, yang memasangkan
71
+ field berdua begitu ada ruang. Benar untuk header dokumen, salah untuk settings pane, kartu login,
72
+ atau dialog 420px. Dan di luar grid, modifier-nya bukan sekadar "tak melepas cap": **ia tak
73
+ melakukan apa-apa**, satu-satunya rule-nya ber-scope grid. Jadi form kolom tunggal tak punya cara
74
+ mengatakan maksudnya, dan keduanya mengatakannya di container — 23 blok. Kit ini pun menulisnya
75
+ **empat kali di halaman referensinya sendiri** sebagai `style="max-width:none"`; keempatnya kini
76
+ `fdy-field--full`. Di dalam grid tak ada yang berubah (rule grid sudah mengatakannya), dan
77
+ `grid-column` tetap urusan grid.
78
+ - **`--fdy-app-sidebar-w`** (default `15.5rem`) di `.fdy-app__sidebar`. Kedua console meng-override
79
+ `width`-nya ke `16.5rem` — yaitu meng-override rule yang juga menganimasikan collapse.
80
+
81
+ ### Fixed
82
+ - **Shell bisa memuat anak yang menggulung** (`#055` §2). `.fdy-app__content` punya `min-width:0`
83
+ dan **tidak** `min-height:0`. Di flex column, sumbu **block**-lah yang menjebak overflow: automatic
84
+ minimum size sebuah flex item adalah kontennya, jadi consumer yang memaku shell ke viewport lalu
85
+ menggulung `.fdy-app__main` mendorong shell-nya terbuka, bukan menggulung. Topbar cerita yang sama
86
+ satu baris di bawah: flex item tanpa `flex`, jadi topbar yang kontennya membungkus melewati
87
+ `min-height` bisa terperas. Kedua console menulis kedua rule itu sendiri-sendiri, di `app.css`,
88
+ sebelum layar kelima mereka. Keduanya kini di kit; tak ada layout yang hari ini benar yang berubah.
89
+ - **Kotak pencarian `.fdy-cfl__search` tak lagi ter-cap 22rem.** Empat berkas menulis ini sebagai
90
+ `style="max-width:none"` — kedua adapter typed dan kedua contoh docs — yaitu kosakata yang hilang
91
+ dari `#055` §1, satu class di sebelahnya. Sekarang satu rule, nol inline style.
92
+
93
+ ### Docs
94
+ - **`.fdy-nav--flat` akhirnya dijelaskan sebagai dirinya sendiri** (`#055` §3, dan ini temuan yang
95
+ lebih berharga daripada item aslinya). Catatan itu meminta "bentuk resmi untuk nav grup statis".
96
+ Bentuk itu **sudah ada sejak 1.1.0** dan melakukan persis tiga hal yang kedua console tulis
97
+ tangan: caret hilang, `cursor:default`, pemisah antar-grup hilang — plus dukungan markup
98
+ non-`<details>`, yang persis kasus `<p class="fdy-nav__grouplabel">` mereka. Yang menyembunyikannya
99
+ adalah satu kalimat di COMPONENTS.md: *"`--flat` drops the surface"*, yang **tidak benar** —
100
+ `.fdy-nav` tak mengecat surface apa pun untuk dibuang. Deskripsi yang akurat hanya hidup di entri
101
+ CHANGELOG 1.1.0. Dua consumer membaca satu kalimat yang salah, menyimpulkan varian itu bukan
102
+ untuk mereka, lalu membangun ulang sesuatu yang sudah mereka punya, selama empat bulan.
103
+ **Pelajarannya bukan tentang nav:** deskripsi varian yang salah lebih mahal daripada varian yang
104
+ hilang, karena yang hilang menghasilkan laporan dan yang salah menghasilkan pekerjaan diam-diam.
105
+ - `COMPONENTS.md`: `.fdy-field--full` kini dijelaskan sebagai pelepas cap (bukan hanya span kolom),
106
+ plus paragraf "single-column form is not a grid"; tabel props `<FdyCfl>` dapat `dialogOnly` dan
107
+ satu bagian tentang membuka picker lewat ref; `--fdy-app-sidebar-w` didaftar di App shell.
108
+
109
+ ### Guarded
110
+ - **Empat gerbang baru, semuanya diverifikasi dengan mutasi.** `test/css.test.mjs`: `--full` melepas
111
+ cap **dan** berada setelah `.fdy-field` (spesifisitas sama, jadi urutan sumber adalah seluruh
112
+ mekanismenya); shell punya kedua `min-*` dan topbar punya `flex:none`. `test/docs.test.mjs`:
113
+ paritas `#054` di tiga stack **plus** hook `[data-fdy-cfl-open]` di enhancer — kalau hook itu
114
+ hilang, gerbangnya membandingkan dengan ketiadaan. `browser/cfl-dialog-only.mjs`: 4 test Chrome
115
+ nyata (Vue + React), dibuka lewat **klik sungguhan** pada chip milik app, bukan dengan memanggil
116
+ method — plus satu asersi yang mengukur **akibat** dan bukan CSS: fixture-nya flex row bergap 8px
117
+ dengan komponen di antara dua chip, jadi host yang punya box terbaca sebagai 16px. Asersi itu juga
118
+ yang menangkap kesalahan pertama penulisnya: hitungan `.fdy-input-group` yang naif menghitung
119
+ **kotak pencarian di dalam dialog**, bukan field.
120
+
12
121
  ## [3.2.0] - 2026-08-28
13
122
 
14
123
  **Dua cacat yang tak bisa di-screenshot, dan satu komponen yang akhirnya muat di panel 420px.**
package/COMPONENTS.md CHANGED
@@ -142,6 +142,8 @@ off-canvas drawer and backdrop are built in.
142
142
 
143
143
  - Parts: `__sidebar` `__brand` (`__brand-mark` `__brand-text` `__brand-title` `__brand-subtitle`)
144
144
  `__content` `__topbar` `__navtoggle` `__title` `__main` `__backdrop`
145
+ - Sidebar width: `--fdy-app-sidebar-w` on `.fdy-app` (default `15.5rem`). Set it rather than
146
+ overriding `width` on `.fdy-app__sidebar`, whose value also feeds the collapse transition.
145
147
  - Modifiers: `--nav-open` (drawer open) · `--nav-collapsed` (collapse to zero width, ≥721px) ·
146
148
  `--nav-overlay` (≥721px: float the nav over the content instead of displacing it) · `--static`
147
149
  (embed the shell in a page instead of filling the viewport)
@@ -154,7 +156,10 @@ positioning rules differ, and only above the breakpoint. It also changes which s
154
156
  On the raw path, if you add or remove the class yourself, call
155
157
  **`FreedayAppShell.refresh(root)`** afterwards — the shell has to re-read the DOM, or `inert` and
156
158
  `aria-expanded` keep describing the arrangement you just left.
157
- - Also: `.fdy-skip`, the skip-to-content link, first child of the shell.
159
+ - Also: `.fdy-skip`, the skip-to-content link, first child of the shell. Its target is
160
+ `__main`, which carries `id="main"` **and `tabindex="-1"`** — without the latter a fragment
161
+ link scrolls the page without moving focus, so the next Tab resumes from where the reader
162
+ was, not from where they were sent. The typed wrappers render both.
158
163
  - **Behaviour: `freeday-app-shell.js`.** Opt in with `data-fdy-app` on the root. The markup below
159
164
  is unchanged. It owns the toggle in both modes plus everything an overlay needs that hand-rolling
160
165
  reliably forgets: Escape, backdrop click, closing when a `.fdy-nav__item` is followed, focus moved
@@ -207,7 +212,7 @@ tall containing block. The brand goes in the **sidebar** (sized to match the top
207
212
  <h1 class="fdy-app__title">Invoices</h1><!-- auto-spacer: pushes what follows right -->
208
213
  <!-- topbar actions -->
209
214
  </header>
210
- <main class="fdy-app__main" id="main"><!-- .fdy-page goes here --></main>
215
+ <main class="fdy-app__main" id="main" tabindex="-1"><!-- .fdy-page goes here --></main>
211
216
  </div>
212
217
 
213
218
  <div class="fdy-app__backdrop"></div>
@@ -281,7 +286,7 @@ One role per level of hierarchy. Never re-use a card title for a page title.
281
286
  | Class | Element | Use |
282
287
  |---|---|---|
283
288
  | `.fdy-eyebrow` | `<p>` | Small uppercase label above a title. Optional. |
284
- | `.fdy-title-page` | `<h1>` | One per screen. |
289
+ | `.fdy-title-page` | `<h1>` · `<h2>` in a shell | One `<h1>` per screen. Standalone page: `<h1>`. Inside `.fdy-app`, the topbar's `.fdy-app__title` is already that `<h1>`, so the page title steps down to `<h2>` — as `docs/reference-screen.html` does. |
285
290
  | `.fdy-title-section` | `<h2>` | A region inside the page. |
286
291
  | `.fdy-title-card` | `<h3>` | A title inside a card or row (`.fdy-card__title` is equivalent). |
287
292
  | `.fdy-text-muted` · `.fdy-text-subtle` | any | Secondary / tertiary text colour. |
@@ -422,15 +427,22 @@ WAI-ARIA APG menu-button. Keyboard ↑/↓/Home/End/Esc handled by the enhancer.
422
427
  The field wrapper owns the vertical rhythm; `<label class="fdy-field">` when it wraps a single
423
428
  native control, otherwise a `<div>` + explicitly associated label.
424
429
 
425
- - `.fdy-field` (+ `--full` inside `.fdy-form-grid`; widths `--w-sm` `--w-lg` `--w-xl` `--w-2xl`
426
- `--w-grow` inside `.fdy-filterbar`)
430
+ - `.fdy-field` (+ `--full`, which releases the width cap **wherever the field is**, and inside
431
+ `.fdy-form-grid` also spans the row; widths `--w-sm` `--w-lg` `--w-xl` `--w-2xl` `--w-grow`
432
+ inside `.fdy-filterbar`)
427
433
  - **The field owns the width; the control in it follows.** `.fdy-field` caps at `22rem` so a lone
428
- field does not run the width of the page. `.fdy-form-grid` and `.fdy-filterbar` lift that cap, and
429
- so does any width you state yourself — and in every one of those cases the control (`.fdy-input`,
434
+ field does not run the width of the page. `.fdy-form-grid` and `.fdy-filterbar` lift that cap,
435
+ `.fdy-field--full` lifts it on one field anywhere, and so does any width you state yourself — and in every one of those cases the control (`.fdy-input`,
430
436
  `.fdy-input-group`, `.fdy-combo`, `.fdy-autocomplete`, `.fdy-cascade`, and the pickers) spans the
431
437
  field. So **size the field, never the control**: styling the control instead leaves the field
432
438
  wider than what is in it, and that dead space is invisible in the DOM — it reads on screen as a
433
439
  gap you did not write.
440
+ - **A single-column form is not a grid.** `.fdy-form-grid` pairs fields up the moment there is room,
441
+ which is right for a document header and wrong for a settings pane, a login card, or a dialog at
442
+ 420px. Those are a plain flex column, and the way one says *"these fields fill me"* is
443
+ `.fdy-field--full` on the fields — **not** a `max-width` override on the container. Both of this
444
+ kit's own consoles wrote that override, once per form, before this modifier reached outside the
445
+ grid.
434
446
  - `.fdy-label` (+`--required`) · `.fdy-input` (+`--error`) · `.fdy-textarea` · `.fdy-help` (+`--error`)
435
447
 
436
448
  **Grouped controls are a `<fieldset>`, not a new block.** Put `.fdy-field` on the fieldset and
@@ -674,6 +686,31 @@ Four of these are required and carry the whole component: `fetchPage`, `columns`
674
686
  | `clearLabel?` | `string` | Accessible name for that button. Default `Clear selection`. |
675
687
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps the picked value visible, focusable and copyable, but the dialog cannot be opened. |
676
688
  | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Field id; the element that labels it; the help/error text it points at. |
689
+ | `dialogOnly?` | `boolean` | Render the **dialog and no field**, for a caller whose trigger is already its own. See below. |
690
+
691
+ **Opening it yourself.** The field is one way in, not the only one. A ref gives you `open()` and
692
+ `close()` — Vue `defineExpose`, React a `FdyCflHandle` through `ref`, Blazor `OpenAsync()` /
693
+ `CloseAsync()` on the `@ref` — and `dialogOnly` drops the field entirely so the picker can be
694
+ triggered by a chip, a table cell, a menu item, or anything else that is not a 22rem readonly input.
695
+ This is what the raw path has always had in `[data-fdy-cfl-open]`; before 3.3.0 the typed wrappers
696
+ were a field *plus* a dialog with no door, and a screen that could not spend a field per trigger had
697
+ to hand-roll the whole picker. With `dialogOnly` the component's host generates no box
698
+ (`.fdy-cfl__host` is `display:contents`), so it can be rendered anywhere — Blazor renders only the
699
+ `<dialog>` and needs no host. `placeholder`, `clearable`, `id` and the field's aria props have
700
+ nothing to name in that mode and are ignored; `disabled` and `readonly` still refuse to open, so a
701
+ programmatic `open()` cannot bypass them.
702
+
703
+ ```vue
704
+ <script setup lang="ts">
705
+ const picker = ref<InstanceType<typeof FdyCfl> | null>(null);
706
+ </script>
707
+
708
+ <template>
709
+ <button class="fdy-chip" type="button" @click="picker?.open()">Cost centre</button>
710
+ <FdyCfl ref="picker" dialog-only v-model="costCentre" :fetch-page="fetchPage"
711
+ :columns="columns" :display="d" :row-key="k" />
712
+ </template>
713
+ ```
677
714
 
678
715
  Blazor carries the same surface in `PascalCase` with three differences: multi-select is a second
679
716
  pair (`Values` / `ValuesChanged`) rather than a widened single binding, the fetch delegate is
@@ -920,7 +957,9 @@ button chrome). `data-fdy-mask="####-####"` formats while typing: `#` digit, `A`
920
957
  alphanumeric, anything else is a literal. Needs `freeday-mask.js`.
921
958
 
922
959
  ## Form grid — `.fdy-form-grid`
923
- Responsive two-column document header. Children are `.fdy-field`; `.fdy-field--full` spans both.
960
+ Responsive two-column document header. Children are `.fdy-field`; `.fdy-field--full` spans both
961
+ columns. For a form that must stay **one** column, do not reach for this grid — it pairs fields up
962
+ as soon as the container is wide enough. Use a flex column and mark the fields `--full`.
924
963
 
925
964
  ## Filter bar — `.fdy-filterbar`
926
965
  A consistent filter row of `.fdy-field`s with a width rhythm (`--w-sm` · default · `--w-lg` ·
@@ -1460,11 +1499,17 @@ Size-matched placeholders so nothing shifts when data lands: `--title` `--text`
1460
1499
  ## Nav (menu) — `.fdy-nav`
1461
1500
  Navigation links, **vertical by default** (the app shell sidebar), horizontal with `--horizontal`. Items are `<a class="fdy-nav__item">` with
1462
1501
  `__icon` / `__label` / `__badge`; the current one gets `aria-current="page"`.
1463
- `--flat` drops the surface. Nested groups are native `<details>`:
1502
+ Nested groups are native `<details>`:
1464
1503
 
1465
1504
  - `.fdy-nav__tree` + `<summary class="fdy-nav__item">` + `.fdy-nav__caret` → children in
1466
1505
  `.fdy-nav__sub`
1467
1506
  - `.fdy-nav__group` + `<summary class="fdy-nav__grouplabel">` → a collapsible section
1507
+ - **`--flat` on the `<nav>` is the static variant**: group labels that never collapse. It drops the
1508
+ disclosure caret, returns the label to `cursor: default`, and removes the divider between groups,
1509
+ so `.fdy-nav__grouplabel` can be a plain `<p>`/`<div>` heading ("OVERVIEW", "ADMINISTRATION")
1510
+ outside any `<details>`. Reach for it instead of undoing those three rules per app — which is what
1511
+ two consoles did for four months, because this line used to claim `--flat` "drops the surface",
1512
+ and it never did: `.fdy-nav` paints no surface to drop.
1468
1513
  - **`--horizontal`** lays the same links out as a row, for a **top-nav application** (primary
1469
1514
  navigation in `.fdy-appbar` or `.fdy-app__topbar`, no sidebar). Same item, same states, same
1470
1515
  `aria-current="page"`; the row scrolls if it runs out of width. On `.fdy-appbar--primary` the
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-v3.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.2.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.3.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.3.1)
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-v3.2.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.2.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.3.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.3.1)
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`.
@@ -41,7 +41,7 @@
41
41
  @TopbarContent
42
42
  </header>
43
43
 
44
- <main class="fdy-app__main">
44
+ <main class="fdy-app__main" id="main" tabindex="-1">
45
45
  @ChildContent
46
46
  </main>
47
47
  </div>
@@ -7,27 +7,33 @@
7
7
  dialog are rendered and re-rendered by Blazor, rows come from an async LoadPage callback, and
8
8
  the <dialog> is opened/closed through the same freeday-blazor.js interop FdyModal uses. *@
9
9
 
10
- <div class="@FieldGroupClass">
11
- <input class="fdy-input" type="text" readonly value="@DisplayValue"
12
- placeholder="@Placeholder" aria-labelledby="@AriaLabelledby"
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">
10
+ @* The field is optional (#054): with DialogOnly the component renders the dialog alone, for a
11
+ caller whose trigger is already its own — a chip, a table cell, a menu item — and opens it
12
+ through @ref: `await picker.OpenAsync()`. The host generates no box. *@
13
+ @if (!DialogOnly)
14
+ {
15
+ <div class="@FieldGroupClass">
16
+ <input class="fdy-input" type="text" readonly value="@DisplayValue"
17
+ placeholder="@Placeholder" aria-labelledby="@AriaLabelledby"
18
+ aria-invalid="@(Invalid ? "true" : null)" aria-readonly="@(Readonly ? "true" : null)" />
19
+ @if (Clearable && Value is not null && !Disabled && !Readonly)
20
+ {
21
+ <button class="fdy-input-group__btn" type="button" @onclick="ClearAsync" aria-label="@ClearLabel">
22
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
23
+ stroke-width="2" stroke-linecap="round" aria-hidden="true">
24
+ <path d="M6 6l12 12M18 6L6 18"></path>
25
+ </svg>
26
+ </button>
27
+ }
28
+ <button class="fdy-input-group__btn" type="button" @onclick="OpenAsync"
29
+ disabled="@(Disabled || Readonly)" aria-label="@OpenLabel">
17
30
  <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>
31
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
32
+ <circle cx="11" cy="11" r="7"></circle><path d="m21 21-4.3-4.3"></path>
20
33
  </svg>
21
34
  </button>
22
- }
23
- <button class="fdy-input-group__btn" type="button" @onclick="OpenAsync"
24
- disabled="@(Disabled || Readonly)" aria-label="@OpenLabel">
25
- <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
26
- stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
27
- <circle cx="11" cy="11" r="7"></circle><path d="m21 21-4.3-4.3"></path>
28
- </svg>
29
- </button>
30
- </div>
35
+ </div>
36
+ }
31
37
 
32
38
  <dialog @ref="Root" class="fdy-modal fdy-modal--cfl" aria-labelledby="@_titleId">
33
39
  <div class="fdy-modal__header">
@@ -70,6 +70,13 @@ public partial class FdyCfl<TRow>
70
70
  /// <summary>Debounce (ms) between a keystroke and the search request.</summary>
71
71
  [Parameter] public int SearchDebounceMs { get; set; } = 250;
72
72
 
73
+ /// <summary>Render the dialog and NO field, for a caller whose trigger is already its own — a chip,
74
+ /// a table cell, a menu item — opened through <c>@ref</c>: <c>await picker.OpenAsync()</c>. The raw
75
+ /// path has had exactly this since the enhancer's <c>[data-fdy-cfl-open]</c>. <c>Placeholder</c>,
76
+ /// <c>Clearable</c> and the field's aria parameters have nothing to name here and are ignored;
77
+ /// <c>Disabled</c> and <c>Readonly</c> still refuse to open.</summary>
78
+ [Parameter] public bool DialogOnly { get; set; }
79
+
73
80
  private readonly string _titleId = $"fdy-cfl-{Guid.NewGuid():N}-title";
74
81
  private int _dialogToken;
75
82
  private bool _open;
@@ -93,7 +100,10 @@ public partial class FdyCfl<TRow>
93
100
  protected override async ValueTask HydrateAsync()
94
101
  => _dialogToken = await JS.InvokeAsync<int>("FreedayBlazor.dialogInit", Root, Self, nameof(OnDismiss), true);
95
102
 
96
- private async Task OpenAsync()
103
+ /// <summary>Open the picker. Public because with <see cref="DialogOnly"/> there is no field to
104
+ /// open it, and because a caller with its own trigger is the case that made #054: it guards
105
+ /// exactly as the built trigger does, so this cannot bypass Disabled/Readonly.</summary>
106
+ public async Task OpenAsync()
97
107
  {
98
108
  if (Disabled || Readonly || _open) return;
99
109
  _open = true;
@@ -105,7 +115,8 @@ public partial class FdyCfl<TRow>
105
115
  await LoadAsync(reset: true);
106
116
  }
107
117
 
108
- private async Task CloseAsync()
118
+ /// <summary>Close the picker without committing, the same path Esc and the footer button take.</summary>
119
+ public async Task CloseAsync()
109
120
  {
110
121
  if (!_open) return;
111
122
  _open = false;
@@ -186,7 +186,7 @@ export function FdyAppShell(props: FdyAppShellProps): JSX.Element {
186
186
  {props.topbar}
187
187
  </header>
188
188
 
189
- <main className="fdy-app__main">{props.children}</main>
189
+ <main className="fdy-app__main" id="main" tabIndex={-1}>{props.children}</main>
190
190
  </div>
191
191
 
192
192
  <div
@@ -1,5 +1,5 @@
1
- import type { JSX } from 'react';
2
- import { useEffect, useId, useRef, useState } from 'react';
1
+ import type { JSX, Ref } from 'react';
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
3
3
 
4
4
  // React port of adapters/vue/components/FdyCfl.vue over freeday's `.fdy-cfl*` +
5
5
  // `.fdy-input-group` classes (see src/components/cfl.css, input-group.css). A controlled
@@ -71,9 +71,27 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
71
71
  describedby?: string;
72
72
  id?: string;
73
73
  ariaLabelledby?: string;
74
+ /** Render the dialog and NO field, for a caller whose trigger is already its own — a chip, a table
75
+ * cell, a menu item — opened through the ref: `picker.current?.open()`. The raw path has had
76
+ * exactly this since the enhancer's `[data-fdy-cfl-open]`; the typed wrappers were a field *plus*
77
+ * a dialog with no way in, so a screen that could not spend a 22rem readonly input per trigger had
78
+ * to hand-roll the picker (#054). The host generates no box, so it can sit anywhere.
79
+ * `placeholder`, `clearable`, `id` and the field's aria props have nothing to name here and are
80
+ * ignored; `disabled` and `readonly` still refuse to open. */
81
+ dialogOnly?: boolean;
74
82
  }
75
83
 
76
- export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<Row>): JSX.Element {
84
+ /** What a `ref` on `<FdyCfl>` gives you. Both guard exactly as the trigger does, so a programmatic
85
+ * open cannot bypass `disabled`/`readonly`. */
86
+ export interface FdyCflHandle {
87
+ open: () => void;
88
+ close: () => void;
89
+ }
90
+
91
+ function FdyCflInner<Row extends Record<string, unknown>>(
92
+ props: FdyCflProps<Row>,
93
+ ref: Ref<FdyCflHandle>,
94
+ ): JSX.Element {
77
95
  const baseId: string = useId();
78
96
  const fieldId: string = props.id ?? `${baseId}-field`;
79
97
  const titleId: string = `${baseId}-title`;
@@ -301,6 +319,10 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
301
319
  dialogRef.current?.close();
302
320
  }
303
321
 
322
+ /* No dependency array on purpose: both close over state (the ticks to seed, whether the field is
323
+ disabled), so a handle frozen at first render would open a picker with last week's props. */
324
+ useImperativeHandle(ref, (): FdyCflHandle => ({ open: openDialog, close: closeDialog }));
325
+
304
326
  // Fires for every close path (button, Esc, commit); centralise cleanup here.
305
327
  function onClose(): void {
306
328
  reqIdRef.current++; // invalidate any in-flight fetch so it can't apply after close
@@ -330,46 +352,50 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
330
352
  }, []);
331
353
 
332
354
  return (
333
- <div className="fdy-input-group">
334
- <input
335
- id={fieldId}
336
- className="fdy-input"
337
- type="text"
338
- readOnly
339
- value={displayValue}
340
- placeholder={props.placeholder}
341
- aria-labelledby={props.ariaLabelledby}
342
- aria-invalid={isInvalid ? 'true' : undefined}
343
- aria-describedby={props.describedby}
344
- disabled={isDisabled}
345
- />
346
- {showClear ? (
347
- <button
348
- type="button"
349
- className="fdy-input-group__btn"
350
- aria-label={clearLabelText}
351
- onClick={clearValue}
352
- >
353
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
354
- <path d="M6 6l12 12M18 6L6 18" />
355
- </svg>
356
- </button>
357
- ) : null}
358
- <button
359
- ref={triggerRef}
360
- type="button"
361
- className="fdy-input-group__btn"
362
- aria-haspopup="dialog"
363
- aria-labelledby={props.ariaLabelledby}
364
- aria-label={props.ariaLabelledby ? undefined : (props.openLabel ?? 'Open search')}
365
- disabled={isDisabled || isReadonly}
366
- onClick={openDialog}
367
- >
368
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
369
- <circle cx={11} cy={11} r={7}></circle>
370
- <path d="m21 21-4.35-4.35"></path>
371
- </svg>
372
- </button>
355
+ <div className={props.dialogOnly === true ? 'fdy-cfl__host' : 'fdy-input-group'}>
356
+ {props.dialogOnly === true ? null : (
357
+ <>
358
+ <input
359
+ id={fieldId}
360
+ className="fdy-input"
361
+ type="text"
362
+ readOnly
363
+ value={displayValue}
364
+ placeholder={props.placeholder}
365
+ aria-labelledby={props.ariaLabelledby}
366
+ aria-invalid={isInvalid ? 'true' : undefined}
367
+ aria-describedby={props.describedby}
368
+ disabled={isDisabled}
369
+ />
370
+ {showClear ? (
371
+ <button
372
+ type="button"
373
+ className="fdy-input-group__btn"
374
+ aria-label={clearLabelText}
375
+ onClick={clearValue}
376
+ >
377
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
378
+ <path d="M6 6l12 12M18 6L6 18" />
379
+ </svg>
380
+ </button>
381
+ ) : null}
382
+ <button
383
+ ref={triggerRef}
384
+ type="button"
385
+ className="fdy-input-group__btn"
386
+ aria-haspopup="dialog"
387
+ aria-labelledby={props.ariaLabelledby}
388
+ aria-label={props.ariaLabelledby ? undefined : (props.openLabel ?? 'Open search')}
389
+ disabled={isDisabled || isReadonly}
390
+ onClick={openDialog}
391
+ >
392
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
393
+ <circle cx={11} cy={11} r={7}></circle>
394
+ <path d="m21 21-4.35-4.35"></path>
395
+ </svg>
396
+ </button>
397
+ </>
398
+ )}
373
399
 
374
400
  <dialog ref={dialogRef} className="fdy-modal fdy-modal--cfl" aria-labelledby={titleId} onClose={onClose} onKeyDown={onKeydown}>
375
401
  <div className="fdy-modal__header">
@@ -379,7 +405,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
379
405
 
380
406
  <div className="fdy-modal__body">
381
407
  <div className="fdy-cfl__search">
382
- <div className="fdy-input-group" style={{ maxWidth: 'none' }}>
408
+ <div className="fdy-input-group">
383
409
  <span className="fdy-input-group__addon fdy-input-group__addon--icon">
384
410
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
385
411
  <circle cx={11} cy={11} r={7}></circle>
@@ -478,3 +504,12 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
478
504
  </div>
479
505
  );
480
506
  }
507
+
508
+ /* `forwardRef` types its argument as a plain function, which erases `Row`: every consumer would get
509
+ `FdyCflProps<Record<string, unknown>>` and lose the row type the whole component exists to carry.
510
+ The cast restores the generic call signature, and it is the standard shape for a generic component
511
+ with a ref while `react` is `>=18` (React 19's ref-as-prop would not need it, and the peer range
512
+ still admits 18). */
513
+ export const FdyCfl = forwardRef(FdyCflInner) as <Row extends Record<string, unknown>>(
514
+ props: FdyCflProps<Row> & { ref?: Ref<FdyCflHandle> },
515
+ ) => JSX.Element;
@@ -33,7 +33,7 @@ export { FdyDatepicker, type FdyDatepickerProps } from './components/FdyDatepick
33
33
  export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
34
34
  export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
35
35
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
36
- export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
36
+ export { FdyCfl, type FdyCflProps, type FdyCflHandle, type CflColumn, type CflPage } from './components/FdyCfl';
37
37
  /** Narrow `FdyCfl`'s `Row | Row[] | null` to the `Row | null` a single-select can produce; throws
38
38
  * on the array only a `multiple` field emits. */
39
39
  export { singleRow } from '../core/cfl-value';
@@ -180,7 +180,7 @@ onBeforeUnmount((): void => {
180
180
  <slot name="topbar" />
181
181
  </header>
182
182
 
183
- <main class="fdy-app__main">
183
+ <main class="fdy-app__main" id="main" tabindex="-1">
184
184
  <slot />
185
185
  </main>
186
186
  </div>
@@ -72,6 +72,14 @@ const props = defineProps<{
72
72
  describedby?: string;
73
73
  id?: string;
74
74
  ariaLabelledby?: string;
75
+ /** Render the dialog and NO field, for a caller whose trigger is already its own — a chip, a table
76
+ * cell, a menu item — opened by ref: `picker.value?.open()`. The raw path has had exactly this
77
+ * since the enhancer's `[data-fdy-cfl-open]`; the typed wrappers were a field *plus* a dialog
78
+ * with no way in, so a screen that could not spend a 22rem readonly input per trigger had to
79
+ * hand-roll the picker (#054). The host generates no box, so it can sit anywhere. `placeholder`,
80
+ * `clearable`, `id` and the field's aria props have nothing to name here and are ignored;
81
+ * `disabled` and `readonly` still refuse to open. */
82
+ dialogOnly?: boolean;
75
83
  }>();
76
84
 
77
85
  const emit = defineEmits<{
@@ -99,6 +107,7 @@ const loading: Ref<boolean> = ref(false);
99
107
  const error: Ref<Error | null> = ref(null);
100
108
  const activeIndex: Ref<number> = ref(-1);
101
109
 
110
+ const isDialogOnly: ComputedRef<boolean> = computed((): boolean => props.dialogOnly === true);
102
111
  const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
103
112
  const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
104
113
 
@@ -321,6 +330,11 @@ function onClose(): void {
321
330
  triggerEl.value?.focus();
322
331
  }
323
332
 
333
+ /* The dialog is reachable from outside, which is what makes `dialogOnly` usable at all: with no
334
+ field there is no trigger, and `open()` is the only door. Both guard exactly as the trigger did,
335
+ so a programmatic open cannot bypass `disabled`/`readonly`. */
336
+ defineExpose({ open: openDialog, close: closeDialog });
337
+
324
338
  function cellText(row: Row, key: keyof Row & string): string {
325
339
  const value: unknown = row[key];
326
340
  return value === null || value === undefined ? '' : String(value);
@@ -332,45 +346,47 @@ onBeforeUnmount((): void => {
332
346
  </script>
333
347
 
334
348
  <template>
335
- <div class="fdy-input-group">
336
- <input
337
- :id="fieldId"
338
- class="fdy-input"
339
- type="text"
340
- readonly
341
- :value="displayValue"
342
- :placeholder="placeholder"
343
- :aria-labelledby="ariaLabelledby"
344
- :aria-invalid="isInvalid ? 'true' : undefined"
345
- :aria-describedby="describedby"
346
- :disabled="isDisabled"
347
- />
348
- <button
349
- v-if="showClear"
350
- type="button"
351
- class="fdy-input-group__btn"
352
- :aria-label="clearLabelText"
353
- @click="clearValue"
354
- >
355
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
356
- <path d="M6 6l12 12M18 6L6 18"></path>
357
- </svg>
358
- </button>
359
- <button
360
- ref="triggerEl"
361
- type="button"
362
- class="fdy-input-group__btn"
363
- aria-haspopup="dialog"
364
- :aria-labelledby="ariaLabelledby"
365
- :aria-label="ariaLabelledby ? undefined : (openLabel ?? 'Open search')"
366
- :disabled="isDisabled || isReadonly"
367
- @click="openDialog"
368
- >
369
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
370
- <circle cx="11" cy="11" r="7"></circle>
371
- <path d="m21 21-4.35-4.35"></path>
372
- </svg>
373
- </button>
349
+ <div :class="isDialogOnly ? 'fdy-cfl__host' : 'fdy-input-group'">
350
+ <template v-if="!isDialogOnly">
351
+ <input
352
+ :id="fieldId"
353
+ class="fdy-input"
354
+ type="text"
355
+ readonly
356
+ :value="displayValue"
357
+ :placeholder="placeholder"
358
+ :aria-labelledby="ariaLabelledby"
359
+ :aria-invalid="isInvalid ? 'true' : undefined"
360
+ :aria-describedby="describedby"
361
+ :disabled="isDisabled"
362
+ />
363
+ <button
364
+ v-if="showClear"
365
+ type="button"
366
+ class="fdy-input-group__btn"
367
+ :aria-label="clearLabelText"
368
+ @click="clearValue"
369
+ >
370
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
371
+ <path d="M6 6l12 12M18 6L6 18"></path>
372
+ </svg>
373
+ </button>
374
+ <button
375
+ ref="triggerEl"
376
+ type="button"
377
+ class="fdy-input-group__btn"
378
+ aria-haspopup="dialog"
379
+ :aria-labelledby="ariaLabelledby"
380
+ :aria-label="ariaLabelledby ? undefined : (openLabel ?? 'Open search')"
381
+ :disabled="isDisabled || isReadonly"
382
+ @click="openDialog"
383
+ >
384
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
385
+ <circle cx="11" cy="11" r="7"></circle>
386
+ <path d="m21 21-4.35-4.35"></path>
387
+ </svg>
388
+ </button>
389
+ </template>
374
390
 
375
391
  <dialog ref="dialogEl" class="fdy-modal fdy-modal--cfl" :aria-labelledby="titleId" @close="onClose" @keydown="onKeydown">
376
392
  <div class="fdy-modal__header">
@@ -380,7 +396,7 @@ onBeforeUnmount((): void => {
380
396
 
381
397
  <div class="fdy-modal__body">
382
398
  <div class="fdy-cfl__search">
383
- <div class="fdy-input-group" style="max-width:none">
399
+ <div class="fdy-input-group">
384
400
  <span class="fdy-input-group__addon fdy-input-group__addon--icon">
385
401
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
386
402
  <circle cx="11" cy="11" r="7"></circle>
@@ -963,8 +963,15 @@ a { color: var(--color-primary); }
963
963
  * > (.fdy-app__topbar, .fdy-app__main)). The __content wrapper gives the sticky
964
964
  * topbar a tall containing block so it can travel, a bare grid cell can't. */
965
965
  .fdy-app{display:flex;align-items:stretch;min-height:100vh;background:var(--color-surface-2);}
966
- .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:15.5rem;box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
967
- .fdy-app__content{flex:1;min-width:0;display:flex;flex-direction:column;}
966
+ .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
967
+ /* `min-height:0` is the height-axis twin of `min-width:0`, and its absence was invisible for as long
968
+ * as the shell scrolled the PAGE (#055 §2). A consumer that pins the shell to the viewport and
969
+ * scrolls .fdy-app__main instead hits it immediately: a column flex item's automatic minimum size is
970
+ * its content, so the scrolling child pushes this box — and the shell — open instead of scrolling.
971
+ * `flex:none` on the topbar is the same story one line down: it is a flex item in this column, and
972
+ * a topbar whose content wraps past `min-height` is a topbar that can be squeezed. Both consoles
973
+ * wrote these two rules independently, in app.css, before the fifth screen. */
974
+ .fdy-app__content{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;}
968
975
  /* Brand is a fixed header, same height as the topbar so they align, and only
969
976
  * the nav below it scrolls. Bare text (no children) renders as one bold
970
977
  * display line via the rules on .fdy-app__brand itself. For a mark + two-line
@@ -1052,7 +1059,7 @@ a { color: var(--color-primary); }
1052
1059
  also apply — it would fight the transform for the same panel. */
1053
1060
  .fdy-app--nav-overlay.fdy-app--nav-collapsed .fdy-app__sidebar{width:16rem;border-right-width:var(--bw);}
1054
1061
  }
1055
- .fdy-app__topbar{position:sticky;top:0;z-index:20;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
1062
+ .fdy-app__topbar{position:sticky;top:0;z-index:20;flex:none;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
1056
1063
  .fdy-app__title{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1057
1064
  .fdy-app__main{flex:1;padding:var(--space-8);}
1058
1065
  .fdy-skip{position:absolute;left:var(--space-2);top:var(--space-2);background:var(--color-primary);color:var(--color-on-primary);padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);z-index:100;transform:translateY(-250%);transition:transform var(--dur-fast) var(--ease-standard);}
@@ -1469,6 +1476,15 @@ a { color: var(--color-primary); }
1469
1476
 
1470
1477
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
1471
1478
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1479
+ /* The search group spans the bar. Four files wrote this as `style="max-width:none"` — both typed
1480
+ adapters and both docs examples — which is the same missing vocabulary as #055 §1, one class over. */
1481
+ .fdy-cfl__search .fdy-input-group{max-width:none;}
1482
+
1483
+ /* Field-less host (#054). With `dialogOnly` the typed wrappers render the dialog and no field, for a
1484
+ caller whose trigger is its own — a chip, a table cell, a menu item — which is what the raw path
1485
+ has always had in `[data-fdy-cfl-open]`. `display:contents` so the host generates no box at all:
1486
+ a zero-size div would still be a flex item or a grid cell in whatever laid the trigger out. */
1487
+ .fdy-cfl__host{display:contents;}
1472
1488
 
1473
1489
  /* Results, dense rows, sticky header, its own scroll region.
1474
1490
  *
@@ -2034,6 +2050,16 @@ a { color: var(--color-primary); }
2034
2050
 
2035
2051
  /* Freeday. Field & Input */
2036
2052
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
2053
+ /* A field marked --full fills whatever contains it (#055 §1). The cap above is for a field standing
2054
+ on its own in a row — a filter bar, a toolbar — and it is right there. But every real form is a
2055
+ single-column flex stack (a settings pane, a login card, a dialog at 420px), and the only release
2056
+ the kit shipped was `.fdy-form-grid > .fdy-field`, which a flex column can never reach:
2057
+ .fdy-form-grid is `repeat(auto-fit,minmax(14rem,1fr))`, which pairs fields up the moment there is
2058
+ room. So a stack had no vocabulary at all and said it on the container instead — 23 times across
2059
+ two consoles, and four times in this kit's OWN reference page as `style="max-width:none"`.
2060
+ Inside .fdy-form-grid this changes nothing (that rule already says it); outside one the modifier
2061
+ finally means what its name says, and the grid keeps `grid-column` as its own concern. */
2062
+ .fdy-field--full{max-width:none;}
2037
2063
  /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
2038
2064
  for one standing on its own, with no field to take a width from; #017 already released the date
2039
2065
  and time pickers here for exactly this reason, and its comment claims a picker then fills its
package/dist/freeday.css CHANGED
@@ -85,8 +85,15 @@ a { color: var(--color-primary); }
85
85
  * > (.fdy-app__topbar, .fdy-app__main)). The __content wrapper gives the sticky
86
86
  * topbar a tall containing block so it can travel, a bare grid cell can't. */
87
87
  .fdy-app{display:flex;align-items:stretch;min-height:100vh;background:var(--color-surface-2);}
88
- .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:15.5rem;box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
89
- .fdy-app__content{flex:1;min-width:0;display:flex;flex-direction:column;}
88
+ .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
89
+ /* `min-height:0` is the height-axis twin of `min-width:0`, and its absence was invisible for as long
90
+ * as the shell scrolled the PAGE (#055 §2). A consumer that pins the shell to the viewport and
91
+ * scrolls .fdy-app__main instead hits it immediately: a column flex item's automatic minimum size is
92
+ * its content, so the scrolling child pushes this box — and the shell — open instead of scrolling.
93
+ * `flex:none` on the topbar is the same story one line down: it is a flex item in this column, and
94
+ * a topbar whose content wraps past `min-height` is a topbar that can be squeezed. Both consoles
95
+ * wrote these two rules independently, in app.css, before the fifth screen. */
96
+ .fdy-app__content{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;}
90
97
  /* Brand is a fixed header, same height as the topbar so they align, and only
91
98
  * the nav below it scrolls. Bare text (no children) renders as one bold
92
99
  * display line via the rules on .fdy-app__brand itself. For a mark + two-line
@@ -174,7 +181,7 @@ a { color: var(--color-primary); }
174
181
  also apply — it would fight the transform for the same panel. */
175
182
  .fdy-app--nav-overlay.fdy-app--nav-collapsed .fdy-app__sidebar{width:16rem;border-right-width:var(--bw);}
176
183
  }
177
- .fdy-app__topbar{position:sticky;top:0;z-index:20;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
184
+ .fdy-app__topbar{position:sticky;top:0;z-index:20;flex:none;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
178
185
  .fdy-app__title{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
179
186
  .fdy-app__main{flex:1;padding:var(--space-8);}
180
187
  .fdy-skip{position:absolute;left:var(--space-2);top:var(--space-2);background:var(--color-primary);color:var(--color-on-primary);padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);z-index:100;transform:translateY(-250%);transition:transform var(--dur-fast) var(--ease-standard);}
@@ -591,6 +598,15 @@ a { color: var(--color-primary); }
591
598
 
592
599
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
593
600
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
601
+ /* The search group spans the bar. Four files wrote this as `style="max-width:none"` — both typed
602
+ adapters and both docs examples — which is the same missing vocabulary as #055 §1, one class over. */
603
+ .fdy-cfl__search .fdy-input-group{max-width:none;}
604
+
605
+ /* Field-less host (#054). With `dialogOnly` the typed wrappers render the dialog and no field, for a
606
+ caller whose trigger is its own — a chip, a table cell, a menu item — which is what the raw path
607
+ has always had in `[data-fdy-cfl-open]`. `display:contents` so the host generates no box at all:
608
+ a zero-size div would still be a flex item or a grid cell in whatever laid the trigger out. */
609
+ .fdy-cfl__host{display:contents;}
594
610
 
595
611
  /* Results, dense rows, sticky header, its own scroll region.
596
612
  *
@@ -1156,6 +1172,16 @@ a { color: var(--color-primary); }
1156
1172
 
1157
1173
  /* Freeday. Field & Input */
1158
1174
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
1175
+ /* A field marked --full fills whatever contains it (#055 §1). The cap above is for a field standing
1176
+ on its own in a row — a filter bar, a toolbar — and it is right there. But every real form is a
1177
+ single-column flex stack (a settings pane, a login card, a dialog at 420px), and the only release
1178
+ the kit shipped was `.fdy-form-grid > .fdy-field`, which a flex column can never reach:
1179
+ .fdy-form-grid is `repeat(auto-fit,minmax(14rem,1fr))`, which pairs fields up the moment there is
1180
+ room. So a stack had no vocabulary at all and said it on the container instead — 23 times across
1181
+ two consoles, and four times in this kit's OWN reference page as `style="max-width:none"`.
1182
+ Inside .fdy-form-grid this changes nothing (that rule already says it); outside one the modifier
1183
+ finally means what its name says, and the grid keeps `grid-column` as its own concern. */
1184
+ .fdy-field--full{max-width:none;}
1159
1185
  /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
1160
1186
  for one standing on its own, with no field to take a width from; #017 already released the date
1161
1187
  and time pickers here for exactly this reason, and its comment claims a picker then fills its
@@ -132,6 +132,12 @@ Recent additions most likely to replace something an app hand-rolled (all detail
132
132
  | `selectable` + `selectedKeys` on `FdyTable` | a checkbox column bolted beside a controlled table, with its own bulk bar |
133
133
  | `fdy-step-before-change` | a wizard whose Next cannot be stopped while the server is still deciding |
134
134
  | `.fdy-table--striped` | a `:nth-child` rule of your own that turns out to be the same colour as hover |
135
+ | `FdyCfl` in a narrow surface | a hand-rolled card picker, because the kit's was a table — it stacks itself under a 30rem container, no prop to pass |
136
+ | `size="cfl"` on `FdyModal` | writing `.fdy-modal--cfl` by hand to reach the 46rem width |
137
+ | `dialogOnly` + `open()` on `FdyCfl` | a hand-rolled picker, because the trigger is a chip or a table cell and not a 22rem field |
138
+ | `.fdy-field--full` | `max-width: none` on the form container, once per form, because the fields are a flex column and not a `.fdy-form-grid` |
139
+ | `.fdy-nav--flat` | undoing the caret, the pointer cursor and the group divider yourself, on a sidebar whose group labels never collapse |
140
+ | `--fdy-app-sidebar-w` | overriding `width` on `.fdy-app__sidebar`, which is also the rule that animates the collapse |
135
141
 
136
142
  ## 3. Starting a new screen
137
143
 
@@ -191,7 +191,7 @@ live docs also have a copy button per component.
191
191
  ```bash
192
192
  npm i @cahyo-dimas/freeday
193
193
  ```
194
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.2.0"` (public npm package). `dist/` is
194
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.3.1"` (public npm package). `dist/` is
195
195
  committed and published → no build step; `npm ci` runs without auth.
196
196
 
197
197
  ### 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": "3.2.0",
3
+ "version": "3.3.1",
4
4
  "description": "Freeday: token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@
92
92
  "scripts": {
93
93
  "build": "node tokens/build.mjs",
94
94
  "test": "node --test",
95
- "test:browser": "node --test --test-concurrency=3 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 browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/cfl-narrow.mjs browser/harness-contract.mjs",
95
+ "test:browser": "node --test --test-concurrency=3 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 browser/text-override.mjs browser/control-heights.mjs browser/field-width.mjs browser/cfl-multi.mjs browser/cfl-dialog-only.mjs browser/crowding.mjs browser/over-dialog.mjs browser/chart-scale.mjs browser/overlay-stack.mjs browser/chart-a11y.mjs browser/app-shell.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/cfl-narrow.mjs browser/harness-contract.mjs",
96
96
  "prepack": "node tokens/build.mjs",
97
97
  "version": "node tokens/build.mjs && git add dist",
98
98
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit",
@@ -6,8 +6,15 @@
6
6
  * > (.fdy-app__topbar, .fdy-app__main)). The __content wrapper gives the sticky
7
7
  * topbar a tall containing block so it can travel, a bare grid cell can't. */
8
8
  .fdy-app{display:flex;align-items:stretch;min-height:100vh;background:var(--color-surface-2);}
9
- .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:15.5rem;box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
10
- .fdy-app__content{flex:1;min-width:0;display:flex;flex-direction:column;}
9
+ .fdy-app__sidebar{position:sticky;top:0;align-self:flex-start;height:100vh;overflow:hidden;flex:none;width:var(--fdy-app-sidebar-w,15.5rem);box-sizing:border-box;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-right:var(--bw) solid var(--color-border);display:flex;flex-direction:column;transition:width var(--dur-slow) var(--ease-standard),border-right-width var(--dur-slow) var(--ease-standard);}
10
+ /* `min-height:0` is the height-axis twin of `min-width:0`, and its absence was invisible for as long
11
+ * as the shell scrolled the PAGE (#055 §2). A consumer that pins the shell to the viewport and
12
+ * scrolls .fdy-app__main instead hits it immediately: a column flex item's automatic minimum size is
13
+ * its content, so the scrolling child pushes this box — and the shell — open instead of scrolling.
14
+ * `flex:none` on the topbar is the same story one line down: it is a flex item in this column, and
15
+ * a topbar whose content wraps past `min-height` is a topbar that can be squeezed. Both consoles
16
+ * wrote these two rules independently, in app.css, before the fifth screen. */
17
+ .fdy-app__content{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;}
11
18
  /* Brand is a fixed header, same height as the topbar so they align, and only
12
19
  * the nav below it scrolls. Bare text (no children) renders as one bold
13
20
  * display line via the rules on .fdy-app__brand itself. For a mark + two-line
@@ -95,7 +102,7 @@
95
102
  also apply — it would fight the transform for the same panel. */
96
103
  .fdy-app--nav-overlay.fdy-app--nav-collapsed .fdy-app__sidebar{width:16rem;border-right-width:var(--bw);}
97
104
  }
98
- .fdy-app__topbar{position:sticky;top:0;z-index:20;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
105
+ .fdy-app__topbar{position:sticky;top:0;z-index:20;flex:none;min-height:var(--space-16);display:flex;align-items:center;gap:var(--space-3);padding:0 var(--space-8);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);border-bottom:var(--bw) solid var(--color-border);}
99
106
  .fdy-app__title{font-family:var(--font-display);font-size:var(--text-xl);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
100
107
  .fdy-app__main{flex:1;padding:var(--space-8);}
101
108
  .fdy-skip{position:absolute;left:var(--space-2);top:var(--space-2);background:var(--color-primary);color:var(--color-on-primary);padding:var(--space-2) var(--space-4);border-radius:var(--radius-md);z-index:100;transform:translateY(-250%);transition:transform var(--dur-fast) var(--ease-standard);}
@@ -6,6 +6,15 @@
6
6
 
7
7
  /* Search bar, single leading-icon field; Enter/typing filters the results. */
8
8
  .fdy-cfl__search{flex:none;padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
9
+ /* The search group spans the bar. Four files wrote this as `style="max-width:none"` — both typed
10
+ adapters and both docs examples — which is the same missing vocabulary as #055 §1, one class over. */
11
+ .fdy-cfl__search .fdy-input-group{max-width:none;}
12
+
13
+ /* Field-less host (#054). With `dialogOnly` the typed wrappers render the dialog and no field, for a
14
+ caller whose trigger is its own — a chip, a table cell, a menu item — which is what the raw path
15
+ has always had in `[data-fdy-cfl-open]`. `display:contents` so the host generates no box at all:
16
+ a zero-size div would still be a flex item or a grid cell in whatever laid the trigger out. */
17
+ .fdy-cfl__host{display:contents;}
9
18
 
10
19
  /* Results, dense rows, sticky header, its own scroll region.
11
20
  *
@@ -1,5 +1,15 @@
1
1
  /* Freeday. Field & Input */
2
2
  .fdy-field{display:flex;flex-direction:column;gap:var(--space-2);max-width:22rem;}
3
+ /* A field marked --full fills whatever contains it (#055 §1). The cap above is for a field standing
4
+ on its own in a row — a filter bar, a toolbar — and it is right there. But every real form is a
5
+ single-column flex stack (a settings pane, a login card, a dialog at 420px), and the only release
6
+ the kit shipped was `.fdy-form-grid > .fdy-field`, which a flex column can never reach:
7
+ .fdy-form-grid is `repeat(auto-fit,minmax(14rem,1fr))`, which pairs fields up the moment there is
8
+ room. So a stack had no vocabulary at all and said it on the container instead — 23 times across
9
+ two consoles, and four times in this kit's OWN reference page as `style="max-width:none"`.
10
+ Inside .fdy-form-grid this changes nothing (that rule already says it); outside one the modifier
11
+ finally means what its name says, and the grid keeps `grid-column` as its own concern. */
12
+ .fdy-field--full{max-width:none;}
3
13
  /* Inside a labelled field, a control fills the field (#051). The 22rem cap those controls carry is
4
14
  for one standing on its own, with no field to take a width from; #017 already released the date
5
15
  and time pickers here for exactly this reason, and its comment claims a picker then fills its