@cahyo-dimas/freeday 3.11.0 → 3.12.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/COMPONENTS.md +25 -0
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/adapters/blazor/FdyCascade.razor +2 -1
  6. package/adapters/blazor/FdyCascade.razor.cs +7 -0
  7. package/adapters/blazor/FdyCombo.razor +2 -1
  8. package/adapters/blazor/FdyCombo.razor.cs +7 -0
  9. package/adapters/blazor/FdyDatepicker.razor +1 -0
  10. package/adapters/blazor/FdyDatepicker.razor.cs +7 -0
  11. package/adapters/blazor/FdyMonthpicker.razor +1 -0
  12. package/adapters/blazor/FdyMonthpicker.razor.cs +7 -0
  13. package/adapters/blazor/FdyTimepicker.razor +2 -1
  14. package/adapters/blazor/FdyTimepicker.razor.cs +7 -0
  15. package/adapters/react/components/FdyCascade.tsx +10 -1
  16. package/adapters/react/components/FdyCombo.tsx +9 -0
  17. package/adapters/react/components/FdyDatepicker.tsx +10 -1
  18. package/adapters/react/components/FdyMonthpicker.tsx +10 -1
  19. package/adapters/react/components/FdyTimepicker.tsx +9 -0
  20. package/adapters/vue/components/FdyCascade.vue +10 -1
  21. package/adapters/vue/components/FdyCombo.vue +9 -0
  22. package/adapters/vue/components/FdyDatepicker.vue +10 -1
  23. package/adapters/vue/components/FdyMonthpicker.vue +10 -1
  24. package/adapters/vue/components/FdyTimepicker.vue +9 -0
  25. package/dist/freeday-cascade.js +5 -0
  26. package/dist/freeday-datepicker.js +5 -0
  27. package/dist/freeday-form.js +60 -0
  28. package/dist/freeday-timepicker.js +5 -0
  29. package/dist/freeday.bundle.css +4 -4
  30. package/dist/freeday.css +4 -4
  31. package/dist/freeday.js +75 -0
  32. package/docs/getting-started.md +1 -1
  33. package/package.json +2 -2
  34. package/src/components/drawer.css +2 -2
  35. package/src/components/modal.css +2 -2
package/CHANGELOG.md CHANGED
@@ -9,6 +9,87 @@ 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.12.1] - 2026-10-27
13
+
14
+ **Modal dan drawer kembali beranimasi di bawah Safari 17.4, termasuk di iPhone 7 pemiliknya.**
15
+
16
+ ### Fixed
17
+ - **Empat shorthand `transition` dipecah jadi longhand** (`#097`), di `modal.css` dan `drawer.css`.
18
+ Shorthand yang membawa satu keyword yang tak dikenal mesin **tidak sah saat parse**, jadi SELURUH
19
+ deklarasinya dibuang -- dan `allow-discrete` tak dikenal di bawah Safari 17.4, yang ada di bawah
20
+ lantai dukungan kit. Akibatnya modal dan drawer buka-tutup tanpa animasi apa pun. Dipecah begini,
21
+ yang hilang di bawah lantai hanya `transition-behavior`, dan paruh opacity/transform-nya bertahan.
22
+ `overlay` dan `display` tetap terdaftar di `transition-property`, tempat nama yang tak cocok tetap
23
+ sah secara sintaks dan sekadar tak pernah cocok, jadi tak ada lagi yang ikut jatuh. Keluarnya
24
+ tetap seketika di sana sebab `display` memang tak dapat ditransisikan di bawah lantai -- sama
25
+ seperti sebelumnya, jadi pemecahan ini tidak mengambil apa pun.
26
+
27
+ Ekuivalensinya di atas lantai **diukur, bukan dinalar**: `transition-property`,
28
+ `transition-timing-function` dan `transition-behavior` identik byte-per-byte sebelum dan sesudah,
29
+ dan satu-satunya beda adalah satu nilai `transition-duration` yang terserialisasi sebagai satu
30
+ nilai alih-alih tiga. Bahwa satu nilai itu tetap berlaku untuk SETIAP properti juga diukur: 280ms
31
+ masing-masing, dan easing tetap per-indeks.
32
+
33
+ Harganya empat deklarasi yang harus tetap sejalan, dan itu kini dijaga uji: `baseline.test.mjs`
34
+ menolak `allow-discrete` di dalam shorthand `transition` mana pun, dan menolak
35
+ `transition-behavior` yang berdiri tanpa ketiga longhand lainnya. Regresi yang paling mungkin
36
+ adalah seseorang merapikan empat longhand kembali menjadi satu shorthand, yang terbaca lebih
37
+ bersih dan diam-diam mengambil animasinya dari setiap peramban di bawah Safari 17.4.
38
+
39
+ ## [3.12.0] - 2026-10-27
40
+
41
+ **Sebuah form kini dapat menuntut kontrol kit terisi.** `data-fdy-validate` mewirekan Constraint
42
+ Validation **native**: `isCandidate` menuntut `willValidate` dan menolak `type === 'button'`, dan
43
+ pemicu kontrol kit memang `<button>`. Jadi `form.elements` tak pernah menawarkannya, `required` di
44
+ atasnya tak berarti apa pun, dan form tak bisa menuntut combo, datepicker, monthpicker, timepicker
45
+ atau cascade diisi.
46
+
47
+ Ini yang menahan audit 3.11.0 di 3 dari 7 pemilih. Empat `<input type="time">` di formulir shift
48
+ keamanan-cluster tetap native **hanya** karena menukarnya ke `FdyTimepicker` akan memperbaiki
49
+ penampilannya dan diam-diam membuang penegakan wajib-isinya.
50
+
51
+ Koreksi atas catatan rilis sebelumnya, yang layak dibawa: ia menulis "tiga belas wrapper bertipe
52
+ merender `<button>`". Salah. Hanya **lima** yang berpemicu tombol; `FdyAutocomplete` dan `FdyCfl`
53
+ merender `<input>` asli, dan tujuh sisanya bukan kontrol bernilai. Angka yang dibesarkan membuat
54
+ perbaikan ini terdengar jauh lebih mahal daripada yang ternyata.
55
+
56
+ ### Added
57
+ - **`data-fdy-required` pada akar kontrol** (`#096`), dan prop `required` / `Required` di
58
+ `FdyCombo`, `FdyDatepicker`, `FdyMonthpicker`, `FdyTimepicker` dan `FdyCascade` di ketiga stack.
59
+ Submit ditolak, pesannya dilukis di slot `.fdy-field` yang sudah ada, dan fokusnya ke pemicu.
60
+ Aturannya **tidak menyebut satu pun nama komponen**: nilainya dibaca dari `data-value`, elemen
61
+ yang menerima `aria-invalid` dan fokus ditemukan lewat `aria-haspopup` yang sudah dipasang setiap
62
+ pemicu, dan re-validasinya memakai `MutationObserver` pada atribut itu — supaya enhancer tak
63
+ perlu mengenal lima nama event (`fdy-change`, `fdy-datepicker-change`, `fdy-time-select`,
64
+ `fdy-cascade-change`).
65
+ - **Kontrol yang ditolak diurut menurut urutan DOM** (`#096`). Mengumpulkan kontrol native lebih
66
+ dulu lalu akar kit akan memfokuskan field KEDUA sementara yang pertama juga merah, dan pembaca
67
+ yang dikirim melewati field merah pertamanya belajar hal yang salah.
68
+
69
+ ### Changed
70
+ - **`data-value` di akar kontrol kini cermin hidup, bukan seed.** Di jalur mentah, datepicker,
71
+ timepicker dan cascade hanya MEMBACANYA saat init; `freeday-select.js` sudah menulisnya kembali
72
+ sejak lama, jadi ini membuat keluarganya sepakat alih-alih memperkenalkan atribut kedua. Aditif:
73
+ tak ada yang membaca atribut itu sesudah init, sebab sebelum ini nilainya basi. Cerminnya
74
+ dipasang di `updateDisplay()`, satu titik sempit yang dilalui pick, clear, pilih-bulan dan
75
+ setRange, bukan di tiap tempat event dikirim.
76
+
77
+ ### Fixed
78
+ - **`FdyDatepicker`, `FdyMonthpicker` dan `FdyCascade` tak pernah mencerminkan nilainya ke akar**
79
+ di adapter Vue dan React (`#096`), padahal `FdyCombo` dan `FdyTimepicker` melakukannya. Tanpa itu
80
+ aturan di atas mustahil, dan atributnya juga pegangan yang dapat dibaca uji atau aturan CSS
81
+ keadaan-kosong.
82
+
83
+ ### Catatan
84
+ - `FdyAutocomplete` dan `FdyCfl` **di luar** aturan ini: keduanya merender `<input>` asli, jadi
85
+ `required` native yang cocok. Belum ada prop yang mengeksposnya, dan atribut tak dikenal jatuh ke
86
+ AKAR wrapper bukan ke input di dalamnya, jadi hari ini keduanya tak bisa dijadikan wajib di stack
87
+ mana pun. Dicatat terbuka di `NEXT-UP.md` alih-alih ditutup separuh: mengalirkannya lewat jalur
88
+ kit akan memberi satu field dua sumber kebenaran.
89
+ - Penimpaan pesan per-kontrol (`data-fdy-msg-required` di akar) bekerja di mana atribut mencapai
90
+ akar: jalur mentah, dan Vue, yang meneruskan atribut tak dikenal. React dan Blazor tidak, jadi
91
+ jalur yang terbaca sama di semua stack adalah `data-fdy-text-required` di elemen `<form>`.
92
+
12
93
  ## [3.11.0] - 2026-10-26
