@cahyo-dimas/freeday 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,99 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.27.0] — 2026-08-14
7
+ Improvement note #44, found while building a settings screen whose only numeric field looked like it
8
+ belonged to a different application.
9
+ ### Fixed
10
+ - **`.fdy-input[type="number"]` no longer shows the user agent's spin buttons.** They are OS widgets
11
+ in OS colours that no theme reaches, so on a dark surface they read as a light-grey artefact glued
12
+ to an otherwise themed field — the one unthemed control on a page where everything else is themed
13
+ to the last pixel. Both halves ship, because they cover different engines: `appearance: textfield`
14
+ (Firefox) and `::-webkit-outer/inner-spin-button { appearance: none }` (Blink/WebKit).
15
+ ### Added
16
+ - **`[data-fdy-number]` + `freeday-number.js`** — the increment affordance back on the kit's terms,
17
+ since hiding the native buttons removes it. **Not a new block:** it is an `.fdy-input-group` with
18
+ two `__btn`s, so it inherits the shared border, `:focus-within` ring and `:has()` error promotion
19
+ that already existed. The reported `.fdy-number` block would have duplicated all of it.
20
+ - **No custom event.** Stepping dispatches native bubbling `input` + `change` on the input, so
21
+ `v-model` / `onChange` / `@bind` work with no adapter and no new API — the input stays the source
22
+ of truth. That is also why this needs no typed wrapper in any of the four stacks.
23
+ - `min`/`max`/`step` live on the input and the buttons never redo the arithmetic (`stepUp()`/
24
+ `stepDown()` clamp for free). They go `disabled` at a bound, on a `disabled`/`readonly` field,
25
+ and when `step="any"` — which has no defined increment and makes `stepUp()` throw, so a stepper
26
+ cannot honestly express it.
27
+ - A `MutationObserver` watches `disabled`/`readonly`/`min`/`max`/`step`: a framework changes those
28
+ without firing an event, and a button that still looks enabled while doing nothing is the exact
29
+ lie this state machine exists to prevent. (First observer in the kit — the alternative was a
30
+ button whose appearance silently drifts from its behaviour.)
31
+ - The buttons are **not tab stops** (`tabindex="-1"`): the input is already focusable and ↑/↓
32
+ already step it, so two extra stops per field cost every keyboard user and buy nothing. They keep
33
+ an `aria-label`, and `type="button"` so they cannot submit their form.
34
+ - `.fdy-input-group__btn` gains a **leading-position** rule (divider on the correct side when the
35
+ button comes first) and a **`:disabled`** state — dim + `not-allowed`, with `:hover` withdrawn,
36
+ because a disabled button still matches `:hover`.
37
+ - `COMPONENTS.md` now answers **which `type` `.fdy-input` covers**, which is the question that would
38
+ have prevented this note: every text-like type themes identically, and the two that keep a native
39
+ widget are named with what to do about each.
40
+ ### Notes on the shape of the fix
41
+ - **`type="search"` deliberately left alone.** Its WebKit clear (×) button is unthemed too — and it
42
+ is on the kit's own docs pages, 10 of them — but it is the only way to empty the field. Stripping
43
+ it removes function, not chrome; the number arrows only removed a mouse-only increment that ↑/↓
44
+ still provides. `type="date"`/`time` likewise keep their picker indicator; the kit ships its own
45
+ datepicker/timepicker and the docs now say to use those instead.
46
+ - **Why this was never caught:** `type="number"` appears **zero** times across `docs/index.html`,
47
+ `docs/reference-screen.html` and `examples/` (against `search` ×10, `text` ×12, `date` ×4). The kit
48
+ never used the input it shipped. The docs page now carries a real number field, in the input-group
49
+ section where it belongs.
50
+ ### Added — guards
51
+ - `test/css.test.mjs` asserts both engine halves survive (each is invisible in review and each brings
52
+ the artefact back on one engine only), and states in-file why `search`/`date` are excluded.
53
+ - `browser/number.mjs` drives real clicks and a real Tab: stepping fires native `input` + `change`,
54
+ bounds disable the right button, a disabled button changes nothing, `readonly` and `step="any"`
55
+ are inert without throwing, and Tab skips the buttons. Mutation-checked against five defects.
56
+ - `browser/harness.mjs` gains `pressKey` — tab order only moves for a trusted key, so the claim is
57
+ measured rather than read off an attribute.
58
+
59
+ ## [1.26.0] — 2026-08-14
60
+ Improvement note #43, found while chasing a "the upload is stuck" report on a 626 KB PDF: the
61
+ transfer took about a second, the server then spent nearly a minute reading the document.
62
+ ### Added
63
+ - **`row.waiting(label)`** — the state between `setProgress` and `done`: the bytes are gone, the
64
+ server has not answered. The row's only long-running state was named after the *transfer*, so it
65
+ kept saying "Mengunggah…" for the whole minute of server-side work — and `setProgress(100)` made it
66
+ worse, because a full bar that then sits still is the most convincing "hung" signal a UI can
67
+ produce. There was no way out within the row's API: `done()` claims success, `fail()` claims an
68
+ error, `ready()` walks backwards. Consumers were rendering a second status line outside the row and
69
+ leaving the row to contradict it.
70
+ The bar goes **indeterminate** and drops `aria-valuenow` — a progressbar with no value is exactly
71
+ what ARIA calls indeterminate, which is the contract `COMPONENTS.md` already stated for
72
+ `.fdy-progress`. The label is the consumer's, because only they know what the server is doing
73
+ (`Membaca PDF…`, `Memindai…`); it falls back to `Menunggu server…`.
74
+ - `COMPONENTS.md` gains the state in the row table plus the sentence that would have saved the
75
+ round-trip: **if your request outlives the transfer, drive `waiting()`**.
76
+ ### Notes on the shape of the fix
77
+ - The report's patch would have shipped the symptom it set out to remove. It put the modifier on the
78
+ **bar** (`.fdy-progress--indeterminate` styles `.fdy-progress__bar`, so it belongs on the
79
+ container — on the bar it matches nothing) and then set an inline `width:100%`, which beats the
80
+ modifier's own width anyway. Both mistakes render a full, frozen bar. The note also hedged that
81
+ `.fdy-progress--indeterminate` might not exist; it has all along.
82
+ - **Leaving the state needs more care than entering it.** `.fdy-progress__bar` is a plain block div:
83
+ with no width it fills its track. So `uploading()`/`setProgress()` restore an explicit width when
84
+ they clear the modifier, or a retried row paints a *full* bar while meaning 0%. `done()`, `fail()`
85
+ and `ready()` need no counterpart — they drop the progress element outright, modifier and all
86
+ (contrary to the note, which expected a line in each).
87
+ - **No `.fdy-file--waiting` class.** `uploading` has none either; only `--success`/`--error` do,
88
+ because they carry colour. A documented class with no rule is markup that looks like it does
89
+ something.
90
+ - Under `prefers-reduced-motion: reduce` the kit's indeterminate treatment is a dimmed **full** bar
91
+ (no animation left to carry the meaning) — pre-existing behaviour for every indeterminate progress,
92
+ not introduced here. For those users the honest signal is the label, not the bar.
93
+ ### Added — guards
94
+ - `browser/upload-states.mjs` gains a third spec, measuring what the **engine renders** rather than
95
+ what the source declares: both ways to get this wrong are invisible in a code read. Mutation-checked
96
+ against five defects, including the report's own two — modifier-on-bar, inline `width:100%`, no
97
+ width restored on return, `aria-valuenow` kept, and label ignored.
98
+
6
99
  ## [1.25.0] — 2026-08-13
