@cahyo-dimas/freeday 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,57 @@ 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.11.0] - 2026-10-26
13
+
14
+ **Audit kontrol native di seluruh layar sebuah aplikasi konsumen, bukan di satu layar yang
15
+ mengeluh.** Pemiliknya melaporkan tampilan HP aslinya tak cocok dengan peramban di lebar yang sama,
16
+ dan dugaan pertama -- termasuk dugaan saya -- adalah CSS. Pengukuran menyangkalnya: dengan
17
+ stylesheet aplikasi yang sebenarnya, pada 375x667, Chromium dan WebKit menata `date`, `time`,
18
+ `number`, `search` dan `text` **identik**, 293x44 keenamnya, selisih satu piksel pada `y` yang
19
+ cuma pembulatan. Jadi yang berbeda bukan tata letaknya, melainkan **widget native-nya**: `date` dan
20
+ `time` digambar sistem operasi, sehingga iOS memberi roda gulir, Android piringan jam dan desktop
21
+ sebuah stepper, masing-masing dengan lebar dan format nilainya sendiri, dan tak ada CSS yang
22
+ menjangkaunya. Itu sebabnya satu HP dan satu peramban di lebar yang sama bisa tidak sepakat soal
23
+ layar yang selebihnya sama.
24
+
25
+ Kit sudah mengatakan ini di tabelnya sendiri ("Use `.fdy-datepicker` / `.fdy-timepicker` /
26
+ `.fdy-datetimepicker` instead") dan sudah mengirim CSS plus enhancer-nya. Yang belum ada adalah
27
+ wrapper bertipe untuk timepicker, jadi aplikasi Vue/React/Blazor harus merakit sendiri
28
+ plumbing custom-event-nya -- dan merakit sendiri adalah yang membuat empat `<input type="time">`
29
+ bertahan di satu formulir shift.
30
+
31
+ Catatan kejujuran: perbedaan iOS itu **tidak** direproduksi di sini. WebKit desktop bukan Mobile
32
+ Safari, dan penampilan kontrol native memang bersifat platform; buktinya adalah tangkapan layar
33
+ perangkat nyata plus fakta terukur bahwa tata letak per lebar sudah identik. Yang dapat diuji
34
+ lokal, dan kini diuji, adalah perilaku penggantinya.
35
+
36
+ ### Added
37
+ - **`FdyTimepicker`** (`#094`), wrapper bertipe di **ketiga** stack, menjadikannya tiga belas.
38
+ Vue dan React mengimplementasikan listbox-nya secara native sebab ia kontrol form -- alasan yang
39
+ sama dengan `FdyCombo`, dan alasan yang `FdyChart` nyatakan dari sisi sebaliknya: enhancer yang
40
+ menguasai DOM berkelahi dengan render framework dan tak menyisakan `v-model`. Blazor
41
+ menjembatani enhancer lewat seed, seperti `FdyDatepicker`. Sorotannya **dijepit, bukan
42
+ berputar**: pada daftar 48 opsi, ArrowUp di `00:00` yang melompat ke `23:30` terbaca sebagai
43
+ kerusakan. Sorotan juga digulirkan ke dalam pandangan, sebab panelnya `max-height:15rem` dan
44
+ sorotan yang tak terlihat bukan sorotan.
45
+ - **`data-id` dan `data-describedby` pada seed timepicker** (`#094`). Keduanya tak terbaca
46
+ sebelumnya, jadi host yang menampilkan teks galat tak punya cara menautkannya. Pemicunya
47
+ `<button>`, yang **bukan** elemen labelable, sehingga `<label for>` memang tak akan pernah
48
+ mengaktifkannya -- itu didokumentasikan sekarang, bukan dibiarkan jadi kejutan.
49
+ - **`FreedayTimepicker.setState(root, { disabled, readonly, invalid })`** (`#094`), bentuknya sama
50
+ dengan `FreedayDatepicker.setState`. Tanpa ini, host yang merender seed-nya sekali lalu tidak
51
+ pernah lagi (Blazor, `ShouldRender => false`) memiliki tiga parameter keadaan yang tak ada yang
52
+ membacanya setelah render pertama.
53
+
54
+ ### Fixed
55
+ - **`FdyDateRange` membuang kedelapan prop label navigasi kalender** (`#095`), di ketiga stack.
56
+ `FdyDatepicker` menerimanya sejak 2.1.1; rentangnya tidak meneruskannya, sehingga rentang tanggal
57
+ adalah **satu-satunya** kontrol tanggal di kit yang navigasinya tak bisa dinamai dalam bahasa
58
+ halamannya sendiri -- aplikasi berbahasa Indonesia mendapat "Previous month" tepat di tempat yang
59
+ tak dapat ia jangkau. Diteruskan apa adanya ke kedua pemilih. Prop datar `xxxLabel`, bukan objek
60
+ `labels`, sebab bentuk itulah yang sudah dipakai setiap komponen lain di kit. Blazor menambah
61
+ `BackToMonthsLabel`, karena seed yang kedua pemilihnya dibangun dari situ punya tombol itu.
62
+
12
63
  ## [3.10.0] - 2026-10-26
13
64
 
14
65
  **Lima rilis dalam satu hari, empat di antaranya menambal satu kelas yang sama, dan pemiliknya
package/COMPONENTS.md CHANGED
@@ -34,8 +34,9 @@ live docs.
34
34
  invalid state does not belong to the control itself.
35
35
  6. **Interactive components need their enhancer script.** Static ones (button, card, badge, table,
36
36
  alert, breadcrumb, timeline, accordion, tree-without-cascade…) are CSS-only.
37
- 7. **On Vue, React or Blazor, twelve components have a typed wrapper. Use it.** `FdyCombo` ·
38
- `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` · `FdyAutocomplete` · `FdyCascade` · `FdyCfl` ·
37
+ 7. **On Vue, React or Blazor, thirteen components have a typed wrapper. Use it.** `FdyCombo` ·
38
+ `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` · `FdyTimepicker` · `FdyAutocomplete` ·
39
+ `FdyCascade` · `FdyCfl` ·
39
40
  `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer` · `FdyAppShell`, from `@cahyo-dimas/freeday/vue`, `/react`, or the
40
41
  `Freeday.Blazor` RCL. Each is flagged at its own section below. Hand-writing their raw markup in
41
42
  those stacks *looks* right at first, because the enhancer initialises once and the first render is
@@ -1020,6 +1021,7 @@ grid's own title.
1020
1021
  | `startPlaceholder?` · `endPlaceholder?` | `string` | One per field. |
1021
1022
  | `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Names the group (`role="group"`), or points at the text that does. |
1022
1023
  | `disabled?` · `readonly?` · `invalid?` | `boolean` | Applied to both pickers together. |
1024
+ | `prevMonthLabel?` · `nextMonthLabel?` · `prevYearLabel?` · `nextYearLabel?` · `prevYearsLabel?` · `nextYearsLabel?` · `chooseMonthLabel?` · `chooseYearLabel?` | `string` | Forwarded verbatim to **both** pickers. Until 3.11.0 this was the one picker in the kit whose calendar nav could not be named in the page's own language: the single picker took these, the range dropped them. Blazor adds `BackToMonthsLabel`, since the raw seed both pickers are built from has that button. |
1023
1025
 
1024
1026
  Blazor splits the value into two scalars, `From` / `FromChanged` and `To` / `ToChanged`, because a
1025
1027
  C# binding cannot express one object bound in two places, and it adds `Separator`.
@@ -1106,10 +1108,52 @@ it has always been, down to the `YYYY-MM-DD` it reports.
1106
1108
  ```
1107
1109
 
1108
1110
  ## Time picker — `data-fdy-timepicker`
1109
- 24-hour `HH:MM` listbox. `data-step` (minutes, default 30), `data-min`, `data-max`. Needs
1111
+ > **Typed wrapper: `<FdyTimepicker>`**. Vue (`v-model`) · React (`value`/`onChange`) · Blazor (`@bind-Value`). In those stacks use the wrapper; the markup below is for stacks without an adapter (and is what the wrapper renders).
1112
+
1113
+ 24-hour `HH:MM` listbox. **Author an empty `<div>`** — the enhancer builds everything. Needs
1110
1114
  `freeday-timepicker.js`. Classes: `.fdy-timepicker` (+`--error`) · `__trigger` `__value`
1111
1115
  (+`--placeholder`) `__icon` `__panel` `__opt`.
1112
1116
 
1117
+ Seed attributes: `data-step` (minutes, default 30), `data-min` (default `00:00`), `data-max`
1118
+ (default `23:59`), `data-value`, `data-label`, `data-placeholder` (default `--:--`), `data-id`,
1119
+ `data-describedby`, `data-disabled`, `data-readonly`, `data-invalid`. Emits a bubbling
1120
+ `fdy-time-select` (`detail {value}`). `FreedayTimepicker.setState(root, { disabled, readonly,
1121
+ invalid })` changes the three states afterwards, which is what a host that renders its seed once
1122
+ and never again (Blazor) needs.
1123
+
1124
+ **Use this instead of `<input type="time">`**, and not for theming alone: the native picker is
1125
+ drawn by the *operating system*, so iOS shows a scroll wheel, Android a clock dial and desktop a
1126
+ stepper, each with its own width and value format, and no CSS reaches any of them. A phone and a
1127
+ browser at the same width therefore disagree about a screen that is otherwise identical.
1128
+
1129
+ ```html
1130
+ <div data-fdy-timepicker data-step="15" data-min="06:00" data-max="22:00"
1131
+ data-value="07:00" data-label="Jam mulai"></div>
1132
+ ```
1133
+
1134
+ ### Props — `<FdyTimepicker>`
1135
+
1136
+ | Prop | Type | What it does |
1137
+ |---|---|---|
1138
+ | `modelValue` · `value` | `string \| null` | `HH:MM`, 24-hour. `''`/`null` for empty. A loose `H:MM` is normalised for matching. Blazor: `Value` / `@bind-Value`. |
1139
+ | `onChange` | `(value: string) => void` | React. Vue emits `update:modelValue` and `change`, Blazor `ValueChanged`. |
1140
+ | `step?` | `number` | Minutes between options. Default 30. |
1141
+ | `min?` · `max?` | `string` | `HH:MM` bounds, inclusive. Default `00:00` to `23:59`. |
1142
+ | `placeholder?` | `string` | Shown when there is no value. Default `--:--`. |
1143
+ | `ariaLabel?` · `ariaLabelledby?` · `describedby?` | `string` | Names the trigger, or points at the text that does. Blazor: `Label` / `Describedby`. |
1144
+ | `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
+ | `disabled?` · `readonly?` · `invalid?` | `boolean` | `readonly` keeps tab order and shows the time but will not open. |
1146
+
1147
+ Keyboard: `ArrowDown`/`ArrowUp` move one option, `Home`/`End` jump to the ends, `Enter`/`Space`
1148
+ pick, `Escape` closes. The highlight is **clamped, not cyclic** — `ArrowUp` at `00:00` stays there,
1149
+ because wrapping to `23:30` reads as a glitch in a list this long — and it is scrolled into view,
1150
+ since the panel is `max-height:15rem` and a highlight nobody can see is no highlight.
1151
+
1152
+ **Deliberate difference between the two paths.** The wrappers keep focus on the **trigger** with
1153
+ `aria-activedescendant` on it, which is what `FdyCombo`, `FdyDatepicker` and `FdyAutocomplete` do;
1154
+ the raw enhancer moves focus **into the panel**. Both are valid APG listbox patterns, and the Vue
1155
+ and React adapters follow their own family rather than the enhancer.
1156
+
1113
1157
  ## Datetime picker — `data-fdy-datetimepicker`
1114
1158
  A **composition** of the two above, not a monolith: nest one datepicker and one timepicker inside
1115
1159
  the wrapper. `freeday-datetime.js` joins them and emits one `fdy-datetime-change` with
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.10.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.10.0)
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)
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`.
@@ -44,9 +44,9 @@ enhancer:
44
44
  | **Blazor (net8.0)** | RCL `Freeday.Blazor` | `@bind-Value` |
45
45
  | HTML statis · Svelte · template server-rendered | *(tanpa wrapper)* markup mentah + enhancer, di bawah | event `fdy-*` |
46
46
 
47
- Kedua belasnya: `FdyCombo` · `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` ·
48
- `FdyAutocomplete` · `FdyCascade` · `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer` ·
49
- `FdyAppShell`. Menulis markup mentah untuk kedua belas ini di dalam Vue/React/Blazor *kelihatan* baik-baik saja pada mulanya, karena render
47
+ Ketiga belasnya: `FdyCombo` · `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` ·
48
+ `FdyTimepicker` · `FdyAutocomplete` · `FdyCascade` · `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer` ·
49
+ `FdyAppShell`. Menulis markup mentah untuk ketiga belas ini di dalam Vue/React/Blazor *kelihatan* baik-baik saja pada mulanya, karena render
50
50
  pertamanya memang benar. Setelah itu ia rusak diam-diam: DOM yang di-render framework belakangan tak pernah ter-hydrate, dan state widget
51
51
  berakhir di DOM alih-alih di state framework-mu. Selebihnya (button, card, badge, alert, markup
52
52
  tabel, layout, dan komponen interaktif yang tak punya wrapper) sama persis di semua stack. Untuk
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.10.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.10.0)
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)
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`.
@@ -44,8 +44,8 @@ enhancer:
44
44
  | **Blazor (net8.0)** | `Freeday.Blazor` RCL | `@bind-Value` |
45
45
  | Static HTML · Svelte · server-rendered templates | *(no wrapper)* raw markup + enhancers, below | `fdy-*` events |
46
46
 
47
- The twelve: `FdyCombo` · `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` · `FdyAutocomplete` ·
48
- `FdyCascade` · `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer` · `FdyAppShell`.
47
+ The thirteen: `FdyCombo` · `FdyDatepicker` · `FdyMonthpicker` · `FdyDateRange` · `FdyTimepicker` ·
48
+ `FdyAutocomplete` · `FdyCascade` · `FdyCfl` · `FdyChart` · `FdyTable` · `FdyModal` · `FdyDrawer` · `FdyAppShell`.
49
49
  Hand-writing the raw
50
50
  markup for these inside Vue/React/Blazor *looks* fine at first, because the first render is correct.
51
51
  Then it breaks quietly:
@@ -8,9 +8,27 @@
8
8
  <div @ref="Root">
9
9
  <div data-fdy-daterange role="group" aria-label="@AriaLabel">
10
10
  <div data-fdy-datepicker data-role="from"
11
- data-value="@From" data-placeholder="@FromPlaceholder" data-min="@Min" data-max="@Max"></div>
11
+ data-value="@From" data-placeholder="@FromPlaceholder" data-min="@Min" data-max="@Max"
12
+ data-fdy-text-prev-month="@PrevMonthLabel"
13
+ data-fdy-text-next-month="@NextMonthLabel"
14
+ data-fdy-text-prev-year="@PrevYearLabel"
15
+ data-fdy-text-next-year="@NextYearLabel"
16
+ data-fdy-text-prev-years="@PrevYearsLabel"
17
+ data-fdy-text-next-years="@NextYearsLabel"
18
+ data-fdy-text-choose-month="@ChooseMonthLabel"
19
+ data-fdy-text-choose-year="@ChooseYearLabel"
20
+ data-fdy-text-back-to-months="@BackToMonthsLabel"></div>
12
21
  <span class="fdy-daterange__sep">@Separator</span>
13
22
  <div data-fdy-datepicker data-role="to"
14
- data-value="@To" data-placeholder="@ToPlaceholder" data-min="@Min" data-max="@Max"></div>
23
+ data-value="@To" data-placeholder="@ToPlaceholder" data-min="@Min" data-max="@Max"
24
+ data-fdy-text-prev-month="@PrevMonthLabel"
25
+ data-fdy-text-next-month="@NextMonthLabel"
26
+ data-fdy-text-prev-year="@PrevYearLabel"
27
+ data-fdy-text-next-year="@NextYearLabel"
28
+ data-fdy-text-prev-years="@PrevYearsLabel"
29
+ data-fdy-text-next-years="@NextYearsLabel"
30
+ data-fdy-text-choose-month="@ChooseMonthLabel"
31
+ data-fdy-text-choose-year="@ChooseYearLabel"
32
+ data-fdy-text-back-to-months="@BackToMonthsLabel"></div>
15
33
  </div>
16
34
  </div>
@@ -23,6 +23,26 @@ public partial class FdyDateRange
23
23
  [Parameter] public string? Max { get; set; }
24
24
  [Parameter] public string Separator { get; set; } = "–";
25
25
 
26
+ /* The calendars' own navigation labels, forwarded verbatim to BOTH pickers. Month and weekday
27
+ NAMES follow the page's `lang` through Intl and need nothing here; these are the buttons
28
+ around them. Until now the range was the one picker in the kit whose nav could not be named
29
+ in the page's own language -- FdyDatepicker took these, FdyDateRange dropped them, so an
30
+ Indonesian app got "Previous month" on the only date control it could not reach. Raised
31
+ from keamanan-cluster (#095). */
32
+ [Parameter] public string? PrevMonthLabel { get; set; }
33
+ [Parameter] public string? NextMonthLabel { get; set; }
34
+ [Parameter] public string? PrevYearLabel { get; set; }
35
+ [Parameter] public string? NextYearLabel { get; set; }
36
+ [Parameter] public string? PrevYearsLabel { get; set; }
37
+ [Parameter] public string? NextYearsLabel { get; set; }
38
+ [Parameter] public string? ChooseMonthLabel { get; set; }
39
+ [Parameter] public string? ChooseYearLabel { get; set; }
40
+
41
+ /// <summary>The year grid's title, back to months. Blazor only, because the raw seed both
42
+ /// pickers are built from supports it and <see cref="FdyDatepicker"/> already exposes it;
43
+ /// the Vue and React adapters build no such button.</summary>
44
+ [Parameter] public string? BackToMonthsLabel { get; set; }
45
+
26
46
  private int _token;
27
47
 
28
48
  // initAll(Root) links the daterange (descendant) and inits both child pickers.
@@ -0,0 +1,23 @@
1
+ @namespace Freeday.Blazor
2
+ @inherits FreedayComponentBase<FdyTimepicker>
3
+
4
+ @* Seed-based wrapper over freeday-timepicker.js: the enhancer builds the input-styled trigger
5
+ + time listbox from this empty div and emits fdy-time-select. The enhancer owns that DOM, so
6
+ the seed renders once (ShouldRender => false) and the picked time is read back through
7
+ @bind-Value. Value is "HH:MM" (24-hour) or "".
8
+
9
+ This is what replaces <input type="time">, and not for theming alone: the native picker is
10
+ drawn by the OPERATING SYSTEM, so iOS shows a scroll wheel, Android a clock dial and desktop a
11
+ stepper, each with its own width and value format, and no CSS reaches any of them (#094). *@
12
+ <div @ref="Root" data-fdy-timepicker
13
+ data-value="@Value"
14
+ data-label="@Label"
15
+ data-placeholder="@Placeholder"
16
+ data-step="@StepAttribute"
17
+ data-min="@Min"
18
+ data-max="@Max"
19
+ data-id="@Id"
20
+ data-describedby="@Describedby"
21
+ data-disabled="@(Disabled ? "true" : null)"
22
+ data-readonly="@(Readonly ? "true" : null)"
23
+ data-invalid="@(Invalid ? "true" : null)"></div>
@@ -0,0 +1,79 @@
1
+ using Microsoft.AspNetCore.Components;
2
+ using Microsoft.JSInterop;
3
+
4
+ namespace Freeday.Blazor;
5
+
6
+ public partial class FdyTimepicker
7
+ {
8
+ /// <summary>Selected time as <c>HH:MM</c> (24-hour), or "" when unset. Two-way bindable
9
+ /// via <c>@bind-Value</c>.</summary>
10
+ [Parameter] public string? Value { get; set; }
11
+ [Parameter] public EventCallback<string?> ValueChanged { get; set; }
12
+
13
+ /// <summary>Accessible label for the trigger.</summary>
14
+ [Parameter] public string? Label { get; set; }
15
+
16
+ /// <summary>Shown when there is no value. The enhancer's own default is <c>--:--</c>.</summary>
17
+ [Parameter] public string? Placeholder { get; set; }
18
+
19
+ /// <summary>Minutes between options. The enhancer's own default is 30.</summary>
20
+ [Parameter] public int? Step { get; set; }
21
+
22
+ /* null, not "30", for the default: an attribute the enhancer does not need is one no reader
23
+ of the seed has to interpret, and the enhancer already falls back to 30. */
24
+ private string? StepAttribute => Step is null ? null : Step.Value.ToString();
25
+
26
+ /// <summary>Earliest selectable time (<c>HH:MM</c>). Default <c>00:00</c>.</summary>
27
+ [Parameter] public string? Min { get; set; }
28
+
29
+ /// <summary>Latest selectable time (<c>HH:MM</c>). Default <c>23:59</c>.</summary>
30
+ [Parameter] public string? Max { get; set; }
31
+
32
+ /// <summary>Id for the trigger the enhancer builds, so <c>aria-describedby</c> targets and
33
+ /// other references can point at it. Note the trigger is a <c>&lt;button&gt;</c>, which is not
34
+ /// a labelable element, so a <c>&lt;label for&gt;</c> will not activate it — use
35
+ /// <see cref="Label"/>.</summary>
36
+ [Parameter] public string? Id { get; set; }
37
+
38
+ /// <summary>Id of the help or error text the trigger describes itself with.</summary>
39
+ [Parameter] public string? Describedby { get; set; }
40
+
41
+ /// <summary>Greyed and out of the tab order.</summary>
42
+ [Parameter] public bool Disabled { get; set; }
43
+
44
+ /// <summary>Locked/view mode: focusable and showing its time, but the list will not open.
45
+ /// Unlike <see cref="Disabled"/> it keeps tab order and is not greyed.</summary>
46
+ [Parameter] public bool Readonly { get; set; }
47
+
48
+ /// <summary>Marks the field invalid (<c>aria-invalid</c> + the error styling).</summary>
49
+ [Parameter] public bool Invalid { get; set; }
50
+
51
+ // The enhancer owns this control's DOM; init it directly (initAll would skip Root itself).
52
+ protected override ValueTask HydrateAsync() => JS.InvokeVoidAsync("FreedayTimepicker.init", Root);
53
+
54
+ protected override async ValueTask OnHydratedAsync()
55
+ => await SubscribeAsync("fdy-time-select", nameof(OnSelect));
56
+
57
+ // The enhancer owns the built trigger + listbox; never let Blazor re-render the seed.
58
+ protected override bool ShouldRender() => !Hydrated;
59
+
60
+ /* Which is exactly why the three states are pushed rather than re-rendered: the seed above is
61
+ written once, so a page that disables this field later would otherwise be ignored. */
62
+ protected override async Task OnParametersSetAsync()
63
+ {
64
+ if (!Hydrated) return;
65
+ await JS.InvokeVoidAsync("FreedayTimepicker.setState", Root,
66
+ new { disabled = Disabled, @readonly = Readonly, invalid = Invalid });
67
+ }
68
+
69
+ /// <summary>Invoked by the bridge when the reader picks a time (enhancer's fdy-time-select).</summary>
70
+ [JSInvokable]
71
+ public async Task OnSelect(SelectDetail detail)
72
+ {
73
+ Value = detail.Value;
74
+ await ValueChanged.InvokeAsync(detail.Value);
75
+ }
76
+
77
+ /// <summary>The detail shape of the enhancer's <c>fdy-time-select</c> event.</summary>
78
+ public sealed record SelectDetail(string Value);
79
+ }
@@ -29,6 +29,19 @@ export interface FdyDateRangeProps {
29
29
  endPlaceholder?: string;
30
30
  ariaLabel?: string;
31
31
  ariaLabelledby?: string;
32
+ /* Forwarded verbatim to BOTH pickers. Without these the range was the one picker in the kit
33
+ whose calendar nav could not be named in the page's own language: FdyDatepicker takes them,
34
+ FdyDateRange dropped them, so an Indonesian app got "Previous month" on the only date control
35
+ it could not reach. Flat `xxxLabel` props, not a `labels` object, because that is the shape
36
+ every other component in the kit already uses. Raised from keamanan-cluster (#095). */
37
+ prevMonthLabel?: string;
38
+ nextMonthLabel?: string;
39
+ prevYearLabel?: string;
40
+ nextYearLabel?: string;
41
+ prevYearsLabel?: string;
42
+ nextYearsLabel?: string;
43
+ chooseMonthLabel?: string;
44
+ chooseYearLabel?: string;
32
45
  }
33
46
 
34
47
  export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
@@ -57,6 +70,14 @@ export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
57
70
  invalid={props.invalid}
58
71
  describedby={props.describedby}
59
72
  placeholder={props.startPlaceholder ?? 'Dari'}
73
+ prevMonthLabel={props.prevMonthLabel}
74
+ nextMonthLabel={props.nextMonthLabel}
75
+ prevYearLabel={props.prevYearLabel}
76
+ nextYearLabel={props.nextYearLabel}
77
+ prevYearsLabel={props.prevYearsLabel}
78
+ nextYearsLabel={props.nextYearsLabel}
79
+ chooseMonthLabel={props.chooseMonthLabel}
80
+ chooseYearLabel={props.chooseYearLabel}
60
81
  />
61
82
  <span className="fdy-daterange__sep" aria-hidden="true">–</span>
62
83
  <FdyDatepicker
@@ -70,6 +91,14 @@ export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
70
91
  invalid={props.invalid}
71
92
  describedby={props.describedby}
72
93
  placeholder={props.endPlaceholder ?? 'Sampai'}
94
+ prevMonthLabel={props.prevMonthLabel}
95
+ nextMonthLabel={props.nextMonthLabel}
96
+ prevYearLabel={props.prevYearLabel}
97
+ nextYearLabel={props.nextYearLabel}
98
+ prevYearsLabel={props.prevYearsLabel}
99
+ nextYearsLabel={props.nextYearsLabel}
100
+ chooseMonthLabel={props.chooseMonthLabel}
101
+ chooseYearLabel={props.chooseYearLabel}
73
102
  />
74
103
  </div>
75
104
  );
@@ -0,0 +1,212 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // React twin of adapters/vue/components/FdyTimepicker.vue over freeday's `.fdy-timepicker`
6
+ // classes (src/components/timepicker.css). See that file's header for why a form control is
7
+ // re-implemented natively instead of wrapping freeday-timepicker.js, and why this replaces
8
+ // `<input type="time">`: the native picker is drawn by the OS, so iOS shows a scroll wheel,
9
+ // Android a clock dial and desktop a stepper, each with its own width and format, and no CSS
10
+ // reaches any of them (#094).
11
+
12
+ export interface FdyTimepickerProps {
13
+ /** `HH:MM`, 24-hour. `''`/`null` for empty. A loose `H:MM` is normalised for matching. */
14
+ value: string | null;
15
+ onChange: (value: string) => void;
16
+ /** Minutes between options. Default 30. */
17
+ step?: number;
18
+ /** `HH:MM` bounds, inclusive. Default `00:00` to `23:59`. */
19
+ min?: string;
20
+ max?: string;
21
+ id?: string;
22
+ ariaLabel?: string;
23
+ ariaLabelledby?: string;
24
+ /** Shown when there is no value. Default `--:--`, the same as the raw path. */
25
+ placeholder?: string;
26
+ disabled?: boolean;
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
+ readonly?: boolean;
29
+ invalid?: boolean;
30
+ describedby?: string;
31
+ }
32
+
33
+ const SHAPE: RegExp = /^\d{1,2}:\d{2}$/;
34
+
35
+ function toMinutes(time: string): number {
36
+ const [h, m] = time.split(':');
37
+ return Number(h) * 60 + Number(m);
38
+ }
39
+ function pad(n: number): string {
40
+ return n < 10 ? `0${n}` : String(n);
41
+ }
42
+ function fromMinutes(total: number): string {
43
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
44
+ }
45
+ /** `7:0` is not a time; `7:00` is the same time as `07:00`. Anything else answers `''`. */
46
+ function normalise(time: string | null): string {
47
+ if (time === null || !SHAPE.test(time)) return '';
48
+ const total: number = toMinutes(time);
49
+ return total >= 0 && total <= 23 * 60 + 59 ? fromMinutes(total) : '';
50
+ }
51
+ function bound(time: string | undefined, fallback: number): number {
52
+ return time !== undefined && SHAPE.test(time) ? toMinutes(time) : fallback;
53
+ }
54
+
55
+ export function FdyTimepicker(props: FdyTimepickerProps): JSX.Element {
56
+ const baseId: string = useId();
57
+ const triggerId: string = props.id ?? `${baseId}-trigger`;
58
+ const panelId: string = `${baseId}-panel`;
59
+ const optionId = (index: number): string => `${baseId}-opt-${index}`;
60
+
61
+ const rootRef = useRef<HTMLDivElement>(null);
62
+ const triggerRef = useRef<HTMLButtonElement>(null);
63
+ const panelRef = useRef<HTMLUListElement>(null);
64
+ const [open, setOpen] = useState<boolean>(false);
65
+ const [highlighted, setHighlighted] = useState<number>(-1);
66
+
67
+ // Lift the panel into the top layer so it escapes an ancestor overflow clip. A shift form is a
68
+ // modal, and a time list long enough to scroll is exactly the panel a card would cut off.
69
+ usePopover(panelRef, triggerRef, open);
70
+
71
+ // Popover attr for React 18 (JSX may not type it): set once on mount.
72
+ useEffect((): void => {
73
+ panelRef.current?.setAttribute('popover', 'manual');
74
+ }, []);
75
+
76
+ const isDisabled: boolean = props.disabled === true;
77
+ const isReadonly: boolean = props.readonly === true;
78
+ const isInvalid: boolean = props.invalid === true;
79
+
80
+ const times: ReadonlyArray<string> = useMemo((): ReadonlyArray<string> => {
81
+ const stride: number = Math.max(1, Math.trunc(props.step ?? 30));
82
+ const from: number = bound(props.min, 0);
83
+ const to: number = bound(props.max, 23 * 60 + 59);
84
+ const out: string[] = [];
85
+ for (let m = from; m <= to; m += stride) out.push(fromMinutes(m));
86
+ return out;
87
+ }, [props.step, props.min, props.max]);
88
+
89
+ const value: string = normalise(props.value);
90
+ const selectedIndex: number = times.indexOf(value);
91
+ const shown: string = value !== '' ? value : (props.placeholder ?? '--:--');
92
+ const isPlaceholder: boolean = value === '';
93
+ const activeDescendant: string | undefined = open && highlighted >= 0 ? optionId(highlighted) : undefined;
94
+
95
+ /* The panel is `max-height:15rem` and scrolls: a highlight the reader cannot see is no highlight,
96
+ and with 48 half-hour options most of the list is out of view. */
97
+ useEffect((): void => {
98
+ const list: HTMLUListElement | null = panelRef.current;
99
+ if (list === null || highlighted < 0) return;
100
+ const opt: Element | undefined = list.children[highlighted];
101
+ if (opt instanceof HTMLElement) opt.scrollIntoView({ block: 'nearest' });
102
+ }, [highlighted, open]);
103
+
104
+ /* Clamped, not cyclic: FdyCombo wraps because its lists are short. Here ArrowUp at 00:00 landing
105
+ on 23:30 reads as a glitch. Matches the raw path. */
106
+ const setHighlight = (index: number): void => {
107
+ const last: number = times.length - 1;
108
+ setHighlighted(last < 0 ? -1 : Math.min(last, Math.max(0, index)));
109
+ };
110
+ const openList = (): void => {
111
+ if (isDisabled || isReadonly || open) return;
112
+ setOpen(true);
113
+ setHighlight(selectedIndex >= 0 ? selectedIndex : 0);
114
+ };
115
+ const closeList = (focusTrigger: boolean): void => {
116
+ setOpen(false);
117
+ setHighlighted(-1);
118
+ if (focusTrigger) triggerRef.current?.focus();
119
+ };
120
+ const toggle = (): void => { if (open) closeList(false); else openList(); };
121
+ const choose = (index: number): void => {
122
+ const next: string | undefined = times[index];
123
+ if (next === undefined) return;
124
+ if (next !== value) props.onChange(next);
125
+ closeList(true);
126
+ };
127
+
128
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
129
+ if (isDisabled || isReadonly) return;
130
+ switch (e.key) {
131
+ case 'ArrowDown': e.preventDefault(); if (open) setHighlight(highlighted + 1); else openList(); break;
132
+ case 'ArrowUp': e.preventDefault(); if (open) setHighlight(highlighted - 1); else openList(); break;
133
+ case 'Home': if (open) { e.preventDefault(); setHighlight(0); } break;
134
+ case 'End': if (open) { e.preventDefault(); setHighlight(times.length - 1); } break;
135
+ case 'Enter':
136
+ case ' ': e.preventDefault(); if (open && highlighted >= 0) choose(highlighted); else openList(); break;
137
+ case 'Escape': if (open) { e.preventDefault(); closeList(true); } break;
138
+ case 'Tab': if (open) closeList(false); break;
139
+ default: break;
140
+ }
141
+ };
142
+
143
+ const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
144
+ const next: EventTarget | null = e.relatedTarget;
145
+ if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closeList(false);
146
+ };
147
+
148
+ useEffect((): void | (() => void) => {
149
+ if (!open) return;
150
+ const onDocPointerDown = (e: MouseEvent): void => {
151
+ const t: EventTarget | null = e.target;
152
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closeList(false);
153
+ };
154
+ document.addEventListener('mousedown', onDocPointerDown);
155
+ return (): void => document.removeEventListener('mousedown', onDocPointerDown);
156
+ }, [open]);
157
+
158
+ return (
159
+ <div
160
+ ref={rootRef}
161
+ className={isInvalid ? 'fdy-timepicker fdy-timepicker--error' : 'fdy-timepicker'}
162
+ data-value={value}
163
+ onKeyDown={onKeyDown}
164
+ onBlur={onFocusout}
165
+ >
166
+ <button
167
+ id={triggerId}
168
+ ref={triggerRef}
169
+ type="button"
170
+ className={open ? 'fdy-timepicker__trigger is-open' : 'fdy-timepicker__trigger'}
171
+ aria-haspopup="listbox"
172
+ aria-controls={panelId}
173
+ aria-expanded={open}
174
+ aria-activedescendant={activeDescendant}
175
+ aria-label={props.ariaLabel}
176
+ aria-labelledby={props.ariaLabelledby}
177
+ aria-invalid={isInvalid ? 'true' : undefined}
178
+ aria-readonly={isReadonly ? 'true' : undefined}
179
+ aria-describedby={props.describedby}
180
+ disabled={isDisabled}
181
+ onClick={toggle}
182
+ >
183
+ <span className={isPlaceholder ? 'fdy-timepicker__value fdy-timepicker__value--placeholder' : 'fdy-timepicker__value'}>{shown}</span>
184
+ <span className="fdy-timepicker__icon">
185
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
186
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
187
+ <circle cx="12" cy="12" r="9" />
188
+ <path d="M12 7v5l3 2" />
189
+ </svg>
190
+ </span>
191
+ </button>
192
+ <ul id={panelId} ref={panelRef} className="fdy-timepicker__panel" role="listbox"
193
+ aria-label={props.ariaLabel} hidden={!open}>
194
+ {times.map((t: string, i: number): JSX.Element => (
195
+ // onMouseDown preventDefault keeps focus on the trigger: an <li> isn't focusable, so a
196
+ // plain mousedown moves focus out of the control, fires onBlur and closes the panel
197
+ // before the click lands. Same pattern as FdyCombo.
198
+ <li
199
+ id={optionId(i)}
200
+ key={t}
201
+ className={i === highlighted ? 'fdy-timepicker__opt is-active' : 'fdy-timepicker__opt'}
202
+ role="option"
203
+ aria-selected={t === value}
204
+ onMouseDown={(e: React.MouseEvent): void => e.preventDefault()}
205
+ onClick={(): void => choose(i)}
206
+ onMouseMove={(): void => setHighlight(i)}
207
+ >{t}</li>
208
+ ))}
209
+ </ul>
210
+ </div>
211
+ );
212
+ }
@@ -35,6 +35,7 @@ export { FdyDatepicker, type FdyDatepickerProps, type FdyDatepickerMode } from '
35
35
  export { FdyMonthpicker, type FdyMonthpickerProps } from './components/FdyMonthpicker';
36
36
  export type { FdyMonth } from '../core/month-grid';
37
37
  export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
38
+ export { FdyTimepicker, type FdyTimepickerProps } from './components/FdyTimepicker';
38
39
  export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
39
40
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
40
41
  export { FdyCfl, type FdyCflProps, type FdyCflHandle, type CflColumn, type CflPage } from './components/FdyCfl';
@@ -4,6 +4,7 @@ export { FdyCombo } from './components/FdyCombo.tsx';
4
4
  export { FdyDatepicker } from './components/FdyDatepicker.tsx';
5
5
  export { FdyMonthpicker } from './components/FdyMonthpicker.tsx';
6
6
  export { FdyDateRange } from './components/FdyDateRange.tsx';
7
+ export { FdyTimepicker } from './components/FdyTimepicker.tsx';
7
8
  export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
8
9
  export { FdyCascade } from './components/FdyCascade.tsx';
9
10
  export { FdyCfl } from './components/FdyCfl.tsx';
@@ -29,6 +29,19 @@ const props = defineProps<{
29
29
  endPlaceholder?: string;
30
30
  ariaLabel?: string;
31
31
  ariaLabelledby?: string;
32
+ /* Forwarded verbatim to BOTH pickers. Without these the range was the one picker in the kit
33
+ whose calendar nav could not be named in the page's own language: FdyDatepicker takes them,
34
+ FdyDateRange dropped them, so an Indonesian app got "Previous month" on the only date control
35
+ it could not reach. Flat `xxxLabel` props, not a `labels` object, because that is the shape
36
+ every other component in the kit already uses. Raised from keamanan-cluster (#095). */
37
+ prevMonthLabel?: string;
38
+ nextMonthLabel?: string;
39
+ prevYearLabel?: string;
40
+ nextYearLabel?: string;
41
+ prevYearsLabel?: string;
42
+ nextYearsLabel?: string;
43
+ chooseMonthLabel?: string;
44
+ chooseYearLabel?: string;
32
45
  }>();
33
46
 
34
47
  const emit = defineEmits<{
@@ -69,6 +82,14 @@ function setEnd(end: string): void {
69
82
  :invalid="invalid"
70
83
  :describedby="describedby"
71
84
  :placeholder="startPlaceholder ?? 'Dari'"
85
+ :prev-month-label="prevMonthLabel"
86
+ :next-month-label="nextMonthLabel"
87
+ :prev-year-label="prevYearLabel"
88
+ :next-year-label="nextYearLabel"
89
+ :prev-years-label="prevYearsLabel"
90
+ :next-years-label="nextYearsLabel"
91
+ :choose-month-label="chooseMonthLabel"
92
+ :choose-year-label="chooseYearLabel"
72
93
  @update:model-value="setStart"
73
94
  />
74
95
  <span class="fdy-daterange__sep" aria-hidden="true">–</span>
@@ -82,6 +103,14 @@ function setEnd(end: string): void {
82
103
  :invalid="invalid"
83
104
  :describedby="describedby"
84
105
  :placeholder="endPlaceholder ?? 'Sampai'"
106
+ :prev-month-label="prevMonthLabel"
107
+ :next-month-label="nextMonthLabel"
108
+ :prev-year-label="prevYearLabel"
109
+ :next-year-label="nextYearLabel"
110
+ :prev-years-label="prevYearsLabel"
111
+ :next-years-label="nextYearsLabel"
112
+ :choose-month-label="chooseMonthLabel"
113
+ :choose-year-label="chooseYearLabel"
85
114
  @update:model-value="setEnd"
86
115
  />
87
116
  </div>
@@ -0,0 +1,276 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A Vue-native binding over freeday's `.fdy-timepicker` classes (src/components/timepicker.css).
6
+ // The kit also ships a framework-agnostic enhancer (freeday-timepicker.js), but letting it own the
7
+ // DOM would fight Vue's rendering and leave no `v-model` — the same reason FdyCombo re-implements
8
+ // the listbox rather than wrapping freeday-select.js. Charts delegate to their renderer; form
9
+ // controls do not.
10
+ //
11
+ // `<input type="time">` is what this replaces, and the reason is not theming alone: the native
12
+ // picker is drawn by the OPERATING SYSTEM, so iOS shows a scroll wheel, Android a clock dial and
13
+ // desktop a stepper, each with its own width and value format, and no CSS reaches any of them. A
14
+ // phone and a browser at the same width therefore disagree about a screen that is otherwise
15
+ // identical. Raised from keamanan-cluster, whose shift form was audited on an iPhone 7 (#094).
16
+ //
17
+ // Deliberate difference from the enhancer: focus stays on the TRIGGER with `aria-activedescendant`
18
+ // on it, which is what FdyCombo, FdyDatepicker and FdyAutocomplete do, rather than moving focus
19
+ // into the panel as the raw path does. Both are valid APG listbox patterns; one family, one model.
20
+
21
+ const props = defineProps<{
22
+ /** `HH:MM`, 24-hour. `''`/`null` for empty. A loose `H:MM` is normalised for matching. */
23
+ modelValue: string | null;
24
+ /** Minutes between options. Default 30. */
25
+ step?: number;
26
+ /** `HH:MM` bounds, inclusive. Default `00:00` to `23:59`. */
27
+ min?: string;
28
+ max?: string;
29
+ id?: string;
30
+ ariaLabel?: string;
31
+ ariaLabelledby?: string;
32
+ /** Shown when there is no value. Default `--:--`, the same as the raw path. */
33
+ placeholder?: string;
34
+ disabled?: boolean;
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
+ readonly?: boolean;
37
+ invalid?: boolean;
38
+ describedby?: string;
39
+ }>();
40
+
41
+ const emit = defineEmits<{
42
+ 'update:modelValue': [value: string];
43
+ change: [value: string];
44
+ }>();
45
+
46
+ const SHAPE: RegExp = /^\d{1,2}:\d{2}$/;
47
+
48
+ function toMinutes(time: string): number {
49
+ const [h, m] = time.split(':');
50
+ return Number(h) * 60 + Number(m);
51
+ }
52
+ function pad(n: number): string {
53
+ return n < 10 ? `0${n}` : String(n);
54
+ }
55
+ function fromMinutes(total: number): string {
56
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
57
+ }
58
+ /** `7:0` is not a time; `7:00` is the same time as `07:00`. Anything else answers `''`. */
59
+ function normalise(time: string | null): string {
60
+ if (time === null || !SHAPE.test(time)) return '';
61
+ const total: number = toMinutes(time);
62
+ return total >= 0 && total <= 23 * 60 + 59 ? fromMinutes(total) : '';
63
+ }
64
+ function bound(time: string | undefined, fallback: number): number {
65
+ return time !== undefined && SHAPE.test(time) ? toMinutes(time) : fallback;
66
+ }
67
+
68
+ const baseId: string = useId();
69
+ const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
70
+ const panelId: string = `${baseId}-panel`;
71
+ function optionId(index: number): string {
72
+ return `${baseId}-opt-${index}`;
73
+ }
74
+
75
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
76
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
77
+ const panelEl: Ref<HTMLUListElement | null> = ref(null);
78
+ const open: Ref<boolean> = ref(false);
79
+
80
+ // Lift the panel into the top layer so it escapes an ancestor overflow clip. A shift form is a
81
+ // modal, and a time list long enough to scroll is exactly the panel a card would cut off.
82
+ usePopover(panelEl, triggerEl, open);
83
+ const highlighted: Ref<number> = ref(-1);
84
+
85
+ const times: ComputedRef<ReadonlyArray<string>> = computed((): ReadonlyArray<string> => {
86
+ const stride: number = Math.max(1, Math.trunc(props.step ?? 30));
87
+ const from: number = bound(props.min, 0);
88
+ const to: number = bound(props.max, 23 * 60 + 59);
89
+ const out: string[] = [];
90
+ for (let m = from; m <= to; m += stride) out.push(fromMinutes(m));
91
+ return out;
92
+ });
93
+
94
+ const value: ComputedRef<string> = computed((): string => normalise(props.modelValue));
95
+ const selectedIndex: ComputedRef<number> = computed((): number => times.value.indexOf(value.value));
96
+ const shown: ComputedRef<string> = computed((): string =>
97
+ value.value !== '' ? value.value : (props.placeholder ?? '--:--'),
98
+ );
99
+ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => value.value === '');
100
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
101
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
102
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
103
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
104
+ open.value && highlighted.value >= 0 ? optionId(highlighted.value) : undefined,
105
+ );
106
+
107
+ /* The panel is `max-height:15rem` and scrolls: a highlight the reader cannot see is no highlight,
108
+ and with 48 half-hour options most of the list is out of view. */
109
+ function revealHighlight(): void {
110
+ void nextTick((): void => {
111
+ const list: HTMLUListElement | null = panelEl.value;
112
+ if (list === null || highlighted.value < 0) return;
113
+ const opt: Element | undefined = list.children[highlighted.value];
114
+ if (opt instanceof HTMLElement) opt.scrollIntoView({ block: 'nearest' });
115
+ });
116
+ }
117
+
118
+ /* Clamped, not cyclic: FdyCombo wraps because its lists are short and a wrap is a quick way back
119
+ to the top. Here ArrowUp at 00:00 landing on 23:30 reads as a glitch. Matches the raw path. */
120
+ function setHighlight(index: number): void {
121
+ const last: number = times.value.length - 1;
122
+ highlighted.value = last < 0 ? -1 : Math.min(last, Math.max(0, index));
123
+ revealHighlight();
124
+ }
125
+
126
+ function openList(): void {
127
+ if (isDisabled.value || isReadonly.value || open.value) return;
128
+ open.value = true;
129
+ setHighlight(selectedIndex.value >= 0 ? selectedIndex.value : 0);
130
+ }
131
+
132
+ function closeList(focusTrigger: boolean): void {
133
+ if (!open.value) return;
134
+ open.value = false;
135
+ highlighted.value = -1;
136
+ if (focusTrigger) triggerEl.value?.focus();
137
+ }
138
+
139
+ function toggle(): void {
140
+ if (open.value) closeList(false);
141
+ else openList();
142
+ }
143
+
144
+ function choose(index: number): void {
145
+ const next: string | undefined = times.value[index];
146
+ if (next === undefined) return;
147
+ if (next !== value.value) {
148
+ emit('update:modelValue', next);
149
+ emit('change', next);
150
+ }
151
+ closeList(true);
152
+ }
153
+
154
+ function onKeydown(e: KeyboardEvent): void {
155
+ if (isDisabled.value || isReadonly.value) return;
156
+ switch (e.key) {
157
+ case 'ArrowDown':
158
+ e.preventDefault();
159
+ if (open.value) setHighlight(highlighted.value + 1);
160
+ else openList();
161
+ break;
162
+ case 'ArrowUp':
163
+ e.preventDefault();
164
+ if (open.value) setHighlight(highlighted.value - 1);
165
+ else openList();
166
+ break;
167
+ case 'Home':
168
+ if (open.value) {
169
+ e.preventDefault();
170
+ setHighlight(0);
171
+ }
172
+ break;
173
+ case 'End':
174
+ if (open.value) {
175
+ e.preventDefault();
176
+ setHighlight(times.value.length - 1);
177
+ }
178
+ break;
179
+ case 'Enter':
180
+ case ' ':
181
+ e.preventDefault();
182
+ if (open.value && highlighted.value >= 0) choose(highlighted.value);
183
+ else openList();
184
+ break;
185
+ case 'Escape':
186
+ if (open.value) {
187
+ e.preventDefault();
188
+ closeList(true);
189
+ }
190
+ break;
191
+ case 'Tab':
192
+ if (open.value) closeList(false);
193
+ break;
194
+ default:
195
+ break;
196
+ }
197
+ }
198
+
199
+ function onDocPointerDown(e: MouseEvent): void {
200
+ const target: EventTarget | null = e.target;
201
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) {
202
+ closeList(false);
203
+ }
204
+ }
205
+
206
+ function onFocusout(e: FocusEvent): void {
207
+ const next: EventTarget | null = e.relatedTarget;
208
+ if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
209
+ closeList(false);
210
+ }
211
+ }
212
+
213
+ onMounted((): void => {
214
+ document.addEventListener('mousedown', onDocPointerDown);
215
+ });
216
+ onBeforeUnmount((): void => {
217
+ document.removeEventListener('mousedown', onDocPointerDown);
218
+ });
219
+ </script>
220
+
221
+ <template>
222
+ <div
223
+ ref="rootEl"
224
+ class="fdy-timepicker"
225
+ :class="{ 'fdy-timepicker--error': isInvalid }"
226
+ :data-value="value"
227
+ @keydown="onKeydown"
228
+ @focusout="onFocusout"
229
+ >
230
+ <button
231
+ :id="triggerId"
232
+ ref="triggerEl"
233
+ type="button"
234
+ class="fdy-timepicker__trigger"
235
+ :class="{ 'is-open': open }"
236
+ aria-haspopup="listbox"
237
+ :aria-controls="panelId"
238
+ :aria-expanded="open"
239
+ :aria-activedescendant="activeDescendant"
240
+ :aria-label="ariaLabel"
241
+ :aria-labelledby="ariaLabelledby"
242
+ :aria-invalid="isInvalid ? 'true' : undefined"
243
+ :aria-readonly="isReadonly ? 'true' : undefined"
244
+ :aria-describedby="describedby"
245
+ :disabled="isDisabled"
246
+ @click="toggle"
247
+ >
248
+ <span class="fdy-timepicker__value" :class="{ 'fdy-timepicker__value--placeholder': isPlaceholder }">{{ shown }}</span>
249
+ <span class="fdy-timepicker__icon">
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
251
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
252
+ <circle cx="12" cy="12" r="9"></circle>
253
+ <path d="M12 7v5l3 2"></path>
254
+ </svg>
255
+ </span>
256
+ </button>
257
+ <ul :id="panelId" ref="panelEl" class="fdy-timepicker__panel" role="listbox"
258
+ :aria-label="ariaLabel" popover="manual" :hidden="!open">
259
+ <!-- @mousedown.prevent keeps focus on the trigger: an <li> isn't focusable, so a plain
260
+ mousedown moves focus out of the control, fires @focusout and closes the panel before
261
+ the click lands — mouse-select would silently do nothing. Same as FdyCombo. -->
262
+ <li
263
+ v-for="(t, i) in times"
264
+ :id="optionId(i)"
265
+ :key="t"
266
+ class="fdy-timepicker__opt"
267
+ :class="{ 'is-active': i === highlighted }"
268
+ role="option"
269
+ :aria-selected="t === value"
270
+ @mousedown.prevent
271
+ @click="choose(i)"
272
+ @mousemove="setHighlight(i)"
273
+ >{{ t }}</li>
274
+ </ul>
275
+ </div>
276
+ </template>
@@ -44,6 +44,7 @@ export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
44
44
  export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
45
45
  export type { FdyMonth } from '../core/month-grid';
46
46
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
47
+ export { default as FdyTimepicker } from './components/FdyTimepicker.vue';
47
48
  export type { DateRangeValue } from './components/FdyDateRange.vue';
48
49
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
49
50
  export { default as FdyCascade } from './components/FdyCascade.vue';
@@ -4,6 +4,7 @@ export { default as FdyCombo } from './components/FdyCombo.vue';
4
4
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
5
5
  export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
6
6
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
7
+ export { default as FdyTimepicker } from './components/FdyTimepicker.vue';
7
8
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
8
9
  export { default as FdyCascade } from './components/FdyCascade.vue';
9
10
  export { default as FdyCfl } from './components/FdyCfl.vue';
@@ -59,6 +59,14 @@
59
59
  trigger.setAttribute('aria-haspopup', 'listbox');
60
60
  trigger.setAttribute('aria-expanded', 'false');
61
61
  trigger.setAttribute('aria-label', label);
62
+ /* The trigger is a <button>, which is NOT a labelable element, so a form's own `<label for>`
63
+ can never activate it -- but an id is still what `aria-describedby` targets and what a host
64
+ needs to point at this control. Both were unreadable from the seed until now, so a Blazor
65
+ host could not name its own error text. Mirrors the datepicker's seed. */
66
+ var seedId = wrap.getAttribute('data-id');
67
+ if (seedId) trigger.id = seedId;
68
+ var describedby = wrap.getAttribute('data-describedby');
69
+ if (describedby) trigger.setAttribute('aria-describedby', describedby);
62
70
  var valueSpan = document.createElement('span');
63
71
  valueSpan.className = 'fdy-timepicker__value';
64
72
  trigger.appendChild(valueSpan);
@@ -96,6 +104,30 @@
96
104
  wrap.appendChild(panel);
97
105
 
98
106
  var active = -1;
107
+ /* A host that renders its seed ONCE and then never re-renders it (Blazor: ShouldRender is
108
+ false after hydration) still has to be able to disable, lock or invalidate the field
109
+ afterwards. Without this the parameter was there and nothing read it. Mirrors
110
+ FreedayDatepicker.setState so the two controls of one family answer the same call. */
111
+ var stateDisabled = wrap.hasAttribute('data-disabled');
112
+ var stateReadonly = wrap.hasAttribute('data-readonly');
113
+ var stateInvalid = wrap.hasAttribute('data-invalid');
114
+
115
+ function applyState() {
116
+ trigger.disabled = stateDisabled;
117
+ if (stateReadonly) trigger.setAttribute('aria-readonly', 'true');
118
+ else trigger.removeAttribute('aria-readonly');
119
+ if (stateInvalid) trigger.setAttribute('aria-invalid', 'true');
120
+ else trigger.removeAttribute('aria-invalid');
121
+ wrap.classList.toggle('fdy-timepicker--error', stateInvalid);
122
+ if ((stateDisabled || stateReadonly) && !panel.hidden) close(false);
123
+ }
124
+ function setState(next) {
125
+ if (!next) return;
126
+ if (next.disabled != null) stateDisabled = !!next.disabled;
127
+ if (next.readonly != null) stateReadonly = !!next.readonly;
128
+ if (next.invalid != null) stateInvalid = !!next.invalid;
129
+ applyState();
130
+ }
99
131
 
100
132
  function updateDisplay() {
101
133
  if (value) {
@@ -124,6 +156,7 @@
124
156
  var _pop = null;
125
157
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
126
158
  function open() {
159
+ if (stateDisabled || stateReadonly) return;
127
160
  if (!panel.hidden) return;
128
161
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
129
162
  trigger.setAttribute('aria-expanded', 'true');
@@ -174,10 +207,12 @@
174
207
  });
175
208
 
176
209
  updateDisplay();
210
+ applyState();
177
211
 
178
212
  var api = {
179
213
  wrap: wrap,
180
214
  getValue: function () { return value; },
215
+ setState: setState,
181
216
  clear: function () { value = ''; for (var i = 0; i < opts.length; i++) opts[i].setAttribute('aria-selected', 'false'); updateDisplay(); }
182
217
  };
183
218
  wrap._fdyTp = api;
@@ -197,5 +232,13 @@
197
232
  initAll();
198
233
  }
199
234
 
200
- window.FreedayTimepicker = { init: initTime, initAll: initAll };
235
+ window.FreedayTimepicker = {
236
+ init: initTime,
237
+ initAll: initAll,
238
+ /* See api.setState above: the one call a render-once host needs. */
239
+ setState: function (root, state) {
240
+ var api = root && root._fdyTp ? root._fdyTp : null;
241
+ if (api && api.setState) api.setState(state);
242
+ }
243
+ };
201
244
  })();
package/dist/freeday.js CHANGED
@@ -4945,6 +4945,14 @@
4945
4945
  trigger.setAttribute('aria-haspopup', 'listbox');
4946
4946
  trigger.setAttribute('aria-expanded', 'false');
4947
4947
  trigger.setAttribute('aria-label', label);
4948
+ /* The trigger is a <button>, which is NOT a labelable element, so a form's own `<label for>`
4949
+ can never activate it -- but an id is still what `aria-describedby` targets and what a host
4950
+ needs to point at this control. Both were unreadable from the seed until now, so a Blazor
4951
+ host could not name its own error text. Mirrors the datepicker's seed. */
4952
+ var seedId = wrap.getAttribute('data-id');
4953
+ if (seedId) trigger.id = seedId;
4954
+ var describedby = wrap.getAttribute('data-describedby');
4955
+ if (describedby) trigger.setAttribute('aria-describedby', describedby);
4948
4956
  var valueSpan = document.createElement('span');
4949
4957
  valueSpan.className = 'fdy-timepicker__value';
4950
4958
  trigger.appendChild(valueSpan);
@@ -4982,6 +4990,30 @@
4982
4990
  wrap.appendChild(panel);
4983
4991
 
4984
4992
  var active = -1;
4993
+ /* A host that renders its seed ONCE and then never re-renders it (Blazor: ShouldRender is
4994
+ false after hydration) still has to be able to disable, lock or invalidate the field
4995
+ afterwards. Without this the parameter was there and nothing read it. Mirrors
4996
+ FreedayDatepicker.setState so the two controls of one family answer the same call. */
4997
+ var stateDisabled = wrap.hasAttribute('data-disabled');
4998
+ var stateReadonly = wrap.hasAttribute('data-readonly');
4999
+ var stateInvalid = wrap.hasAttribute('data-invalid');
5000
+
5001
+ function applyState() {
5002
+ trigger.disabled = stateDisabled;
5003
+ if (stateReadonly) trigger.setAttribute('aria-readonly', 'true');
5004
+ else trigger.removeAttribute('aria-readonly');
5005
+ if (stateInvalid) trigger.setAttribute('aria-invalid', 'true');
5006
+ else trigger.removeAttribute('aria-invalid');
5007
+ wrap.classList.toggle('fdy-timepicker--error', stateInvalid);
5008
+ if ((stateDisabled || stateReadonly) && !panel.hidden) close(false);
5009
+ }
5010
+ function setState(next) {
5011
+ if (!next) return;
5012
+ if (next.disabled != null) stateDisabled = !!next.disabled;
5013
+ if (next.readonly != null) stateReadonly = !!next.readonly;
5014
+ if (next.invalid != null) stateInvalid = !!next.invalid;
5015
+ applyState();
5016
+ }
4985
5017
 
4986
5018
  function updateDisplay() {
4987
5019
  if (value) {
@@ -5010,6 +5042,7 @@
5010
5042
  var _pop = null;
5011
5043
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
5012
5044
  function open() {
5045
+ if (stateDisabled || stateReadonly) return;
5013
5046
  if (!panel.hidden) return;
5014
5047
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
5015
5048
  trigger.setAttribute('aria-expanded', 'true');
@@ -5060,10 +5093,12 @@
5060
5093
  });
5061
5094
 
5062
5095
  updateDisplay();
5096
+ applyState();
5063
5097
 
5064
5098
  var api = {
5065
5099
  wrap: wrap,
5066
5100
  getValue: function () { return value; },
5101
+ setState: setState,
5067
5102
  clear: function () { value = ''; for (var i = 0; i < opts.length; i++) opts[i].setAttribute('aria-selected', 'false'); updateDisplay(); }
5068
5103
  };
5069
5104
  wrap._fdyTp = api;
@@ -5083,7 +5118,15 @@
5083
5118
  initAll();
5084
5119
  }
5085
5120
 
5086
- window.FreedayTimepicker = { init: initTime, initAll: initAll };
5121
+ window.FreedayTimepicker = {
5122
+ init: initTime,
5123
+ initAll: initAll,
5124
+ /* See api.setState above: the one call a render-once host needs. */
5125
+ setState: function (root, state) {
5126
+ var api = root && root._fdyTp ? root._fdyTp : null;
5127
+ if (api && api.setState) api.setState(state);
5128
+ }
5129
+ };
5087
5130
  })();
5088
5131
 
5089
5132
  /* Freeday, toast API (optional, zero-dependency).
@@ -33,9 +33,9 @@ and the code looks correct and fails later.**
33
33
  | Blazor (net8.0) | `@using Freeday.Blazor` (RCL) | `@bind-Value` |
34
34
  | Static HTML, Svelte, server-rendered templates… | no wrapper: raw markup + the enhancer script | `fdy-*` DOM events |
35
35
 
36
- The twelve: **FdyCombo · FdyDatepicker · FdyMonthpicker · FdyDateRange · FdyAutocomplete ·
37
- FdyCascade · FdyCfl · FdyChart · FdyTable · FdyModal · FdyDrawer · FdyAppShell**. In
38
- Vue/React/Blazor, **never hand-write the raw markup + enhancer for these twelve.** The raw path *appears* to work: the enhancer auto-initialises
36
+ The thirteen: **FdyCombo · FdyDatepicker · FdyMonthpicker · FdyDateRange · FdyTimepicker ·
37
+ FdyAutocomplete · FdyCascade · FdyCfl · FdyChart · FdyTable · FdyModal · FdyDrawer · FdyAppShell**. In
38
+ Vue/React/Blazor, **never hand-write the raw markup + enhancer for these thirteen.** The raw path *appears* to work: the enhancer auto-initialises
39
39
  once on `DOMContentLoaded` and the first render is correct. Then it fails quietly: DOM your framework
40
40
  renders later is never hydrated, and the widget's state lives in the DOM instead of in your
41
41
  framework's state.
@@ -21,8 +21,8 @@ Freeday = **CSS** (semantic tokens + `fdy-*` classes) + **zero-dependency JS enh
21
21
  the enhancer owns the widget's DOM. You **listen for `fdy-*` events** (all bubbling
22
22
  `CustomEvent`s, data in `event.detail`) → store them in your framework state. Event/API contract
23
23
  table: [`integrations.md` §Event & API contract](integrations.md).
24
- **On Vue, React or Blazor this is not the path to take for twelve of the components.** `FdyCombo`,
25
- `FdyDatepicker`, `FdyMonthpicker`, `FdyDateRange`, `FdyAutocomplete`, `FdyCascade`, `FdyCfl`,
24
+ **On Vue, React or Blazor this is not the path to take for thirteen of the components.** `FdyCombo`,
25
+ `FdyDatepicker`, `FdyMonthpicker`, `FdyDateRange`, `FdyTimepicker`, `FdyAutocomplete`, `FdyCascade`, `FdyCfl`,
26
26
  `FdyChart`, `FdyTable`, `FdyModal`, `FdyDrawer`, `FdyAppShell` ship typed wrappers that own the state properly (Vue and
27
27
  React re-implement the interaction natively; Blazor wraps the enhancer over interop). Use them;
28
28
  the raw path is for the components without a wrapper, and for stacks without an adapter.
@@ -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.10.0"` (public npm package). `dist/` is
201
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^3.11.0"` (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`)
@@ -451,9 +451,9 @@ kit's CSS/enhancers), then bind:
451
451
  <FdyChart Type="donut" Values="_byCity" Labels="_cityLabels" AriaLabel="Revenue by city" />