13
94
 
14
95
  **Audit kontrol native di seluruh layar sebuah aplikasi konsumen, bukan di satu layar yang
package/COMPONENTS.md CHANGED
@@ -709,6 +709,7 @@ while each line referenced directly would be read even when hidden. Zero JS; wor
709
709
  | `ariaLabelledby?` | `string` | Id of the element that labels the combobox. |
710
710
  | `placeholder?` | `string` | Shown when no option matches the current value. |
711
711
  | `disabled?` | `boolean` | Greyed and out of the tab order, the native `disabled` semantics. |
712
+ | `required?` | `boolean` | Lets a [`data-fdy-validate`](#form-validation--data-fdy-validate) form demand this control be filled. Renders `data-fdy-required` on the root. Needed because the trigger is a `<button>`, whose `willValidate` is false, so a plain `required` on it has never meant anything. Blazor: `Required`. |
712
713
  | `readonly?` | `boolean` | Locked/view mode: keeps focus and tab order and shows its value, but cannot be opened or changed. Unlike `disabled` it is not greyed. |
713
714
  | `invalid?` | `boolean` | Sets `aria-invalid`; pair it with `describedby` pointing at the error text. |
714
715
  | `describedby?` | `string` | Id of the help or error text (`aria-describedby`). |
@@ -786,6 +787,7 @@ Editable combobox that filters as you type. Needs `freeday-autocomplete.js`.
786
787
  | `placeholder?` | `string` | |
787
788
  | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
788
789
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | As on `<FdyCombo>`. |
790
+ | `required?` | `boolean` | Lets a [`data-fdy-validate`](#form-validation--data-fdy-validate) form demand this control be filled. Renders `data-fdy-required` on the root. Needed because the trigger is a `<button>`, whose `willValidate` is false, so a plain `required` on it has never meant anything. Blazor: `Required`. |
789
791
 
790
792
  Raw path: `data-disabled` / `data-readonly` / `data-invalid`, `data-id`, `data-describedby`, and
791
793
  `FreedayCascade.setState(root, { disabled, readonly, invalid })` for a host that rendered once.
@@ -962,6 +964,7 @@ a new class.
962
964
  | `placeholder?` | `string` | |
963
965
  | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
964
966
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps focus and tab order and shows its date, but it cannot be opened, cleared or changed. |
967
+ | `required?` | `boolean` | Lets a [`data-fdy-validate`](#form-validation--data-fdy-validate) form demand this control be filled. Renders `data-fdy-required` on the root. Needed because the trigger is a `<button>`, whose `willValidate` is false, so a plain `required` on it has never meant anything. Blazor: `Required`. |
965
968
  | `clearable?` | `boolean` | Show a × in the trigger once a date is set, so an optional date can be unset. Sends `''`. Off by default. |
966
969
  | `clearLabel?` | `string` | Accessible name for that ×. Default `Clear date`. |
967
970
  | `prevMonthLabel?` · `nextMonthLabel?` | `string` | The month arrows. Defaults `Previous month` / `Next month`. |
@@ -996,6 +999,7 @@ page's `<html lang>`), `clearable` and `clearLabel` (this path builds no clear b
996
999
  | `placeholder?` | `string` | Shown while nothing is picked. Default `Select month`. |
997
1000
  | `id?` · `ariaLabelledby?` · `describedby?` | `string` | Trigger id; the element that labels it; the help/error text it points at. |
998
1001
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps focus and tab order and shows its month, but it cannot be opened, cleared or changed. |
1002
+ | `required?` | `boolean` | Lets a [`data-fdy-validate`](#form-validation--data-fdy-validate) form demand this control be filled. Renders `data-fdy-required` on the root. Needed because the trigger is a `<button>`, whose `willValidate` is false, so a plain `required` on it has never meant anything. Blazor: `Required`. |
999
1003
  | `clearable?` | `boolean` | Show a × in the trigger once a month is set, so an optional month can be unset. Sends `''`. Off by default. |
1000
1004
  | `clearLabel?` | `string` | Accessible name for that ×. Default `Clear month`. |
1001
1005
  | `prevYearLabel?` · `nextYearLabel?` | `string` | The arrows beside the month grid, which step a year. Defaults `Previous year` / `Next year`. |
@@ -1143,6 +1147,7 @@ browser at the same width therefore disagree about a screen that is otherwise id
1143
1147
  | `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Names the trigger, or points at the text that does. Blazor: `Label` / `Describedby`. |
1144
1148
  | `id?` | `string` | Id for the trigger. It is a `<button>`, which is **not** a labelable element, so a `<label for>` will not activate it — name it with `ariaLabel`/`ariaLabelledby`. |
1145
1149
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps tab order and shows the time but will not open. |
1150
+ | `required?` | `boolean` | Lets a [`data-fdy-validate`](#form-validation--data-fdy-validate) form demand this control be filled. Renders `data-fdy-required` on the root. Needed because the trigger is a `<button>`, whose `willValidate` is false, so a plain `required` on it has never meant anything. Blazor: `Required`. |
1146
1151
 
1147
1152
  Keyboard: `ArrowDown`/`ArrowUp` move one option, `Home`/`End` jump to the ends, `Enter`/`Space`
1148
1153
  pick, `Escape` closes. The highlight is **clamped, not cyclic** — `ArrowUp` at `00:00` stays there,
@@ -1223,6 +1228,26 @@ blur/input. Needs `freeday-form.js`.
1223
1228
  - Per-control messages: `data-fdy-msg-required`, `-type`, `-minlength`, `-min`, `-mismatch`, …
1224
1229
  - Cross-field: `data-fdy-match="#otherFieldId"`
1225
1230
  - Emits `fdy-form-valid` / `fdy-form-invalid`.
1231
+ - **A kit control can be required too** — `data-fdy-required` on its root. Combo, datepicker,
1232
+ monthpicker, timepicker and cascade build a `<button>` trigger, and a button's `willValidate` is
1233
+ false, so `form.elements` never offered one and `required` on it meant nothing. The enhancer now
1234
+ collects these roots alongside the native controls: empty `data-value` refuses the submit, the
1235
+ message lands in the same `.fdy-field` slot, and the focus goes to the trigger. It names no
1236
+ component — the value is read from `data-value` and the trigger is found by `aria-haspopup`. In
1237
+ the typed stacks pass `required` / `Required` instead of writing the attribute.
1238
+ **`FdyAutocomplete` and `FdyCfl` are outside this rule**: they render a real `<input>`, so the
1239
+ native `required` is the mechanism that fits them — but no prop exposes it yet, and an unknown
1240
+ attribute falls through to a wrapper's ROOT rather than to the input inside it, so today an app
1241
+ cannot require either one. Tracked in `NEXT-UP.md`; the rule above deliberately does not cover
1242
+ them, because painting a native input through the kit path would give it two sources of truth.
1243
+ Re-validation watches the attribute with a `MutationObserver`, so no control has to announce
1244
+ itself. The message override is per-control (`data-fdy-msg-required` on the root) wherever
1245
+ attributes reach the root — the raw path, and Vue, where an unknown attribute falls through. In
1246
+ React and Blazor they do not, so use the form-level `data-fdy-text-required`, which reads the
1247
+ same in every stack.
1248
+ - **`data-value` on a control's root is the live value**, not just the seed it used to be on the
1249
+ datepicker, timepicker and cascade (the combo always kept it current). That is what makes the
1250
+ rule above possible, and it is also the handle a test or a CSS empty-state rule can read.
1226
1251
  - **A refused field is revealed before it is focused**, because `focus()` on a control that is not
1227
1252
  rendered does nothing. A closed `<details>` around it (accordion, tree, nav group) is opened, an
1228
1253
  unselected tab is selected by clicking it, and a `.fdy-step-panel` left behind is returned to by
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.11.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.11.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.12.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.12.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.11.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.11.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.12.1-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.12.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`.
@@ -16,7 +16,8 @@
16
16
  data-describedby="@Describedby"
17
17
  data-disabled="@(Disabled ? "true" : null)"
18
18
  data-readonly="@(Readonly ? "true" : null)"
19
- data-invalid="@(Invalid ? "true" : null)">
19
+ data-invalid="@(Invalid ? "true" : null)"
20
+ data-fdy-required="@(Required ? "" : null)">
20
21
  <ul>
21
22
  @foreach (FdyCascadeNode node in Nodes)
22
23
  {
@@ -46,6 +46,13 @@ public partial class FdyCascade
46
46
  /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
47
47
  [Parameter] public bool Invalid { get; set; }
48
48
 
49
+ /// <summary>Let a <c>data-fdy-validate</c> form demand this control be filled. Renders
50
+ /// <c>data-fdy-required</c> on the root, and <c>freeday-form.js</c> refuses the submit, paints
51
+ /// the message in the field and focuses the trigger. Needed because the trigger is a
52
+ /// <c>&lt;button&gt;</c>: <c>willValidate</c> is false, so a plain <c>required</c> on it has
53
+ /// never meant anything. Override the message with <c>data-fdy-msg-required</c> on the root.</summary>
54
+ [Parameter] public bool Required { get; set; }
55
+
49
56
  protected override bool ShouldRender() => !Hydrated;
50
57
 
51
58
  /* Pushed, not re-rendered: the seed's <ul> is consumed and removed by the enhancer, so this
@@ -6,7 +6,8 @@
6
6
  markup the freeday-select.js enhancer expects; the enhancer owns the open/keyboard/focus
7
7
  behaviour and emits fdy-change, which flows back to @bind-Value. An externally-changed Value
8
8
  is pushed onto the enhancer silently (no event echo). *@
9
- <div @ref="Root" class="@RootClass" data-fdy-combo data-value="@SelectedKey">
9
+ <div @ref="Root" class="@RootClass" data-fdy-combo data-value="@SelectedKey"
10
+ data-fdy-required="@(Required ? "" : null)">
10
11
  <button id="@ButtonId" type="button" class="fdy-combo__button" role="combobox"
11
12
  aria-haspopup="listbox" aria-expanded="false"
12
13
  aria-labelledby="@AriaLabelledby"
@@ -29,6 +29,13 @@ public partial class FdyCombo<TValue>
29
29
 
30
30
  [Parameter] public bool Invalid { get; set; }
31
31
 
32
+ /// <summary>Let a <c>data-fdy-validate</c> form demand this control be filled. Renders
33
+ /// <c>data-fdy-required</c> on the root, and <c>freeday-form.js</c> refuses the submit, paints
34
+ /// the message in the field and focuses the trigger. Needed because the trigger is a
35
+ /// <c>&lt;button&gt;</c>: <c>willValidate</c> is false, so a plain <c>required</c> on it has
36
+ /// never meant anything. Override the message with <c>data-fdy-msg-required</c> on the root.</summary>
37
+ [Parameter] public bool Required { get; set; }
38
+
32
39
  /// <summary>Id of the help or error text the combobox describes itself with. Vue and React
33
40
  /// have always had this one; Blazor was the odd stack out.</summary>
34
41
  [Parameter] public string? Describedby { get; set; }
@@ -18,6 +18,7 @@
18
18
  data-disabled="@(Disabled ? "true" : null)"
19
19
  data-readonly="@(Readonly ? "true" : null)"
20
20
  data-invalid="@(Invalid ? "true" : null)"
21
+ data-fdy-required="@(Required ? "" : null)"
21
22
  data-fdy-text-prev-month="@PrevMonthLabel"
22
23
  data-fdy-text-next-month="@NextMonthLabel"
23
24
  data-fdy-text-prev-year="@PrevYearLabel"
@@ -54,6 +54,13 @@ public partial class FdyDatepicker
54
54
  /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
55
55
  [Parameter] public bool Invalid { get; set; }
56
56
 
57
+ /// <summary>Let a <c>data-fdy-validate</c> form demand this control be filled. Renders
58
+ /// <c>data-fdy-required</c> on the root, and <c>freeday-form.js</c> refuses the submit, paints
59
+ /// the message in the field and focuses the trigger. Needed because the trigger is a
60
+ /// <c>&lt;button&gt;</c>: <c>willValidate</c> is false, so a plain <c>required</c> on it has
61
+ /// never meant anything. Override the message with <c>data-fdy-msg-required</c> on the root.</summary>
62
+ [Parameter] public bool Required { get; set; }
63
+
57
64
  /* The calendar's own navigation labels. Month and weekday NAMES follow the page's `lang`
58
65
  through Intl and need nothing here; these are the buttons around them, which until 2.1.1
59
66
  were literals inside the enhancer that no host could reach. Leave one null and the
@@ -21,6 +21,7 @@
21
21
  data-disabled="@(Disabled ? "true" : null)"
22
22
  data-readonly="@(Readonly ? "true" : null)"
23
23
  data-invalid="@(Invalid ? "true" : null)"
24
+ data-fdy-required="@(Required ? "" : null)"
24
25
  data-fdy-text-prev-year="@PrevYearLabel"
25
26
  data-fdy-text-next-year="@NextYearLabel"
26
27
  data-fdy-text-prev-years="@PrevYearsLabel"
@@ -57,6 +57,13 @@ public partial class FdyMonthpicker
57
57
  /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
58
58
  [Parameter] public bool Invalid { get; set; }
59
59
 
60
+ /// <summary>Let a <c>data-fdy-validate</c> form demand this control be filled. Renders
61
+ /// <c>data-fdy-required</c> on the root, and <c>freeday-form.js</c> refuses the submit, paints
62
+ /// the message in the field and focuses the trigger. Needed because the trigger is a
63
+ /// <c>&lt;button&gt;</c>: <c>willValidate</c> is false, so a plain <c>required</c> on it has
64
+ /// never meant anything. Override the message with <c>data-fdy-msg-required</c> on the root.</summary>
65
+ [Parameter] public bool Required { get; set; }
66
+
60
67
  /* The calendar's navigation labels, and only the ones this picker shows. Month NAMES follow the
61
68
  page's `lang` through Intl and need nothing here. The day grid's own arrows are absent along
62
69
  with the day grid, so there is no PrevMonthLabel to pass: the arrows beside a month grid step
@@ -20,4 +20,5 @@
20
20
  data-describedby="@Describedby"
21
21
  data-disabled="@(Disabled ? "true" : null)"
22
22
  data-readonly="@(Readonly ? "true" : null)"
23
- data-invalid="@(Invalid ? "true" : null)"></div>
23
+ data-invalid="@(Invalid ? "true" : null)"
24
+ data-fdy-required="@(Required ? "" : null)"></div>
@@ -48,6 +48,13 @@ public partial class FdyTimepicker
48
48
  /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
49
49
  [Parameter] public bool Invalid { get; set; }
50
50
 
51
+ /// <summary>Let a <c>data-fdy-validate</c> form demand this control be filled. Renders
52
+ /// <c>data-fdy-required</c> on the root, and <c>freeday-form.js</c> refuses the submit, paints
53
+ /// the message in the field and focuses the trigger. Needed because the trigger is a
54
+ /// <c>&lt;button&gt;</c>: <c>willValidate</c> is false, so a plain <c>required</c> on it has
55
+ /// never meant anything. Override the message with <c>data-fdy-msg-required</c> on the root.</summary>
56
+ [Parameter] public bool Required { get; set; }
57
+
51
58
  // The enhancer owns this control's DOM; init it directly (initAll would skip Root itself).
52
59
  protected override ValueTask HydrateAsync() => JS.InvokeVoidAsync("FreedayTimepicker.init", Root);
53
60
 
@@ -33,6 +33,14 @@ export interface FdyCascadeProps {
33
33
  disabled?: boolean;
34
34
  /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
35
35
  readonly?: boolean;
36
+ /**
37
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
38
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
39
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
40
+ * a plain `required` on it has never meant anything (#096). Override the message per control
41
+ * with `data-fdy-msg-required` on the root.
42
+ */
43
+ required?: boolean;
36
44
  invalid?: boolean;
37
45
  describedby?: string;
38
46
  }
@@ -168,7 +176,8 @@ export function FdyCascade(props: FdyCascadeProps): JSX.Element {
168
176
  }, [active, open, stack]);
169
177
 
170
178
  return (
171
- <div ref={rootRef} className={isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'}>
179
+ <div ref={rootRef} className={isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'}
180
+ data-value={props.value} data-fdy-required={props.required === true ? '' : undefined}>
172
181
  <button
173
182
  ref={triggerRef}
174
183
  id={triggerId}
@@ -17,6 +17,14 @@ export interface FdyComboProps<T extends string> {
17
17
  disabled?: boolean;
18
18
  /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
19
19
  readonly?: boolean;
20
+ /**
21
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
22
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
23
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
24
+ * a plain `required` on it has never meant anything (#096). Override the message per control
25
+ * with `data-fdy-msg-required` on the root.
26
+ */
27
+ required?: boolean;
20
28
  invalid?: boolean;
21
29
  describedby?: string;
22
30
  }
@@ -128,6 +136,7 @@ export function FdyCombo<T extends string>(props: FdyComboProps<T>): JSX.Element
128
136
  ref={rootRef}
129
137
  className={isInvalid ? 'fdy-combo fdy-combo--error' : 'fdy-combo'}
130
138
  data-value={props.value}
139
+ data-fdy-required={props.required === true ? '' : undefined}
131
140
  onKeyDown={onKeyDown}
132
141
  onBlur={onFocusout}
133
142
  >
@@ -73,6 +73,14 @@ export interface FdyDatepickerProps {
73
73
  disabled?: boolean;
74
74
  /** Locked/view mode: stays focusable and shows its date, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
75
75
  readonly?: boolean;
76
+ /**
77
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
78
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
79
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
80
+ * a plain `required` on it has never meant anything (#096). Override the message per control
81
+ * with `data-fdy-msg-required` on the root.
82
+ */
83
+ required?: boolean;
76
84
  invalid?: boolean;
77
85
  describedby?: string;
78
86
  id?: string;
@@ -527,7 +535,8 @@ export function FdyDatepicker(props: FdyDatepickerProps): JSX.Element {
527
535
  }, [open]);
528
536
 
529
537
  return (
530
- <div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'} onBlur={onFocusout}>
538
+ <div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'}
539
+ data-value={props.value ?? ''} data-fdy-required={props.required === true ? '' : undefined} onBlur={onFocusout}>
531
540
  <button
532
541
  id={triggerId}
533
542
  ref={triggerRef}
@@ -50,6 +50,14 @@ export interface FdyMonthpickerProps {
50
50
  disabled?: boolean;
51
51
  /** Locked/view mode: stays focusable and shows its month, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
52
52
  readonly?: boolean;
53
+ /**
54
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
55
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
56
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
57
+ * a plain `required` on it has never meant anything (#096). Override the message per control
58
+ * with `data-fdy-msg-required` on the root.
59
+ */
60
+ required?: boolean;
53
61
  invalid?: boolean;
54
62
  describedby?: string;
55
63
  id?: string;
@@ -277,7 +285,8 @@ export function FdyMonthpicker(props: FdyMonthpickerProps): JSX.Element {
277
285
  }, [open]);
278
286
 
279
287
  return (
280
- <div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'} onBlur={onFocusout}>
288
+ <div ref={rootRef} className={isInvalid ? 'fdy-datepicker fdy-datepicker--error' : 'fdy-datepicker'}
289
+ data-value={props.value ?? ''} data-fdy-required={props.required === true ? '' : undefined} onBlur={onFocusout}>
281
290
  <button
282
291
  id={triggerId}
283
292
  ref={triggerRef}
@@ -26,6 +26,14 @@ export interface FdyTimepickerProps {
26
26
  disabled?: boolean;
27
27
  /** Locked/view mode: stays focusable and shows its time, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
28
28
  readonly?: boolean;
29
+ /**
30
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
31
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
32
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
33
+ * a plain `required` on it has never meant anything (#096). Override the message per control
34
+ * with `data-fdy-msg-required` on the root.
35
+ */
36
+ required?: boolean;
29
37
  invalid?: boolean;
30
38
  describedby?: string;
31
39
  }
@@ -160,6 +168,7 @@ export function FdyTimepicker(props: FdyTimepickerProps): JSX.Element {
160
168
  ref={rootRef}
161
169
  className={isInvalid ? 'fdy-timepicker fdy-timepicker--error' : 'fdy-timepicker'}
162
170
  data-value={value}
171
+ data-fdy-required={props.required === true ? '' : undefined}
163
172
  onKeyDown={onKeyDown}
164
173
  onBlur={onFocusout}
165
174
  >
@@ -46,6 +46,14 @@ const props = defineProps<{
46
46
  disabled?: boolean;
47
47
  /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
48
48
  readonly?: boolean;
49
+ /**
50
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
51
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
52
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
53
+ * a plain `required` on it has never meant anything (#096). Override the message per control
54
+ * with `data-fdy-msg-required`, which falls through onto the root.
55
+ */
56
+ required?: boolean;
49
57
  invalid?: boolean;
50
58
  describedby?: string;
51
59
  }>();
@@ -184,7 +192,8 @@ onBeforeUnmount((): void => {
184
192
  </script>
185
193
 
186
194
  <template>
187
- <div ref="rootEl" :class="isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'">
195
+ <div ref="rootEl" :class="isInvalid ? 'fdy-cascade fdy-cascade--error' : 'fdy-cascade'"
196
+ :data-value="modelValue" :data-fdy-required="required === true ? '' : undefined">
188
197
  <button
189
198
  :id="triggerId"
190
199
  ref="triggerEl"
@@ -24,6 +24,14 @@ const props = defineProps<{
24
24
  disabled?: boolean;
25
25
  /** Locked/view mode: stays focusable and shows its value, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
26
26
  readonly?: boolean;
27
+ /**
28
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
29
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
30
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
31
+ * a plain `required` on it has never meant anything (#096). Override the message per control
32
+ * with `data-fdy-msg-required`, which falls through onto the root.
33
+ */
34
+ required?: boolean;
27
35
  invalid?: boolean;
28
36
  describedby?: string;
29
37
  }>();
@@ -194,6 +202,7 @@ onBeforeUnmount((): void => {
194
202
  class="fdy-combo"
195
203
  :class="{ 'fdy-combo--error': isInvalid }"
196
204
  :data-value="modelValue"
205
+ :data-fdy-required="required === true ? '' : undefined"
197
206
  @keydown="onKeydown"
198
207
  @focusout="onFocusout"
199
208
  >
@@ -75,6 +75,14 @@ const props = defineProps<{
75
75
  disabled?: boolean;
76
76
  /** Locked/view mode: stays focusable and shows its date, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
77
77
  readonly?: boolean;
78
+ /**
79
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
80
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
81
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
82
+ * a plain `required` on it has never meant anything (#096). Override the message per control
83
+ * with `data-fdy-msg-required`, which falls through onto the root.
84
+ */
85
+ required?: boolean;
78
86
  invalid?: boolean;
79
87
  describedby?: string;
80
88
  id?: string;
@@ -516,7 +524,8 @@ onBeforeUnmount((): void => {
516
524
  </script>
517
525
 
518
526
  <template>
519
- <div ref="rootEl" class="fdy-datepicker" :class="{ 'fdy-datepicker--error': isInvalid }" @focusout="onFocusout">
527
+ <div ref="rootEl" class="fdy-datepicker" :class="{ 'fdy-datepicker--error': isInvalid }"
528
+ :data-value="modelValue ?? ''" :data-fdy-required="required === true ? '' : undefined" @focusout="onFocusout">
520
529
  <button
521
530
  :id="triggerId"
522
531
  ref="triggerEl"
@@ -57,6 +57,14 @@ const props = defineProps<{
57
57
  disabled?: boolean;
58
58
  /** Locked/view mode: stays focusable and shows its month, but can't be opened, cleared, or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
59
59
  readonly?: boolean;
60
+ /**
61
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
62
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
63
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
64
+ * a plain `required` on it has never meant anything (#096). Override the message per control
65
+ * with `data-fdy-msg-required`, which falls through onto the root.
66
+ */
67
+ required?: boolean;
60
68
  invalid?: boolean;
61
69
  describedby?: string;
62
70
  id?: string;
@@ -290,7 +298,8 @@ onBeforeUnmount((): void => {
290
298
  </script>
291
299
 
292
300
  <template>
293
- <div ref="rootEl" class="fdy-datepicker" :class="{ 'fdy-datepicker--error': isInvalid }" @focusout="onFocusout">
301
+ <div ref="rootEl" class="fdy-datepicker" :class="{ 'fdy-datepicker--error': isInvalid }"
302
+ :data-value="modelValue ?? ''" :data-fdy-required="required === true ? '' : undefined" @focusout="onFocusout">
294
303
  <button
295
304
  :id="triggerId"
296
305
  ref="triggerEl"
@@ -34,6 +34,14 @@ const props = defineProps<{
34
34
  disabled?: boolean;
35
35
  /** Locked/view mode: stays focusable and shows its time, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
36
36
  readonly?: boolean;
37
+ /**
38
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
39
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
40
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
41
+ * a plain `required` on it has never meant anything (#096). Override the message per control
42
+ * with `data-fdy-msg-required`, which falls through onto the root.
43
+ */
44
+ required?: boolean;
37
45
  invalid?: boolean;
38
46
  describedby?: string;
39
47
  }>();
@@ -224,6 +232,7 @@ onBeforeUnmount((): void => {
224
232
  class="fdy-timepicker"
225
233
  :class="{ 'fdy-timepicker--error': isInvalid }"
226
234
  :data-value="value"
235
+ :data-fdy-required="required === true ? '' : undefined"
227
236
  @keydown="onKeydown"
228
237
  @focusout="onFocusout"
229
238
  >
@@ -198,6 +198,11 @@
198
198
  if (!node) return;
199
199
  if (node.children) { drill(i); return; }
200
200
  selectedValue = node.value;
201
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
202
+ always done this, so this makes the family agree rather than inventing a second idea, and
203
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
204
+ is: one attribute instead of five event names (#096). */
205
+ wrap.setAttribute('data-value', node.value);
201
206
  var labels = stack.map(function (n) { return n.label; }).concat([node.label]);
202
207
  valueSpan.textContent = labels.join(sep);
203
208
  valueSpan.classList.remove('fdy-cascade__value--placeholder');
@@ -175,6 +175,11 @@
175
175
  wrap.appendChild(panel);
176
176
 
177
177
  function updateDisplay() {
178
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
179
+ always done this, so this makes the family agree rather than inventing a second idea, and
180
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
181
+ is: one attribute instead of five event names (#096). */
182
+ wrap.setAttribute('data-value', selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : '');
178
183
  if (selected) {
179
184
  /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
180
185
  which is the whole reason this mode exists: <input type="month"> names the month in the
@@ -74,6 +74,36 @@
74
74
 
75
75
  function fieldOf(el) { return el.closest('.fdy-field') || el.parentElement; }
76
76
 
77
+ /* A kit control (combo, datepicker, monthpicker, timepicker, cascade) is NOT a form control.
78
+ * Its trigger is a <button>, so `willValidate` is false and `form.elements` never offers it --
79
+ * `required` on one meant nothing, and a form could not demand that it be filled (#096).
80
+ *
81
+ * Nothing here names a component. Two attributes carry it:
82
+ * data-fdy-required the app asking for it, on the control's ROOT
83
+ * data-value the live value, which every value-bearing kit control now mirrors
84
+ * and the element that takes aria-invalid and focus is found by `aria-haspopup`, which every
85
+ * popup-style kit trigger already sets. `FdyAutocomplete` and `FdyCfl` are absent on purpose:
86
+ * they render a real <input>, so the NATIVE `required` is what fits them, and routing them
87
+ * through here would give one field two sources of truth. No prop exposes that yet, which is a
88
+ * separate gap and is written down in NEXT-UP.md rather than half-closed here. */
89
+ function kitRoots(form) {
90
+ return Array.prototype.slice.call(form.querySelectorAll('[data-fdy-required]'));
91
+ }
92
+
93
+ /* The trigger, or the root itself if a control ever ships without one, so a missing trigger
94
+ degrades to a visible error rather than a thrown TypeError. */
95
+ function kitAria(root) {
96
+ return root.querySelector('[aria-haspopup]') || root;
97
+ }
98
+
99
+ function kitMessage(root) {
100
+ if ((root.getAttribute('data-value') || '') !== '') return '';
101
+ var form = root.closest('form');
102
+ return root.getAttribute('data-fdy-msg-required')
103
+ || root.getAttribute('data-fdy-msg')
104
+ || textOf(form, 'required');
105
+ }
106
+
77
107
  function ensureErrorEl(el) {
78
108
  var field = fieldOf(el);
79
109
  var errEl = field ? field.querySelector('[data-fdy-error]') : null;
@@ -164,6 +194,21 @@
164
194
  function controls() {
165
195
  return Array.prototype.filter.call(form.elements, isCandidate);
166
196
  }
197
+ function validateKit(root) {
198
+ var msg = kitMessage(root);
199
+ paint(kitAria(root), msg);
200
+ return !msg;
201
+ }
202
+ /* The value lives in an attribute, and each control changes it through its own event name
203
+ (fdy-change, fdy-datepicker-change, fdy-time-select, fdy-cascade-change). Observing the
204
+ attribute is what keeps this enhancer from having to know any of them. */
205
+ function kitLiveBind(root) {
206
+ if (root.dataset.fdyKitBound === '1') return;
207
+ root.dataset.fdyKitBound = '1';
208
+ new MutationObserver(function () {
209
+ if (submitted || eager) validateKit(root);
210
+ }).observe(root, { attributes: true, attributeFilter: ['data-value'] });
211
+ }
167
212
  function validateOne(el) {
168
213
  var msg = messageFor(el);
169
214
  paint(el, msg);
@@ -178,6 +223,7 @@
178
223
  }
179
224
 
180
225
  Array.prototype.forEach.call(controls(), liveBind);
226
+ kitRoots(form).forEach(kitLiveBind);
181
227
 
182
228
  form.addEventListener('submit', function (e) {
183
229
  submitted = true;
@@ -186,6 +232,18 @@
186
232
  liveBind(el);
187
233
  if (!validateOne(el)) invalid.push(el);
188
234
  });
235
+ kitRoots(form).forEach(function (root) {
236
+ kitLiveBind(root);
237
+ if (!validateKit(root)) invalid.push(kitAria(root));
238
+ });
239
+ /* DOM order, not native-then-kit: `invalid[0]` is what gets revealed and focused, and a
240
+ reader sent to the second field while the first is also red learns the wrong thing. */
241
+ invalid.sort(function (a, b) {
242
+ var rel = a.compareDocumentPosition(b);
243
+ if (rel & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
244
+ if (rel & Node.DOCUMENT_POSITION_PRECEDING) return 1;
245
+ return 0;
246
+ });
189
247
  if (invalid.length) {
190
248
  e.preventDefault();
191
249
  reveal(invalid[0]);
@@ -202,11 +260,13 @@
202
260
  submitted = true;
203
261
  var invalid = [];
204
262
  Array.prototype.forEach.call(controls(), function (el) { if (!validateOne(el)) invalid.push(el); });
263
+ kitRoots(form).forEach(function (root) { if (!validateKit(root)) invalid.push(kitAria(root)); });
205
264
  return invalid;
206
265
  },
207
266
  reset: function () {
208
267
  submitted = false;
209
268
  Array.prototype.forEach.call(controls(), function (el) { paint(el, ''); });
269
+ kitRoots(form).forEach(function (root) { paint(kitAria(root), ''); });
210
270
  }
211
271
  };
212
272
  form._fdyForm = api;
@@ -130,6 +130,11 @@
130
130
  }
131
131
 
132
132
  function updateDisplay() {
133
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
134
+ always done this, so this makes the family agree rather than inventing a second idea, and
135
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
136
+ is: one attribute instead of five event names (#096). */
137
+ wrap.setAttribute('data-value', value);
133
138
  if (value) {
134
139
  valueSpan.textContent = value;
135
140
  valueSpan.classList.remove('fdy-timepicker__value--placeholder');
@@ -2472,10 +2472,10 @@ a { color: var(--color-primary-strong); }
2472
2472
  /* display is gated behind [open] so the native <dialog> stays hidden until opened.
2473
2473
  * An unconditional display here would beat the UA `dialog:not([open]){display:none}`
2474
2474
  * rule (author origin wins over user-agent) and leave the drawer visible on load. */
2475
- .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition:transform var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
2475
+ .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
2476
2476
  .fdy-drawer[open]{display:flex;transform:none;}
2477
2477
  .fdy-drawer--right{inset:0 0 0 auto;transform:translateX(100%);}
2478
- .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
2478
+ .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
2479
2479
  .fdy-drawer__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
2480
2480
  .fdy-drawer__title{font-family:var(--font-display);font-weight:var(--weight-semibold);font-size:var(--text-lg);color:var(--color-text);margin:0 auto 0 0;}
2481
2481
  .fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
@@ -2778,12 +2778,12 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2778
2778
  .fdy-btn-split__toggle svg{display:block;width:1rem;height:1rem;}
2779
2779
 
2780
2780
  /* Freeday. Modal (native <dialog>) */
2781
- .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);transition:opacity var(--dur-base) var(--ease-spring),transform var(--dur-base) var(--ease-spring),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
2781
+ .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
2782
2782
  .fdy-modal--sm{width:min(24rem,calc(100vw - var(--space-8)));}
2783
2783
  .fdy-modal--md{width:min(32rem,calc(100vw - var(--space-8)));}
2784
2784
  .fdy-modal--lg{width:min(48rem,calc(100vw - var(--space-8)));}
2785
2785
  .fdy-modal--wide{width:min(60rem,calc(100vw - var(--space-8)));}
2786
- .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-base) var(--ease-standard),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
2786
+ .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
2787
2787
  .fdy-modal__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
2788
2788
  .fdy-modal__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;}
2789
2789
  .fdy-modal__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
package/dist/freeday.css CHANGED
@@ -1295,10 +1295,10 @@ a { color: var(--color-primary-strong); }
1295
1295
  /* display is gated behind [open] so the native <dialog> stays hidden until opened.
1296
1296
  * An unconditional display here would beat the UA `dialog:not([open]){display:none}`
1297
1297
  * rule (author origin wins over user-agent) and leave the drawer visible on load. */
1298
- .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition:transform var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
1298
+ .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
1299
1299
  .fdy-drawer[open]{display:flex;transform:none;}
1300
1300
  .fdy-drawer--right{inset:0 0 0 auto;transform:translateX(100%);}
1301
- .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
1301
+ .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
1302
1302
  .fdy-drawer__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1303
1303
  .fdy-drawer__title{font-family:var(--font-display);font-weight:var(--weight-semibold);font-size:var(--text-lg);color:var(--color-text);margin:0 auto 0 0;}
1304
1304
  .fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
@@ -1601,12 +1601,12 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1601
1601
  .fdy-btn-split__toggle svg{display:block;width:1rem;height:1rem;}
1602
1602
 
1603
1603
  /* Freeday. Modal (native <dialog>) */
1604
- .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);transition:opacity var(--dur-base) var(--ease-spring),transform var(--dur-base) var(--ease-spring),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
1604
+ .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
1605
1605
  .fdy-modal--sm{width:min(24rem,calc(100vw - var(--space-8)));}
1606
1606
  .fdy-modal--md{width:min(32rem,calc(100vw - var(--space-8)));}
1607
1607
  .fdy-modal--lg{width:min(48rem,calc(100vw - var(--space-8)));}
1608
1608
  .fdy-modal--wide{width:min(60rem,calc(100vw - var(--space-8)));}
1609
- .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-base) var(--ease-standard),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
1609
+ .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
1610
1610
  .fdy-modal__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1611
1611
  .fdy-modal__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;}
1612
1612
  .fdy-modal__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
package/dist/freeday.js CHANGED
@@ -944,6 +944,11 @@
944
944
  if (!node) return;
945
945
  if (node.children) { drill(i); return; }
946
946
  selectedValue = node.value;
947
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
948
+ always done this, so this makes the family agree rather than inventing a second idea, and
949
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
950
+ is: one attribute instead of five event names (#096). */
951
+ wrap.setAttribute('data-value', node.value);
947
952
  var labels = stack.map(function (n) { return n.label; }).concat([node.label]);
948
953
  valueSpan.textContent = labels.join(sep);
949
954
  valueSpan.classList.remove('fdy-cascade__value--placeholder');
@@ -2262,6 +2267,11 @@
2262
2267
  wrap.appendChild(panel);
2263
2268
 
2264
2269
  function updateDisplay() {
2270
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
2271
+ always done this, so this makes the family agree rather than inventing a second idea, and
2272
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
2273
+ is: one attribute instead of five event names (#096). */
2274
+ wrap.setAttribute('data-value', selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : '');
2265
2275
  if (selected) {
2266
2276
  /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
2267
2277
  which is the whole reason this mode exists: <input type="month"> names the month in the
@@ -3011,6 +3021,36 @@
3011
3021
 
3012
3022
  function fieldOf(el) { return el.closest('.fdy-field') || el.parentElement; }
3013
3023
 
3024
+ /* A kit control (combo, datepicker, monthpicker, timepicker, cascade) is NOT a form control.
3025
+ * Its trigger is a <button>, so `willValidate` is false and `form.elements` never offers it --
3026
+ * `required` on one meant nothing, and a form could not demand that it be filled (#096).
3027
+ *
3028
+ * Nothing here names a component. Two attributes carry it:
3029
+ * data-fdy-required the app asking for it, on the control's ROOT
3030
+ * data-value the live value, which every value-bearing kit control now mirrors
3031
+ * and the element that takes aria-invalid and focus is found by `aria-haspopup`, which every
3032
+ * popup-style kit trigger already sets. `FdyAutocomplete` and `FdyCfl` are absent on purpose:
3033
+ * they render a real <input>, so the NATIVE `required` is what fits them, and routing them
3034
+ * through here would give one field two sources of truth. No prop exposes that yet, which is a
3035
+ * separate gap and is written down in NEXT-UP.md rather than half-closed here. */
3036
+ function kitRoots(form) {
3037
+ return Array.prototype.slice.call(form.querySelectorAll('[data-fdy-required]'));
3038
+ }
3039
+
3040
+ /* The trigger, or the root itself if a control ever ships without one, so a missing trigger
3041
+ degrades to a visible error rather than a thrown TypeError. */
3042
+ function kitAria(root) {
3043
+ return root.querySelector('[aria-haspopup]') || root;
3044
+ }
3045
+
3046
+ function kitMessage(root) {
3047
+ if ((root.getAttribute('data-value') || '') !== '') return '';
3048
+ var form = root.closest('form');
3049
+ return root.getAttribute('data-fdy-msg-required')
3050
+ || root.getAttribute('data-fdy-msg')
3051
+ || textOf(form, 'required');
3052
+ }
3053
+
3014
3054
  function ensureErrorEl(el) {
3015
3055
  var field = fieldOf(el);
3016
3056
  var errEl = field ? field.querySelector('[data-fdy-error]') : null;
@@ -3101,6 +3141,21 @@
3101
3141
  function controls() {
3102
3142
  return Array.prototype.filter.call(form.elements, isCandidate);
3103
3143
  }
3144
+ function validateKit(root) {
3145
+ var msg = kitMessage(root);
3146
+ paint(kitAria(root), msg);
3147
+ return !msg;
3148
+ }
3149
+ /* The value lives in an attribute, and each control changes it through its own event name
3150
+ (fdy-change, fdy-datepicker-change, fdy-time-select, fdy-cascade-change). Observing the
3151
+ attribute is what keeps this enhancer from having to know any of them. */
3152
+ function kitLiveBind(root) {
3153
+ if (root.dataset.fdyKitBound === '1') return;
3154
+ root.dataset.fdyKitBound = '1';
3155
+ new MutationObserver(function () {
3156
+ if (submitted || eager) validateKit(root);
3157
+ }).observe(root, { attributes: true, attributeFilter: ['data-value'] });
3158
+ }
3104
3159
  function validateOne(el) {
3105
3160
  var msg = messageFor(el);
3106
3161
  paint(el, msg);
@@ -3115,6 +3170,7 @@
3115
3170
  }
3116
3171
 
3117
3172
  Array.prototype.forEach.call(controls(), liveBind);
3173
+ kitRoots(form).forEach(kitLiveBind);
3118
3174
 
3119
3175
  form.addEventListener('submit', function (e) {
3120
3176
  submitted = true;
@@ -3123,6 +3179,18 @@
3123
3179
  liveBind(el);
3124
3180
  if (!validateOne(el)) invalid.push(el);
3125
3181
  });
3182
+ kitRoots(form).forEach(function (root) {
3183
+ kitLiveBind(root);
3184
+ if (!validateKit(root)) invalid.push(kitAria(root));
3185
+ });
3186
+ /* DOM order, not native-then-kit: `invalid[0]` is what gets revealed and focused, and a
3187
+ reader sent to the second field while the first is also red learns the wrong thing. */
3188
+ invalid.sort(function (a, b) {
3189
+ var rel = a.compareDocumentPosition(b);
3190
+ if (rel & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
3191
+ if (rel & Node.DOCUMENT_POSITION_PRECEDING) return 1;
3192
+ return 0;
3193
+ });
3126
3194
  if (invalid.length) {
3127
3195
  e.preventDefault();
3128
3196
  reveal(invalid[0]);
@@ -3139,11 +3207,13 @@
3139
3207
  submitted = true;
3140
3208
  var invalid = [];
3141
3209
  Array.prototype.forEach.call(controls(), function (el) { if (!validateOne(el)) invalid.push(el); });
3210
+ kitRoots(form).forEach(function (root) { if (!validateKit(root)) invalid.push(kitAria(root)); });
3142
3211
  return invalid;
3143
3212
  },
3144
3213
  reset: function () {
3145
3214
  submitted = false;
3146
3215
  Array.prototype.forEach.call(controls(), function (el) { paint(el, ''); });
3216
+ kitRoots(form).forEach(function (root) { paint(kitAria(root), ''); });
3147
3217
  }
3148
3218
  };
3149
3219
  form._fdyForm = api;
@@ -5016,6 +5086,11 @@
5016
5086
  }
5017
5087
 
5018
5088
  function updateDisplay() {
5089
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
5090
+ always done this, so this makes the family agree rather than inventing a second idea, and
5091
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
5092
+ is: one attribute instead of five event names (#096). */
5093
+ wrap.setAttribute('data-value', value);
5019
5094
  if (value) {
5020
5095
  valueSpan.textContent = value;
5021
5096
  valueSpan.classList.remove('fdy-timepicker__value--placeholder');
@@ -198,7 +198,7 @@ live docs also have a copy button per component.
198
198
  ```bash
199
199
  npm i @cahyo-dimas/freeday
200
200
  ```
201
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.11.0"` (public npm package). `dist/` is
201
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.12.1"` (public npm package). `dist/` is
202
202
  committed and published → no build step; `npm ci` runs without auth.
203
203
 
204
204
  ### 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.11.0",
3
+ "version": "3.12.1",
4
4
  "description": "Freeday: token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -96,7 +96,7 @@
96
96
  "scripts": {
97
97
  "build": "node tokens/build.mjs",
98
98
  "test": "node --test",
99
- "test:browser": "node --test --test-concurrency=3 browser/color-mix.mjs 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/bottom-nav.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/tabs-vertical.mjs browser/menu-align.mjs browser/cfl-narrow.mjs browser/form-reveal.mjs browser/confirm-search-copy.mjs browser/daterange-settled.mjs browser/display-parts.mjs browser/table-empty.mjs browser/table-footer-text.mjs browser/month-mode.mjs browser/monthpicker.mjs browser/page-columns.mjs browser/table-hover.mjs browser/typed-popover.mjs browser/filter-parity.mjs browser/filter-wrap.mjs browser/filter-stack.mjs browser/timepicker.mjs browser/harness-contract.mjs",
99
+ "test:browser": "node --test --test-concurrency=3 browser/color-mix.mjs 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/bottom-nav.mjs browser/picker-states.mjs browser/tabs-strip.mjs browser/tabs-vertical.mjs browser/menu-align.mjs browser/cfl-narrow.mjs browser/form-reveal.mjs browser/confirm-search-copy.mjs browser/daterange-settled.mjs browser/display-parts.mjs browser/table-empty.mjs browser/table-footer-text.mjs browser/month-mode.mjs browser/monthpicker.mjs browser/page-columns.mjs browser/table-hover.mjs browser/typed-popover.mjs browser/filter-parity.mjs browser/filter-wrap.mjs browser/filter-stack.mjs browser/timepicker.mjs browser/form-kit-required.mjs browser/picker-in-modal.mjs browser/harness-contract.mjs",
100
100
  "prepack": "node tokens/build.mjs",
101
101
  "version": "node tokens/build.mjs && git add dist",
102
102
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit",
@@ -4,10 +4,10 @@
4
4
  /* display is gated behind [open] so the native <dialog> stays hidden until opened.
5
5
  * An unconditional display here would beat the UA `dialog:not([open]){display:none}`
6
6
  * rule (author origin wins over user-agent) and leave the drawer visible on load. */
7
- .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition:transform var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
7
+ .fdy-drawer{position:fixed;inset:0 auto 0 0;margin:0;padding:0;height:100vh;max-height:none;width:min(20rem,82vw);flex-direction:column;border:0;background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-4),var(--surface-inset);overflow:hidden;transform:translateX(-100%);transition-property:transform,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
8
8
  .fdy-drawer[open]{display:flex;transform:none;}
9
9
  .fdy-drawer--right{inset:0 0 0 auto;transform:translateX(100%);}
10
- .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-slow) var(--ease-standard),overlay var(--dur-slow) allow-discrete,display var(--dur-slow) allow-discrete;}
10
+ .fdy-drawer::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-slow);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
11
11
  .fdy-drawer__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
12
12
  .fdy-drawer__title{font-family:var(--font-display);font-weight:var(--weight-semibold);font-size:var(--text-lg);color:var(--color-text);margin:0 auto 0 0;}
13
13
  .fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
@@ -1,10 +1,10 @@
1
1
  /* Freeday. Modal (native <dialog>) */
2
- .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);transition:opacity var(--dur-base) var(--ease-spring),transform var(--dur-base) var(--ease-spring),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
2
+ .fdy-modal{width:min(32rem,calc(100vw - var(--space-8)));max-height:calc(100vh - var(--space-16));padding:0;border:var(--bw) solid var(--color-border);border-radius:var(--radius-xl);background:var(--color-surface-raised);backdrop-filter:var(--surface-filter);-webkit-backdrop-filter:var(--surface-filter);color:var(--color-text);box-shadow:var(--shadow-lift-hover),var(--surface-inset);overflow:hidden;flex-direction:column;opacity:0;transform:translateY(8px) scale(.98);/* Longhands, not the `transition` shorthand. A shorthand carrying one keyword the engine does not know is INVALID AT PARSE, so the whole declaration is thrown away -- and `allow-discrete` is unknown below Safari 17.4, which is under this kit's floor. The result was a modal and a drawer that opened and closed with no animation at all on an iPhone 7. Split this way only `transition-behavior` is dropped there, and the opacity/transform half survives. The lists align BY INDEX and reproduce the shorthand exactly, including `ease` for the discrete pair and `normal` for the continuous one -- verified by comparing computed styles before and after (#097). */transition-property:opacity,transform,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-spring),var(--ease-spring),ease,ease;transition-behavior:normal,normal,allow-discrete,allow-discrete;}
3
3
  .fdy-modal--sm{width:min(24rem,calc(100vw - var(--space-8)));}
4
4
  .fdy-modal--md{width:min(32rem,calc(100vw - var(--space-8)));}
5
5
  .fdy-modal--lg{width:min(48rem,calc(100vw - var(--space-8)));}
6
6
  .fdy-modal--wide{width:min(60rem,calc(100vw - var(--space-8)));}
7
- .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition:opacity var(--dur-base) var(--ease-standard),overlay var(--dur-base) allow-discrete,display var(--dur-base) allow-discrete;}
7
+ .fdy-modal::backdrop{background:rgba(10,12,25,.5);-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);opacity:0;transition-property:opacity,overlay,display;transition-duration:var(--dur-base);transition-timing-function:var(--ease-standard),ease,ease;transition-behavior:normal,allow-discrete,allow-discrete;}
8
8
  .fdy-modal__header{flex:none;display:flex;align-items:center;gap:var(--space-3);padding:var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
9
9
  .fdy-modal__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text);margin:0 auto 0 0;}
10
10
  .fdy-modal__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}