7
100
  Improvement note #42, found while adopting 1.24.0 — the other half of the same integration.
8
101
  ### Fixed
package/COMPONENTS.md CHANGED
@@ -91,6 +91,7 @@ element is fine. Events are bubbling
91
91
  | `data-fdy-tree` | `freeday-tree.js` | `FreedayTree` | — |
92
92
  | `data-fdy-rating` | `freeday-rating.js` | `FreedayRating` | — (native `change`) |
93
93
  | `data-fdy-slider` | `freeday-slider.js` | `FreedaySlider` | — (native `input`) |
94
+ | `data-fdy-number` | `freeday-number.js` | `FreedayNumber` | — (native `input`, `change`) |
94
95
  | `data-fdy-drawer="id"` | `freeday-drawer.js` | `FreedayDrawer` | — |
95
96
  | `data-fdy-chart="type"` | `freeday-chart.js` | `FreedayChart` | — (`.update(el)` to repaint) |
96
97
  | — (imperative) | `freeday-toast.js` | `Freeday.toast()` / `Freeday.dismiss()` | — |
@@ -307,6 +308,17 @@ native control, otherwise a `<div>` + explicitly associated label.
307
308
  </label>
308
309
  ```
309
310
 
311
+ **Which `type` does `.fdy-input` cover?** Every text-like type — `text` `email` `password` `tel`
312
+ `url` `search` `number` — themes identically. Two carry a native widget the kit does *not* override:
313
+
314
+ | Type | What the UA still draws | What to do |
315
+ |---|---|---|
316
+ | `search` | WebKit's clear (×) button, in OS colours | **Left alone on purpose** — it is the only way to empty the field. Want it themed? Drop the type back to `text` and add a `.fdy-input-group__btn` that clears. |
317
+ | `date` `time` `datetime-local` | the picker indicator glyph, and the whole native picker | Use `.fdy-datepicker` / `.fdy-timepicker` / `.fdy-datetimepicker` instead — the kit ships its own. |
318
+
319
+ `number` used to be a third: the UA spin buttons are unthemeable, so `.fdy-input[type="number"]`
320
+ now hides them and `[data-fdy-number]` below gives the affordance back.
321
+
310
322
  ## Input group — `.fdy-input-group`
311
323
  Prefix/suffix addons around an input: text (`Rp`, `%`), a decorative icon
312
324
  (`__addon--icon`), or an action button (`__btn`).
@@ -320,6 +332,31 @@ Prefix/suffix addons around an input: text (`Rp`, `%`), a decorative icon
320
332
  </div>
321
333
  ```
322
334
 
