@cahyo-dimas/freeday 1.24.0 → 1.26.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,76 @@
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.26.0] — 2026-08-14
7
+ Improvement note #43, found while chasing a "the upload is stuck" report on a 626 KB PDF: the
8
+ transfer took about a second, the server then spent nearly a minute reading the document.
9
+ ### Added
10
+ - **`row.waiting(label)`** — the state between `setProgress` and `done`: the bytes are gone, the
11
+ server has not answered. The row's only long-running state was named after the *transfer*, so it
12
+ kept saying "Mengunggah…" for the whole minute of server-side work — and `setProgress(100)` made it
13
+ worse, because a full bar that then sits still is the most convincing "hung" signal a UI can
14
+ produce. There was no way out within the row's API: `done()` claims success, `fail()` claims an
15
+ error, `ready()` walks backwards. Consumers were rendering a second status line outside the row and
16
+ leaving the row to contradict it.
17
+ The bar goes **indeterminate** and drops `aria-valuenow` — a progressbar with no value is exactly
18
+ what ARIA calls indeterminate, which is the contract `COMPONENTS.md` already stated for
19
+ `.fdy-progress`. The label is the consumer's, because only they know what the server is doing
20
+ (`Membaca PDF…`, `Memindai…`); it falls back to `Menunggu server…`.
21
+ - `COMPONENTS.md` gains the state in the row table plus the sentence that would have saved the
22
+ round-trip: **if your request outlives the transfer, drive `waiting()`**.
23
+ ### Notes on the shape of the fix
24
+ - The report's patch would have shipped the symptom it set out to remove. It put the modifier on the
25
+ **bar** (`.fdy-progress--indeterminate` styles `.fdy-progress__bar`, so it belongs on the
26
+ container — on the bar it matches nothing) and then set an inline `width:100%`, which beats the
27
+ modifier's own width anyway. Both mistakes render a full, frozen bar. The note also hedged that
28
+ `.fdy-progress--indeterminate` might not exist; it has all along.
29
+ - **Leaving the state needs more care than entering it.** `.fdy-progress__bar` is a plain block div:
30
+ with no width it fills its track. So `uploading()`/`setProgress()` restore an explicit width when
31
+ they clear the modifier, or a retried row paints a *full* bar while meaning 0%. `done()`, `fail()`
32
+ and `ready()` need no counterpart — they drop the progress element outright, modifier and all
33
+ (contrary to the note, which expected a line in each).
34
+ - **No `.fdy-file--waiting` class.** `uploading` has none either; only `--success`/`--error` do,
35
+ because they carry colour. A documented class with no rule is markup that looks like it does
36
+ something.
37
+ - Under `prefers-reduced-motion: reduce` the kit's indeterminate treatment is a dimmed **full** bar
38
+ (no animation left to carry the meaning) — pre-existing behaviour for every indeterminate progress,
39
+ not introduced here. For those users the honest signal is the label, not the bar.
40
+ ### Added — guards
41
+ - `browser/upload-states.mjs` gains a third spec, measuring what the **engine renders** rather than
42
+ what the source declares: both ways to get this wrong are invisible in a code read. Mutation-checked
43
+ against five defects, including the report's own two — modifier-on-bar, inline `width:100%`, no
44
+ width restored on return, `aria-valuenow` kept, and label ignored.
45
+
46
+ ## [1.25.0] — 2026-08-13
47
+ Improvement note #42, found while adopting 1.24.0 — the other half of the same integration.
48
+ ### Fixed
49
+ - **`fdy-upload-remove` now fires on the dropzone**, the same element as `fdy-upload-add`. It was
50
+ dispatched on the *row*, which lives in the file list — and the kit's own markup contract puts that
51
+ list as a **sibling** of the dropzone, so the event never bubbled through the zone. A consumer
52
+ following the documentation got `add` and never got `remove`: no error, right event name, right
53
+ element, and the other event on that element working. Their state kept a file the user had already
54
+ taken away.
55
+ The kit's own header comment was the source of the mistake — it said both events are emitted "on the
56
+ dropzone" while the code dispatched one of them somewhere else. It now states the target for each,
57
+ and why (a row in a sibling list can never reach the zone). `COMPONENTS.md` says it once beside the
58
+ row state table, with the removal listener in the worked example.
59
+ ### Notes on the shape of the fix
60
+ - The report proposed dispatching on **both** the row and the zone, for backward compatibility. Not
61
+ taken, and the guard proves why: when the file list is **nested inside** the dropzone — which
62
+ `data-filelist` permits — the row already bubbles through the zone, so a second dispatch makes a
63
+ plain zone listener fire **twice** per removal. Firing on both also leaves the pair asymmetric (one
64
+ `add`, two `remove`s for anyone delegating on a common ancestor), which is the same class of silent
65
+ bug this note is about. One canonical target is the honest fix.
66
+ - Dispatching on the zone also fixes the **listless** case from 1.24.0: a row that was never attached
67
+ to the document bubbles to nothing at all, so its × was previously unobservable.
68
+ - *Migration:* a consumer that worked around the old behaviour by listening on the file list must
69
+ move that listener to the dropzone. That position was never documented — it was the bug.
70
+ ### Added — guards
71
+ - `browser/upload-states.mjs` gains a second spec: removal fires on the dropzone **exactly once**, in
72
+ both layouts (list as sibling, list nested inside the zone), and delegation on a shared ancestor
73
+ sees no duplicates. Mutation-checked against *both* rejected designs — reverting to the row target
74
+ fails it, and so does the report's dispatch-on-both.
75
+
6
76
  ## [1.24.0] — 2026-08-13