452
452
  <FdyDrawer @bind-Open="_drawerOpen" Title="Detail" Side="right">…</FdyDrawer>
453
453
  ```
454
- Twelve components at parity with the Vue/React adapters: **`FdyModal`** · **`FdyDrawer`** (`@bind-Open`,
454
+ Thirteen components at parity with the Vue/React adapters: **`FdyModal`** · **`FdyDrawer`** (`@bind-Open`,
455
455
  `Title`, `Size`/`Side`, `Dismissible`) · **`FdyCombo<TValue>`** · **`FdyDatepicker`** ·
456
- **`FdyMonthpicker`** (`@bind-Value` as `yyyy-MM`) · **`FdyAutocomplete`** · **`FdyCascade`** · **`FdyDateRange`** (`@bind-From`/`@bind-To`) ·
456
+ **`FdyMonthpicker`** (`@bind-Value` as `yyyy-MM`) · **`FdyTimepicker`** (`@bind-Value` as `HH:MM`) · **`FdyAutocomplete`** · **`FdyCascade`** · **`FdyDateRange`** (`@bind-From`/`@bind-To`) ·
457
457
  **`FdyCfl<TRow>`** (async `LoadPage`) · **`FdyChart`** · **`FdyTable<TRow>`** (client sort/filter/page,
458
458
  or controlled `Sort`/`Filters`/`Page` for a server-paged table; `RowActivatable`, `RowDetail`) ·
459
459
  **`FdyAppShell`** (`@bind-NavOpen`). Each
@@ -154,7 +154,7 @@ function Panel(): JSX.Element {
154
154
  ```