335
+ ## Number field — `[data-fdy-number]`
336
+ A number input with its increment/decrement affordance back, after `.fdy-input` removed the
337
+ browser's own. **Not a new block** — it is an `.fdy-input-group` with two `__btn`s, so it inherits
338
+ the shared border, focus ring and error promotion. Needs `freeday-number.js`.
339
+
340
+ ```html
341
+ <div class="fdy-input-group" data-fdy-number>
342
+ <button type="button" class="fdy-input-group__btn" data-fdy-number-step="-1"
343
+ tabindex="-1" aria-label="Kurangi">−</button>
344
+ <input class="fdy-input" type="number" min="0" max="10" step="1" value="1" aria-label="Konfirmasi">
345
+ <button type="button" class="fdy-input-group__btn" data-fdy-number-step="1"
346
+ tabindex="-1" aria-label="Tambah">+</button>
347
+ </div>
348
+ ```
349
+
350
+ - **No custom event.** Stepping fires native bubbling `input` + `change` on the input, so `v-model`,
351
+ `onChange` and `@bind` work with no adapter — the input stays the source of truth.
352
+ - `min` / `max` / `step` live on the **input**; the buttons never do the arithmetic themselves
353
+ (`stepUp()`/`stepDown()` clamp for free) and go `disabled` at a bound, on a `disabled`/`readonly`
354
+ field, and when `step="any"` (a stepper cannot express "no defined increment").
355
+ - The buttons are **not tab stops** (`tabindex="-1"`): the input is already focusable and ↑/↓
356
+ already step it, so extra stops would cost every keyboard user and buy nothing. Keep the
357
+ `aria-label` — pointer and browse-mode users still get a named control.
358
+ - `type="button"` is required. Inside a `<form>`, a bare `<button>` submits it.
359
+
323
360
  ## Checkbox · radio · switch
324
361
  Native inputs, styled. `.fdy-check` · `.fdy-radio` · `.fdy-switch` on the wrapping `<label>`;
325
362
  `.fdy-checkbox` on a bare `<input type="checkbox">` used inside tables/trees.
@@ -495,11 +532,17 @@ transfer started; the kit never claims one it is not performing.
495
532
  | *(initial)* | **rest** — chosen, not sent. Size only, no progress bar. |
496
533
  | `.uploading()` | in flight — adds the progress bar |
497
534
  | `.setProgress(pct)` | moves the bar (0–100) |
535
+ | `.waiting(label)` | **sent, waiting on the server** — indeterminate bar, no `aria-valuenow`; `label` is yours (default *Menunggu server…*) |
498
536
  | `.done()` | success — drops the bar, `.fdy-file--success` |
499
537
  | `.fail(msg)` | error — drops the bar, `.fdy-file--error`, `msg` replaces the sub-line |
500
538
  | `.ready()` | back to **rest** (e.g. after a failed attempt the user will retry) |
501
539
  | `.el` | the row element |
502
540
 
541
+ **If your request outlives the transfer, drive `waiting()`.** Server-side work after the last byte —
542
+ OCR, extraction, virus scanning, transcoding — is not uploading, and `setProgress(100)` left standing
543
+ is read as a hang. `waiting()` is the state for it: the label says what the server is doing, and the
544
+ bar stops claiming a percentage it no longer has.
545
+
503
546
  ```js
504
547
  zone.addEventListener('fdy-upload-add', (e) => {
505
548
  if (e.detail.rejected) return; // the kit already rendered the reason
@@ -507,6 +550,8 @@ zone.addEventListener('fdy-upload-add', (e) => {
507
550
  submitBtn.onclick = async () => {
508
551
  row.uploading();
509
552
  await send(file, (pct) => row.setProgress(pct));
553
+ row.waiting('Membaca dokumen…'); // bytes gone, server still working
554
+ await serverFinished();
510
555
  row.done();
511
556
  };
512
557
  });
package/README.id.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **Lebih banyak _free day_ buat dev — UI kit-nya sudah siap pakai.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.25.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.25.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
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`.
@@ -130,6 +130,7 @@ Semua auto-init `[data-fdy-*]` saat `DOMContentLoaded` — idempotent, progressi
130
130
  | `freeday-datetime` | `[data-fdy-datetimepicker]` | `fdy-datetime-change` `{date,time,value}` · `window.FreedayDatetime` |
131
131
  | `freeday-cascade` | `[data-fdy-cascade]` (model `<ul>` bersarang) | `fdy-cascade-change` `{value,path,labels}` · `window.FreedayCascade` |
132
132
  | `freeday-mask` | `[data-fdy-mask]`, `[data-fdy-password]` | `fdy-mask` `{value,raw}` · `window.FreedayMask` |
133
+ | `freeday-number` | `[data-fdy-number]` | — (native `input`/`change`) · `window.FreedayNumber` |
133
134
  | `freeday-form` | `[data-fdy-validate]` (form) | `fdy-form-invalid`/`-valid` · `window.FreedayForm` |
134
135
  | `freeday-chip` | `[data-fdy-chips]`, `.fdy-chip__remove` | `fdy-chip-change`/`fdy-chip-remove` · `window.FreedayChip` |