7
77
  Improvement note #41: the upload row had no "chosen, not yet sent" state, so every consumer-driven
8
78
  integration showed a transfer that had not started.
package/COMPONENTS.md CHANGED
@@ -482,6 +482,10 @@ Click/Enter opens the file dialog; drop works too. Needs `freeday-upload.js`.
482
482
  — the kit fakes a transfer to `done()`; never set it in an app)
483
483
  - A11y: the dropzone is `role="button" tabindex="0"` + `aria-label`.
484
484
 
485
+ **Both events fire on the dropzone** — `fdy-upload-add` *and* `fdy-upload-remove`. One listener, one
486
+ element. (The file list may be anywhere in the document; nothing is dispatched on the row, because a
487
+ row in a sibling list would never bubble through the zone.)
488
+
485
489
  **The row is yours to drive.** `fdy-upload-add` carries `detail.row`, a small state machine over the
486
490
  rendered `.fdy-file`. A dropped file **rests** — it shows its size and nothing else — until you say a
487
491
  transfer started; the kit never claims one it is not performing.
@@ -491,11 +495,17 @@ transfer started; the kit never claims one it is not performing.
491
495
  | *(initial)* | **rest** — chosen, not sent. Size only, no progress bar. |
492
496
  | `.uploading()` | in flight — adds the progress bar |
493
497
  | `.setProgress(pct)` | moves the bar (0–100) |
498
+ | `.waiting(label)` | **sent, waiting on the server** — indeterminate bar, no `aria-valuenow`; `label` is yours (default *Menunggu server…*) |
494
499
  | `.done()` | success — drops the bar, `.fdy-file--success` |
495
500
  | `.fail(msg)` | error — drops the bar, `.fdy-file--error`, `msg` replaces the sub-line |
496
501
  | `.ready()` | back to **rest** (e.g. after a failed attempt the user will retry) |
497
502
  | `.el` | the row element |
498
503
 