155
155
  > **Controlled alternative (parity with Vue's `v-model`):** `@cahyo-dimas/freeday/react` also exports
156
156
  > the typed components `FdyCombo` / `FdyDatepicker` / `FdyMonthpicker` / `FdyDateRange` /
157
- > `FdyAutocomplete` / `FdyCascade` / `FdyCfl` / `FdyChart` / `FdyTable` / `FdyModal` / `FdyDrawer` /
157
+ > `FdyTimepicker` / `FdyAutocomplete` / `FdyCascade` / `FdyCfl` / `FdyChart` / `FdyTable` / `FdyModal` / `FdyDrawer` /
158
158
  > `FdyAppShell`, with plain
159
159
  > `value`/`onChange` (props in, events out), no `data-fdy-*` + manual event listener:
160
160
  > ```tsx
@@ -200,7 +200,7 @@ document.addEventListener('fdy-form-invalid', (e) =>
200
200
 
201
201
  > **Native components (parity with Vue's `v-model` / React's `value`/`onChange`):** the
202
202
  > **`Freeday.Blazor`** RCL (net8.0, `adapters/blazor/`) ships the typed components `FdyCombo<TValue>` /
203
- > `FdyDatepicker` / `FdyMonthpicker` / `FdyDateRange` / `FdyAutocomplete` / `FdyCascade` /
203
+ > `FdyDatepicker` / `FdyMonthpicker` / `FdyDateRange` / `FdyTimepicker` / `FdyAutocomplete` / `FdyCascade` /
204
204
  > `FdyCfl<TRow>` / `FdyChart` / `FdyTable<TRow>` / `FdyModal` / `FdyDrawer` / `FdyAppShell` with `@bind`, no `@ref` / manual `initAll` / `[JSInvokable]`:
205
205
  > ```razor
206
206
  > @using Freeday.Blazor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
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/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/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",