135
136
  | `freeday-upload` | `[data-fdy-dropzone]` | `fdy-upload-add`/`-remove` · `window.FreedayUpload` |
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.25.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.25.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.27.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.27.0)
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`.
@@ -129,6 +129,7 @@ Each one auto-inits `[data-fdy-*]` on `DOMContentLoaded` — idempotent, progres
129
129
  | `freeday-datetime` | `[data-fdy-datetimepicker]` | `fdy-datetime-change` `{date,time,value}` · `window.FreedayDatetime` |
130
130
  | `freeday-cascade` | `[data-fdy-cascade]` (nested `<ul>` model) | `fdy-cascade-change` `{value,path,labels}` · `window.FreedayCascade` |
131
131
  | `freeday-mask` | `[data-fdy-mask]`, `[data-fdy-password]` | `fdy-mask` `{value,raw}` · `window.FreedayMask` |
132
+ | `freeday-number` | `[data-fdy-number]` | — (native `input`/`change`) · `window.FreedayNumber` |
132
133
  | `freeday-form` | `[data-fdy-validate]` (form) | `fdy-form-invalid`/`-valid` · `window.FreedayForm` |
133
134
  | `freeday-chip` | `[data-fdy-chips]`, `.fdy-chip__remove` | `fdy-chip-change`/`fdy-chip-remove` · `window.FreedayChip` |
134
135
  | `freeday-upload` | `[data-fdy-dropzone]` | `fdy-upload-add`/`-remove` · `window.FreedayUpload` |
@@ -0,0 +1,104 @@
1
+ /* Freeday — number field enhancer (optional, zero-dependency).
2
+ * Gives a native <input type="number"> its increment/decrement affordance back, on the kit's terms:
3
+ * .fdy-input hides the user agent's own spin buttons (unthemeable OS widgets), so this puts two real
4
+ * buttons in an .fdy-input-group instead. No new block — the group already owns the shared border,
5
+ * :focus-within ring and error promotion.
6
+ *
7
+ * Markup contract:
8
+ * <div class="fdy-input-group" data-fdy-number>
9
+ * <button type="button" class="fdy-input-group__btn" data-fdy-number-step="-1"
10
+ * tabindex="-1" aria-label="Kurangi">−</button>
11
+ * <input class="fdy-input" type="number" min="0" max="10" step="1" value="1" aria-label="…">
12
+ * <button type="button" class="fdy-input-group__btn" data-fdy-number-step="1"
13
+ * tabindex="-1" aria-label="Tambah">+</button>
14
+ * </div>
15
+ *
16
+ * No custom event: the input is the source of truth, so stepping dispatches native bubbling `input`
17
+ * and `change`. v-model / onChange / @bind bind to the input directly, exactly as they would without
18
+ * this enhancer.
19
+ *
20
+ * The buttons are deliberately OUT of the tab order (tabindex="-1"): the input is already focusable
21
+ * and ArrowUp/ArrowDown already step it, so two extra tab stops per field would cost every keyboard
22
+ * user something and buy nothing. They keep an aria-label, so pointer and browse-mode users still
23
+ * get a named control.
24
+ */
25
+ (function () {
26
+ 'use strict';
27
+
28
+ function initNumber(root) {
29
+ if (root.dataset.fdyNumberReady === '1') return;
30
+
31
+ var input = root.querySelector('input[type="number"]');
32
+ var buttons = Array.prototype.slice.call(root.querySelectorAll('[data-fdy-number-step]'));
33
+ if (!input || !buttons.length) return;
34
+ root.dataset.fdyNumberReady = '1';
35
+
36
+ /* step="any" means there is no defined increment, and stepUp()/stepDown() throw InvalidStateError
37
+ on such a field. A stepper cannot express it, so the buttons stay visibly disabled rather than
38
+ failing on click. Read live, not captured: `step` is one of the attributes watched below. */
39
+ function stepless() {
40
+ return (input.getAttribute('step') || '').toLowerCase() === 'any';
41
+ }
42
+
43
+ function frozen() {
44
+ return stepless() || input.disabled || input.readOnly;
45
+ }
46
+
47
+ /* Bounds are read from the input, never recomputed: stepUp()/stepDown() already clamp to
48
+ min/max/step, so these two only decide whether the button tells the truth about what it does. */
49
+ function atLimit(direction) {
50
+ var bound = direction > 0 ? input.max : input.min;
51
+ if (bound === '' || input.value === '') return false;
52
+ var value = Number(input.value);
53
+ var limit = Number(bound);
54
+ if (isNaN(value) || isNaN(limit)) return false;
55
+ return direction > 0 ? value >= limit : value <= limit;
56
+ }
57
+
58
+ function sync() {
59
+ buttons.forEach(function (btn) {
60
+ var direction = Number(btn.getAttribute('data-fdy-number-step')) < 0 ? -1 : 1;
61
+ btn.disabled = frozen() || atLimit(direction);
62
+ });
63
+ }
64
+
65
+ buttons.forEach(function (btn) {
66
+ btn.addEventListener('click', function () {
67
+ if (frozen()) return;
68
+ if (Number(btn.getAttribute('data-fdy-number-step')) < 0) input.stepDown();
69
+ else input.stepUp();
70
+ /* The value changed without the user typing, so say so the way the platform does — frameworks
71
+ listen to these, not to a kit-specific event. */
72
+ input.dispatchEvent(new Event('input', { bubbles: true }));
73
+ input.dispatchEvent(new Event('change', { bubbles: true }));
74
+ sync();
75
+ });
76
+ });
77
+
78
+ input.addEventListener('input', sync);
79
+ input.addEventListener('change', sync);
80
+ /* Typing is not the only thing that moves a bound: a framework flips `disabled` or recomputes
81
+ `max` without any event firing, and a button that still looks enabled but no longer does
82
+ anything is the lie this state machine exists to avoid. */
83
+ new MutationObserver(sync).observe(input, {
84
+ attributes: true,
85
+ attributeFilter: ['disabled', 'readonly', 'min', 'max', 'step'],
86
+ });
87
+ sync();
88
+ }
89
+
90
+ function initAll(context) {
91
+ var root = context || document;
92
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON the widget. */
93
+ if (root.matches && root.matches('[data-fdy-number]')) initNumber(root);
94
+ Array.prototype.forEach.call(root.querySelectorAll('[data-fdy-number]'), initNumber);
95
+ }
96
+
97
+ if (document.readyState === 'loading') {
98
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
99
+ } else {
100
+ initAll();
101
+ }
102
+
103
+ window.FreedayNumber = { init: initNumber, initAll: initAll };
104
+ })();
@@ -20,8 +20,8 @@
20
20
  * "fdy-upload-add" {file, rejected, reason, row}
21
21
  * "fdy-upload-remove" {file}
22
22
  * `row` is the state machine over the rendered .fdy-file:
23
- * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) / .done() /
24
- * .fail(msg) / .el
23
+ * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) /
24
+ * .waiting(label) (sent, awaiting the server) / .done() / .fail(msg) / .el
25
25
  * Note the file list is a SIBLING of the dropzone above, so nothing dispatched on a row would ever
26
26
  * bubble through the zone — which is why removal fires on the zone and not on the row.
27
27
  */
@@ -107,6 +107,13 @@
107
107
  function dropProgress() {
108
108
  if (progressWrap) { progressWrap.remove(); progressWrap = null; bar = null; progressEl = null; }
109
109
  }
110
+ /* Back to a measured bar. While the indeterminate modifier is on it owns the bar's width, so an
111
+ explicit one has to be restored when it comes off: .fdy-progress__bar is a plain block div, and
112
+ with no width at all it fills the track — a full bar, which is the opposite of what 0% means. */
113
+ function determinate() {
114
+ progressEl.classList.remove('fdy-progress--indeterminate');
115
+ if (!bar.style.width) bar.style.width = '0%';
116
+ }
110
117
  return {
111
118
  el: el,
112
119
  /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
@@ -124,13 +131,34 @@
124
131
  icon.innerHTML = FILE_ICON;
125
132
  sub.textContent = fmtSize(file.size) + ' · Mengunggah…';
126
133
  ensureProgress();
134
+ determinate();
127
135
  },
128
136
  setProgress: function (pct) {
129
137
  ensureProgress();
138
+ determinate();
130
139
  var v = Math.max(0, Math.min(100, pct));
131
140
  bar.style.width = v + '%';
132
141
  progressEl.setAttribute('aria-valuenow', String(Math.round(v)));
133
142
  },
143
+ /* The bytes are gone and the server has not answered yet — extraction, scanning, transcoding.
144
+ "Mengunggah…" turns false the moment the last byte leaves, and a determinate bar parked at
145
+ 100% is the most convincing "hung" signal a UI can produce, so this state reports no
146
+ percentage: the bar goes indeterminate and the label belongs to the consumer, because only
147
+ they know what the server is doing ("Membaca PDF…", "Memindai…"). done()/fail()/ready() need
148
+ no counterpart here — they drop the progress element outright, modifier and all. */
149
+ waiting: function (label) {
150
+ el.classList.remove('fdy-file--error', 'fdy-file--success');
151
+ icon.innerHTML = FILE_ICON;
152
+ sub.textContent = fmtSize(file.size) + ' · ' + (label || 'Menunggu server…');
153
+ ensureProgress();
154
+ progressEl.classList.add('fdy-progress--indeterminate');
155
+ /* The modifier styles .fdy-progress__bar, so it must sit on the CONTAINER, and the inline
156
+ width setProgress wrote has to go — an inline style beats any rule the modifier brings.
157
+ aria-valuenow goes with it: a progressbar with no value is precisely what ARIA calls
158
+ indeterminate, which is the contract COMPONENTS.md already states for this component. */
159
+ bar.style.width = '';
160
+ progressEl.removeAttribute('aria-valuenow');
161
+ },
134
162
  done: function () {
135
163
  el.classList.add('fdy-file--success');
136
164
  el.classList.remove('fdy-file--error');
@@ -1230,6 +1230,12 @@ a { color: var(--color-primary); }
1230
1230
  .fdy-input-group__btn:hover{background:var(--color-surface-3);color:var(--color-text);}
1231
1231
  .fdy-input-group__btn:focus-visible{outline:none;color:var(--color-text);box-shadow:inset 0 0 0 2px var(--color-primary);}
1232
1232
  .fdy-input-group__btn svg{display:block;width:1.1rem;height:1.1rem;}
1233
+ /* Leading action button (the "−" of a number stepper): the divider belongs on the other side. */
1234
+ .fdy-input-group__btn:first-child{border-left:0;border-right:var(--bw) solid var(--color-border);}
1235
+ /* At a bound, or on a disabled/read-only field, the button must look as inert as it acts — same dim
1236
+ + not-allowed as .fdy-input:disabled. :hover still matches a disabled button, so it is withdrawn. */
1237
+ .fdy-input-group__btn:disabled{opacity:.5;cursor:not-allowed;}
1238
+ .fdy-input-group__btn:disabled:hover{background:var(--color-surface-2);color:var(--color-text-muted);}
1233
1239
  /* Error state must win over the neutralizing rules above — promote to the wrapper. */
1234
1240
  .fdy-input-group:has(.fdy-input--error),.fdy-input-group:has(.fdy-combo--error),.fdy-input-group:has([aria-invalid="true"]){border-color:var(--color-danger);}
1235
1241
  .fdy-input-group:has(.fdy-input--error):focus-within,.fdy-input-group:has(.fdy-combo--error):focus-within,.fdy-input-group:has([aria-invalid="true"]):focus-within{border-color:var(--color-danger);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
@@ -1254,6 +1260,14 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1254
1260
  .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
1255
1261
  .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
1256
1262
  .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
1263
+ /* Numbers (#44): the user agent draws its own spin buttons inside the field, in OS widget colours no
1264
+ theme reaches — on a dark surface they read as a light-grey artefact glued to a themed field, and
1265
+ it is the only unthemed control on the page. Removed so .fdy-input looks the same whatever its
1266
+ type. `appearance:textfield` is the Firefox half, the pseudo-element the Blink/WebKit half; both
1267
+ are needed. This trades away the mouse-only increment — arrow keys on a focused number input still
1268
+ work, and [data-fdy-number] gives the affordance back on the kit's terms. */
1269
+ .fdy-input[type="number"]{appearance:textfield;-webkit-appearance:textfield;}
1270
+ .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
1257
1271
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
1258
1272
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
1259
1273
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
package/dist/freeday.css CHANGED
@@ -860,6 +860,12 @@ a { color: var(--color-primary); }
860
860
  .fdy-input-group__btn:hover{background:var(--color-surface-3);color:var(--color-text);}
861
861
  .fdy-input-group__btn:focus-visible{outline:none;color:var(--color-text);box-shadow:inset 0 0 0 2px var(--color-primary);}
862
862
  .fdy-input-group__btn svg{display:block;width:1.1rem;height:1.1rem;}
863
+ /* Leading action button (the "−" of a number stepper): the divider belongs on the other side. */
864
+ .fdy-input-group__btn:first-child{border-left:0;border-right:var(--bw) solid var(--color-border);}
865
+ /* At a bound, or on a disabled/read-only field, the button must look as inert as it acts — same dim
866
+ + not-allowed as .fdy-input:disabled. :hover still matches a disabled button, so it is withdrawn. */
867
+ .fdy-input-group__btn:disabled{opacity:.5;cursor:not-allowed;}
868
+ .fdy-input-group__btn:disabled:hover{background:var(--color-surface-2);color:var(--color-text-muted);}
863
869
  /* Error state must win over the neutralizing rules above — promote to the wrapper. */
864
870
  .fdy-input-group:has(.fdy-input--error),.fdy-input-group:has(.fdy-combo--error),.fdy-input-group:has([aria-invalid="true"]){border-color:var(--color-danger);}
865
871
  .fdy-input-group:has(.fdy-input--error):focus-within,.fdy-input-group:has(.fdy-combo--error):focus-within,.fdy-input-group:has([aria-invalid="true"]):focus-within{border-color:var(--color-danger);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
@@ -884,6 +890,14 @@ fieldset.fdy-field>legend{padding:0;float:none;}
884
890
  .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
885
891
  .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
886
892
  .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
893
+ /* Numbers (#44): the user agent draws its own spin buttons inside the field, in OS widget colours no
894
+ theme reaches — on a dark surface they read as a light-grey artefact glued to a themed field, and
895
+ it is the only unthemed control on the page. Removed so .fdy-input looks the same whatever its
896
+ type. `appearance:textfield` is the Firefox half, the pseudo-element the Blink/WebKit half; both
897
+ are needed. This trades away the mouse-only increment — arrow keys on a focused number input still
898
+ work, and [data-fdy-number] gives the affordance back on the kit's terms. */
899
+ .fdy-input[type="number"]{appearance:textfield;-webkit-appearance:textfield;}
900
+ .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
887
901
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
888
902
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
889
903
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}
package/dist/freeday.js CHANGED
@@ -2166,6 +2166,111 @@
2166
2166
  window.FreedayMenu = { init: initMenu, initAll: initAll };
2167
2167
  })();
2168
2168
 
2169
+ /* Freeday — number field enhancer (optional, zero-dependency).
2170
+ * Gives a native <input type="number"> its increment/decrement affordance back, on the kit's terms:
2171
+ * .fdy-input hides the user agent's own spin buttons (unthemeable OS widgets), so this puts two real
2172
+ * buttons in an .fdy-input-group instead. No new block — the group already owns the shared border,
2173
+ * :focus-within ring and error promotion.
2174
+ *
2175
+ * Markup contract:
2176
+ * <div class="fdy-input-group" data-fdy-number>
2177
+ * <button type="button" class="fdy-input-group__btn" data-fdy-number-step="-1"
2178
+ * tabindex="-1" aria-label="Kurangi">−</button>
2179
+ * <input class="fdy-input" type="number" min="0" max="10" step="1" value="1" aria-label="…">
2180
+ * <button type="button" class="fdy-input-group__btn" data-fdy-number-step="1"
2181
+ * tabindex="-1" aria-label="Tambah">+</button>
2182
+ * </div>
2183
+ *
2184
+ * No custom event: the input is the source of truth, so stepping dispatches native bubbling `input`
2185
+ * and `change`. v-model / onChange / @bind bind to the input directly, exactly as they would without
2186
+ * this enhancer.
2187
+ *
2188
+ * The buttons are deliberately OUT of the tab order (tabindex="-1"): the input is already focusable
2189
+ * and ArrowUp/ArrowDown already step it, so two extra tab stops per field would cost every keyboard
2190
+ * user something and buy nothing. They keep an aria-label, so pointer and browse-mode users still
2191
+ * get a named control.
2192
+ */
2193
+ (function () {
2194
+ 'use strict';
2195
+
2196
+ function initNumber(root) {
2197
+ if (root.dataset.fdyNumberReady === '1') return;
2198
+
2199
+ var input = root.querySelector('input[type="number"]');
2200
+ var buttons = Array.prototype.slice.call(root.querySelectorAll('[data-fdy-number-step]'));
2201
+ if (!input || !buttons.length) return;
2202
+ root.dataset.fdyNumberReady = '1';
2203
+
2204
+ /* step="any" means there is no defined increment, and stepUp()/stepDown() throw InvalidStateError
2205
+ on such a field. A stepper cannot express it, so the buttons stay visibly disabled rather than
2206
+ failing on click. Read live, not captured: `step` is one of the attributes watched below. */
2207
+ function stepless() {
2208
+ return (input.getAttribute('step') || '').toLowerCase() === 'any';
2209
+ }
2210
+
2211
+ function frozen() {
2212
+ return stepless() || input.disabled || input.readOnly;
2213
+ }
2214
+
2215
+ /* Bounds are read from the input, never recomputed: stepUp()/stepDown() already clamp to
2216
+ min/max/step, so these two only decide whether the button tells the truth about what it does. */
2217
+ function atLimit(direction) {
2218
+ var bound = direction > 0 ? input.max : input.min;
2219
+ if (bound === '' || input.value === '') return false;
2220
+ var value = Number(input.value);
2221
+ var limit = Number(bound);
2222
+ if (isNaN(value) || isNaN(limit)) return false;
2223
+ return direction > 0 ? value >= limit : value <= limit;
2224
+ }
2225
+
2226
+ function sync() {
2227
+ buttons.forEach(function (btn) {
2228
+ var direction = Number(btn.getAttribute('data-fdy-number-step')) < 0 ? -1 : 1;
2229
+ btn.disabled = frozen() || atLimit(direction);
2230
+ });
2231
+ }
2232
+
2233
+ buttons.forEach(function (btn) {
2234
+ btn.addEventListener('click', function () {
2235
+ if (frozen()) return;
2236
+ if (Number(btn.getAttribute('data-fdy-number-step')) < 0) input.stepDown();
2237
+ else input.stepUp();
2238
+ /* The value changed without the user typing, so say so the way the platform does — frameworks
2239
+ listen to these, not to a kit-specific event. */
2240
+ input.dispatchEvent(new Event('input', { bubbles: true }));
2241
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2242
+ sync();
2243
+ });
2244
+ });
2245
+
2246
+ input.addEventListener('input', sync);
2247
+ input.addEventListener('change', sync);
2248
+ /* Typing is not the only thing that moves a bound: a framework flips `disabled` or recomputes
2249
+ `max` without any event firing, and a button that still looks enabled but no longer does
2250
+ anything is the lie this state machine exists to avoid. */
2251
+ new MutationObserver(sync).observe(input, {
2252
+ attributes: true,
2253
+ attributeFilter: ['disabled', 'readonly', 'min', 'max', 'step'],
2254
+ });
2255
+ sync();
2256
+ }
2257
+
2258
+ function initAll(context) {
2259
+ var root = context || document;
2260
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON the widget. */
2261
+ if (root.matches && root.matches('[data-fdy-number]')) initNumber(root);
2262
+ Array.prototype.forEach.call(root.querySelectorAll('[data-fdy-number]'), initNumber);
2263
+ }
2264
+
2265
+ if (document.readyState === 'loading') {
2266
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
2267
+ } else {
2268
+ initAll();
2269
+ }
2270
+
2271
+ window.FreedayNumber = { init: initNumber, initAll: initAll };
2272
+ })();
2273
+
2169
2274
  /* Freeday — popover positioning helper (optional, zero-dependency).
2170
2275
  * Lifts a dropdown panel into the top layer via the native Popover API so it escapes ANY
2171
2276
  * ancestor overflow clip (a .fdy-card with overflow:hidden, an app-shell main with
@@ -3595,8 +3700,8 @@
3595
3700
  * "fdy-upload-add" {file, rejected, reason, row}
3596
3701
  * "fdy-upload-remove" {file}
3597
3702
  * `row` is the state machine over the rendered .fdy-file:
3598
- * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) / .done() /
3599
- * .fail(msg) / .el
3703
+ * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) /
3704
+ * .waiting(label) (sent, awaiting the server) / .done() / .fail(msg) / .el
3600
3705
  * Note the file list is a SIBLING of the dropzone above, so nothing dispatched on a row would ever
3601
3706
  * bubble through the zone — which is why removal fires on the zone and not on the row.
3602
3707
  */
@@ -3682,6 +3787,13 @@
3682
3787
  function dropProgress() {
3683
3788
  if (progressWrap) { progressWrap.remove(); progressWrap = null; bar = null; progressEl = null; }
3684
3789
  }
3790
+ /* Back to a measured bar. While the indeterminate modifier is on it owns the bar's width, so an
3791
+ explicit one has to be restored when it comes off: .fdy-progress__bar is a plain block div, and
3792
+ with no width at all it fills the track — a full bar, which is the opposite of what 0% means. */
3793
+ function determinate() {
3794
+ progressEl.classList.remove('fdy-progress--indeterminate');
3795
+ if (!bar.style.width) bar.style.width = '0%';
3796
+ }
3685
3797
  return {
3686
3798
  el: el,
3687
3799
  /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
@@ -3699,13 +3811,34 @@
3699
3811
  icon.innerHTML = FILE_ICON;
3700
3812
  sub.textContent = fmtSize(file.size) + ' · Mengunggah…';
3701
3813
  ensureProgress();
3814
+ determinate();
3702
3815
  },
3703
3816
  setProgress: function (pct) {
3704
3817
  ensureProgress();
3818
+ determinate();
3705
3819
  var v = Math.max(0, Math.min(100, pct));
3706
3820
  bar.style.width = v + '%';
3707
3821
  progressEl.setAttribute('aria-valuenow', String(Math.round(v)));
3708
3822
  },
3823
+ /* The bytes are gone and the server has not answered yet — extraction, scanning, transcoding.
3824
+ "Mengunggah…" turns false the moment the last byte leaves, and a determinate bar parked at
3825
+ 100% is the most convincing "hung" signal a UI can produce, so this state reports no
3826
+ percentage: the bar goes indeterminate and the label belongs to the consumer, because only
3827
+ they know what the server is doing ("Membaca PDF…", "Memindai…"). done()/fail()/ready() need
3828
+ no counterpart here — they drop the progress element outright, modifier and all. */
3829
+ waiting: function (label) {
3830
+ el.classList.remove('fdy-file--error', 'fdy-file--success');
3831
+ icon.innerHTML = FILE_ICON;
3832
+ sub.textContent = fmtSize(file.size) + ' · ' + (label || 'Menunggu server…');
3833
+ ensureProgress();
3834
+ progressEl.classList.add('fdy-progress--indeterminate');
3835
+ /* The modifier styles .fdy-progress__bar, so it must sit on the CONTAINER, and the inline
3836
+ width setProgress wrote has to go — an inline style beats any rule the modifier brings.
3837
+ aria-valuenow goes with it: a progressbar with no value is precisely what ARIA calls
3838
+ indeterminate, which is the contract COMPONENTS.md already states for this component. */
3839
+ bar.style.width = '';
3840
+ progressEl.removeAttribute('aria-valuenow');
3841
+ },
3709
3842
  done: function () {
3710
3843
  el.classList.add('fdy-file--success');
3711
3844
  el.classList.remove('fdy-file--error');
@@ -184,7 +184,7 @@ live docs also have a copy button per component.
184
184
  ```bash