504
+ **If your request outlives the transfer, drive `waiting()`.** Server-side work after the last byte —
505
+ OCR, extraction, virus scanning, transcoding — is not uploading, and `setProgress(100)` left standing
506
+ is read as a hang. `waiting()` is the state for it: the label says what the server is doing, and the
507
+ bar stops claiming a percentage it no longer has.
508
+
499
509
  ```js
500
510
  zone.addEventListener('fdy-upload-add', (e) => {
501
511
  if (e.detail.rejected) return; // the kit already rendered the reason
@@ -503,9 +513,12 @@ zone.addEventListener('fdy-upload-add', (e) => {
503
513
  submitBtn.onclick = async () => {
504
514
  row.uploading();
505
515
  await send(file, (pct) => row.setProgress(pct));
516
+ row.waiting('Membaca dokumen…'); // bytes gone, server still working
517
+ await serverFinished();
506
518
  row.done();
507
519
  };
508
520
  });
521
+ zone.addEventListener('fdy-upload-remove', (e) => forget(e.detail.file)); // same element
509
522
  ```
510
523
 
511
524
  **Bring your own row:** omit the file list entirely (no `data-filelist`, and no `.fdy-filelist`
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.24.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.24.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.26.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.26.0)
9
9
 
10
10
  UI KIT yang token-driven & framework-agnostic — satu sumber kebenaran untuk warna, tipografi,
11
11
  spasi, dan komponen. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs — the UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v1.24.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.24.0)
8
+ [![Release](https://img.shields.io/badge/release-v1.26.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.26.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`.
@@ -15,8 +15,15 @@
15
15
  * </div>
16
16
  * <div class="fdy-filelist" id="dz-list"></div>
17
17
  *
18
- * Emits bubbling CustomEvents on the dropzone: "fdy-upload-add" {file, rejected, reason, row}
19
- * (row exposes .uploading()/.setProgress(pct)/.done()/.fail(msg)) and "fdy-upload-remove" {file}.
18
+ * Emits bubbling CustomEvents — BOTH on the dropzone element, which is the one target a consumer
19
+ * needs to listen on:
20
+ * "fdy-upload-add" {file, rejected, reason, row}
21
+ * "fdy-upload-remove" {file}
22
+ * `row` is the state machine over the rendered .fdy-file:
23
+ * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) /
24
+ * .waiting(label) (sent, awaiting the server) / .done() / .fail(msg) / .el
25
+ * Note the file list is a SIBLING of the dropzone above, so nothing dispatched on a row would ever
26
+ * bubble through the zone — which is why removal fires on the zone and not on the row.
20
27
  */
21
28
  (function () {
22
29
  'use strict';
@@ -43,7 +50,7 @@
43
50
  });
44
51
  }
45
52
 