185
185
  npm i @cahyo-dimas/freeday
186
186
  ```
187
- Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.25.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.27.0"` (public npm package). `dist/` is
188
188
  committed and published → no build step; `npm ci` runs without auth.
189
189
 
190
190
  ### 2. Import the CSS + enhancers **once** in your entry (`src/main.ts`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "scripts": {
72
72
  "build": "node tokens/build.mjs",
73
73
  "test": "node --test",
74
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs",
74
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs",
75
75
  "prepack": "node tokens/build.mjs",
76
76
  "version": "node tokens/build.mjs && git add dist",
77
77
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"
@@ -17,6 +17,12 @@
17
17
  .fdy-input-group__btn:hover{background:var(--color-surface-3);color:var(--color-text);}
18
18
  .fdy-input-group__btn:focus-visible{outline:none;color:var(--color-text);box-shadow:inset 0 0 0 2px var(--color-primary);}
19
19
  .fdy-input-group__btn svg{display:block;width:1.1rem;height:1.1rem;}
20
+ /* Leading action button (the "−" of a number stepper): the divider belongs on the other side. */
21
+ .fdy-input-group__btn:first-child{border-left:0;border-right:var(--bw) solid var(--color-border);}
22
+ /* At a bound, or on a disabled/read-only field, the button must look as inert as it acts — same dim
23
+ + not-allowed as .fdy-input:disabled. :hover still matches a disabled button, so it is withdrawn. */
24
+ .fdy-input-group__btn:disabled{opacity:.5;cursor:not-allowed;}
25
+ .fdy-input-group__btn:disabled:hover{background:var(--color-surface-2);color:var(--color-text-muted);}
20
26
  /* Error state must win over the neutralizing rules above — promote to the wrapper. */
21
27
  .fdy-input-group:has(.fdy-input--error),.fdy-input-group:has(.fdy-combo--error),.fdy-input-group:has([aria-invalid="true"]){border-color:var(--color-danger);}
22
28
  .fdy-input-group:has(.fdy-input--error):focus-within,.fdy-input-group:has(.fdy-combo--error):focus-within,.fdy-input-group:has([aria-invalid="true"]):focus-within{border-color:var(--color-danger);box-shadow:inset 0 1px 2px rgba(16,14,30,.04),0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
@@ -18,6 +18,14 @@ fieldset.fdy-field>legend{padding:0;float:none;}
18
18
  .fdy-input[readonly],.fdy-textarea[readonly]{background:var(--color-surface-2);border-color:var(--color-border);color:var(--color-text);cursor:default;}
19
19
  .fdy-input[readonly]:hover,.fdy-textarea[readonly]:hover{border-color:var(--color-border);}
20
20
  .fdy-input[readonly]:focus-visible,.fdy-textarea[readonly]:focus-visible{border-color:var(--color-border-strong);box-shadow:none;}
21
+ /* Numbers (#44): the user agent draws its own spin buttons inside the field, in OS widget colours no
22
+ theme reaches — on a dark surface they read as a light-grey artefact glued to a themed field, and
23
+ it is the only unthemed control on the page. Removed so .fdy-input looks the same whatever its
24
+ type. `appearance:textfield` is the Firefox half, the pseudo-element the Blink/WebKit half; both
25
+ are needed. This trades away the mouse-only increment — arrow keys on a focused number input still
26
+ work, and [data-fdy-number] gives the affordance back on the kit's terms. */
27
+ .fdy-input[type="number"]{appearance:textfield;-webkit-appearance:textfield;}
28
+ .fdy-input[type="number"]::-webkit-outer-spin-button,.fdy-input[type="number"]::-webkit-inner-spin-button{appearance:none;-webkit-appearance:none;margin:0;}
21
29
  .fdy-input--error,.fdy-input[aria-invalid="true"],.fdy-textarea[aria-invalid="true"]{border-color:var(--color-danger);}
22
30
  .fdy-input--error:focus,.fdy-input[aria-invalid="true"]:focus,.fdy-textarea[aria-invalid="true"]:focus{border-color:var(--color-danger);box-shadow:0 0 0 3px color-mix(in srgb,var(--color-danger) 26%,transparent);}
23
31
  .fdy-help{font-size:var(--text-xs);color:var(--color-text-muted);}