46
- function makeRow(file) {
53
+ function makeRow(file, zone) {
47
54
  var el = document.createElement('div');
48
55
  el.className = 'fdy-file';
49
56
  var icon = document.createElement('span');
@@ -65,7 +72,14 @@
65
72
  remove.setAttribute('aria-label', 'Hapus ' + file.name);
66
73
  remove.innerHTML = '&times;';
67
74
  remove.addEventListener('click', function () {
68
- el.dispatchEvent(new CustomEvent('fdy-upload-remove', { bubbles: true, detail: { file: file } }));
75
+ /* Dispatched on the ZONE, not on the row the same target as fdy-upload-add, so one listener
76
+ on the dropzone gets both. The row lives in the file list, which the kit's own markup
77
+ contract puts as a SIBLING of the dropzone, so a row event never bubbles through the zone:
78
+ a consumer following the docs saw `add` arrive and `remove` never fire, with no error.
79
+ Firing on both would look safer, but it makes the pair asymmetric — one `add` and, for
80
+ anyone delegating on a common ancestor, two `remove`s — which is its own silent bug. It
81
+ also fixes the listless case: a row that was never attached bubbles to nothing at all. */
82
+ zone.dispatchEvent(new CustomEvent('fdy-upload-remove', { bubbles: true, detail: { file: file } }));
69
83
  el.remove();
70
84
  });
71
85
  el.appendChild(icon);
@@ -93,6 +107,13 @@
93
107
  function dropProgress() {
94
108
  if (progressWrap) { progressWrap.remove(); progressWrap = null; bar = null; progressEl = null; }
95
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
+ }
96
117
  return {
97
118
  el: el,
98
119
  /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
@@ -110,13 +131,34 @@
110
131
  icon.innerHTML = FILE_ICON;
111
132
  sub.textContent = fmtSize(file.size) + ' · Mengunggah…';
112
133
  ensureProgress();
134
+ determinate();
113
135
  },
114
136
  setProgress: function (pct) {
115
137
  ensureProgress();
138
+ determinate();
116
139
  var v = Math.max(0, Math.min(100, pct));
117
140
  bar.style.width = v + '%';
118
141
  progressEl.setAttribute('aria-valuenow', String(Math.round(v)));
119
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
+ },
120
162
  done: function () {
121
163
  el.classList.add('fdy-file--success');
122
164
  el.classList.remove('fdy-file--error');
@@ -191,7 +233,7 @@
191
233
  var reason = null;
192
234
  if (!accepts(file, acceptAttr)) reason = 'Tipe berkas tidak didukung.';
193
235
  else if (maxSize && file.size > maxSize) reason = 'Ukuran melebihi batas (' + fmtSize(maxSize) + ').';
194
- var row = makeRow(file);
236
+ var row = makeRow(file, zone);
195
237
  if (list) list.appendChild(row.el);
196
238
  if (reason) {
197
239
  row.fail(reason);
package/dist/freeday.js CHANGED
@@ -3590,8 +3590,15 @@
3590
3590
  * </div>
3591
3591
  * <div class="fdy-filelist" id="dz-list"></div>
3592
3592
  *
3593
- * Emits bubbling CustomEvents on the dropzone: "fdy-upload-add" {file, rejected, reason, row}
3594
- * (row exposes .uploading()/.setProgress(pct)/.done()/.fail(msg)) and "fdy-upload-remove" {file}.
3593
+ * Emits bubbling CustomEvents — BOTH on the dropzone element, which is the one target a consumer
3594
+ * needs to listen on:
3595
+ * "fdy-upload-add" {file, rejected, reason, row}
3596
+ * "fdy-upload-remove" {file}
3597
+ * `row` is the state machine over the rendered .fdy-file:
3598
+ * .ready() (rest — where a dropped file starts) / .uploading() / .setProgress(pct) /
3599
+ * .waiting(label) (sent, awaiting the server) / .done() / .fail(msg) / .el
3600
+ * Note the file list is a SIBLING of the dropzone above, so nothing dispatched on a row would ever
3601
+ * bubble through the zone — which is why removal fires on the zone and not on the row.
3595
3602
  */
3596
3603
  (function () {
3597
3604
  'use strict';
@@ -3618,7 +3625,7 @@
3618
3625
  });
3619
3626
  }
3620
3627
 
3621
- function makeRow(file) {
3628
+ function makeRow(file, zone) {
3622
3629
  var el = document.createElement('div');
3623
3630
  el.className = 'fdy-file';
3624
3631
  var icon = document.createElement('span');
@@ -3640,7 +3647,14 @@
3640
3647
  remove.setAttribute('aria-label', 'Hapus ' + file.name);
3641
3648
  remove.innerHTML = '&times;';
3642
3649
  remove.addEventListener('click', function () {
3643
- el.dispatchEvent(new CustomEvent('fdy-upload-remove', { bubbles: true, detail: { file: file } }));
3650
+ /* Dispatched on the ZONE, not on the row the same target as fdy-upload-add, so one listener
3651
+ on the dropzone gets both. The row lives in the file list, which the kit's own markup
3652
+ contract puts as a SIBLING of the dropzone, so a row event never bubbles through the zone:
3653
+ a consumer following the docs saw `add` arrive and `remove` never fire, with no error.
3654
+ Firing on both would look safer, but it makes the pair asymmetric — one `add` and, for
3655
+ anyone delegating on a common ancestor, two `remove`s — which is its own silent bug. It
3656
+ also fixes the listless case: a row that was never attached bubbles to nothing at all. */
3657
+ zone.dispatchEvent(new CustomEvent('fdy-upload-remove', { bubbles: true, detail: { file: file } }));
3644
3658
  el.remove();
3645
3659
  });
3646
3660
  el.appendChild(icon);
@@ -3668,6 +3682,13 @@
3668
3682
  function dropProgress() {
3669
3683
  if (progressWrap) { progressWrap.remove(); progressWrap = null; bar = null; progressEl = null; }
3670
3684
  }
3685
+ /* Back to a measured bar. While the indeterminate modifier is on it owns the bar's width, so an
3686
+ explicit one has to be restored when it comes off: .fdy-progress__bar is a plain block div, and
3687
+ with no width at all it fills the track — a full bar, which is the opposite of what 0% means. */
3688
+ function determinate() {
3689
+ progressEl.classList.remove('fdy-progress--indeterminate');
3690
+ if (!bar.style.width) bar.style.width = '0%';
3691
+ }
3671
3692
  return {
3672
3693
  el: el,
3673
3694
  /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
@@ -3685,13 +3706,34 @@
3685
3706
  icon.innerHTML = FILE_ICON;
3686
3707
  sub.textContent = fmtSize(file.size) + ' · Mengunggah…';
3687
3708
  ensureProgress();
3709
+ determinate();
3688
3710
  },
3689
3711
  setProgress: function (pct) {
3690
3712
  ensureProgress();
3713
+ determinate();
3691
3714
  var v = Math.max(0, Math.min(100, pct));
3692
3715
  bar.style.width = v + '%';
3693
3716
  progressEl.setAttribute('aria-valuenow', String(Math.round(v)));
3694
3717
  },
3718
+ /* The bytes are gone and the server has not answered yet — extraction, scanning, transcoding.
3719
+ "Mengunggah…" turns false the moment the last byte leaves, and a determinate bar parked at
3720
+ 100% is the most convincing "hung" signal a UI can produce, so this state reports no
3721
+ percentage: the bar goes indeterminate and the label belongs to the consumer, because only
3722
+ they know what the server is doing ("Membaca PDF…", "Memindai…"). done()/fail()/ready() need
3723
+ no counterpart here — they drop the progress element outright, modifier and all. */
3724
+ waiting: function (label) {
3725
+ el.classList.remove('fdy-file--error', 'fdy-file--success');
3726
+ icon.innerHTML = FILE_ICON;
3727
+ sub.textContent = fmtSize(file.size) + ' · ' + (label || 'Menunggu server…');
3728
+ ensureProgress();
3729
+ progressEl.classList.add('fdy-progress--indeterminate');
3730
+ /* The modifier styles .fdy-progress__bar, so it must sit on the CONTAINER, and the inline
3731
+ width setProgress wrote has to go — an inline style beats any rule the modifier brings.
3732
+ aria-valuenow goes with it: a progressbar with no value is precisely what ARIA calls
3733
+ indeterminate, which is the contract COMPONENTS.md already states for this component. */
3734
+ bar.style.width = '';
3735
+ progressEl.removeAttribute('aria-valuenow');
3736
+ },
3695
3737
  done: function () {
3696
3738
  el.classList.add('fdy-file--success');
3697
3739
  el.classList.remove('fdy-file--error');
@@ -3766,7 +3808,7 @@
3766
3808
  var reason = null;
3767
3809
  if (!accepts(file, acceptAttr)) reason = 'Tipe berkas tidak didukung.';
3768
3810
  else if (maxSize && file.size > maxSize) reason = 'Ukuran melebihi batas (' + fmtSize(maxSize) + ').';
3769
- var row = makeRow(file);
3811
+ var row = makeRow(file, zone);
3770
3812
  if (list) list.appendChild(row.el);
3771
3813
  if (reason) {
3772
3814
  row.fail(reason);
@@ -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.24.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.26.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.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",