@cahyo-dimas/freeday 1.23.0 → 1.25.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,79 @@
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.25.0] — 2026-08-13
7
+ Improvement note #42, found while adopting 1.24.0 — the other half of the same integration.
8
+ ### Fixed
9
+ - **`fdy-upload-remove` now fires on the dropzone**, the same element as `fdy-upload-add`. It was
10
+ dispatched on the *row*, which lives in the file list — and the kit's own markup contract puts that
11
+ list as a **sibling** of the dropzone, so the event never bubbled through the zone. A consumer
12
+ following the documentation got `add` and never got `remove`: no error, right event name, right
13
+ element, and the other event on that element working. Their state kept a file the user had already
14
+ taken away.
15
+ The kit's own header comment was the source of the mistake — it said both events are emitted "on the
16
+ dropzone" while the code dispatched one of them somewhere else. It now states the target for each,
17
+ and why (a row in a sibling list can never reach the zone). `COMPONENTS.md` says it once beside the
18
+ row state table, with the removal listener in the worked example.
19
+ ### Notes on the shape of the fix
20
+ - The report proposed dispatching on **both** the row and the zone, for backward compatibility. Not
21
+ taken, and the guard proves why: when the file list is **nested inside** the dropzone — which
22
+ `data-filelist` permits — the row already bubbles through the zone, so a second dispatch makes a
23
+ plain zone listener fire **twice** per removal. Firing on both also leaves the pair asymmetric (one
24
+ `add`, two `remove`s for anyone delegating on a common ancestor), which is the same class of silent
25
+ bug this note is about. One canonical target is the honest fix.
26
+ - Dispatching on the zone also fixes the **listless** case from 1.24.0: a row that was never attached
27
+ to the document bubbles to nothing at all, so its × was previously unobservable.
28
+ - *Migration:* a consumer that worked around the old behaviour by listening on the file list must
29
+ move that listener to the dropzone. That position was never documented — it was the bug.
30
+ ### Added — guards
31
+ - `browser/upload-states.mjs` gains a second spec: removal fires on the dropzone **exactly once**, in
32
+ both layouts (list as sibling, list nested inside the zone), and delegation on a shared ancestor
33
+ sees no duplicates. Mutation-checked against *both* rejected designs — reverting to the row target
34
+ fails it, and so does the report's dispatch-on-both.
35
+
36
+ ## [1.24.0] — 2026-08-13
37
+ Improvement note #41: the upload row had no "chosen, not yet sent" state, so every consumer-driven
38
+ integration showed a transfer that had not started.
39
+ ### Fixed
40
+ - **A dropped file now rests until the consumer starts the transfer.** `handleFiles` called
41
+ `row.uploading()` unconditionally — and *before* dispatching `fdy-upload-add`, so a consumer could
42
+ not pre-empt it. Between the drop and the app's own submit button (which may be a minute, while the
43
+ user fills in the rest of the form) the row claimed to be uploading, with a progress bar that never
44
+ moved. A user watching that reasonably concludes the upload has hung and reports a bug against a
45
+ transfer that was never started. The state machine was missing its start state: `done()` claims
46
+ success, `fail()` claims an error, and `uploading()` was where it already was.
47
+ The demo path is unchanged — with `data-fdy-upload-simulate` the kit *is* performing a transfer, so
48
+ showing one stays correct. That attribute already marks the only place the old default was right,
49
+ which is why this changes the default rather than adding an opt-in: making correct integrations opt
50
+ in to correctness is backwards.
51
+ - **`fdy-upload-add` no longer depends on rendering a row.** The guard was `if (!list || !fileList)
52
+ return;`, so a dropzone with no file list lost the event that tells the app a file arrived —
53
+ "bring your own row" silently cost you the notification. Rendering and announcing are now separate:
54
+ no list means no row is attached, and the event still fires with a working `detail.row`.
55
+ Deliberately **no new attribute** for this. The reporter proposed `data-rows="off"`; `list` is used
56
+ in exactly two places, so decoupling covers the same case without growing the API surface, and no
57
+ page can be relying on "no event".
58
+ ### Added
59
+ - **`row.ready()`** — the rest state, and the way back to it (a failed attempt the user will retry).
60
+ It reuses the existing `dropProgress()`, so the bar is removed exactly as `done()`/`fail()` do it.
61
+ ### Docs
62
+ - **`COMPONENTS.md` documents the row state machine at all.** `uploading()` / `setProgress()` /
63
+ `done()` / `fail()` had **zero** mentions anywhere in the shipped docs — a consumer holding
64
+ `detail.row` had no supported way to know they existed, which is a fair part of why the old default
65
+ went unquestioned. The section now carries the state table, a worked example, the
66
+ bring-your-own-row position, and `data-fdy-upload-simulate` marked demo-only.
67
+ Two corrections to the report while transcribing it: the function is `handleFiles` (not `addFiles`)
68
+ and the attribute is `data-fdy-upload-simulate` (not `data-simulate`) — the latter matters, since
69
+ documenting the wrong name would have consumers set an attribute that does nothing.
70
+ ### Added — guards
71
+ - **`browser/upload-states.mjs`** — drops a real `File` and asserts the rest state shows no progress
72
+ bar, that `uploading()` → `setProgress()` → `done()` still chains, that the simulate path is
73
+ untouched, and that a listless dropzone still dispatches. Mutation-checked on both halves.
74
+ The fixture wraps the listless dropzone in its own container **on purpose**: with no
75
+ `data-filelist` the enhancer falls back to `parentNode.querySelector('.fdy-filelist')`, so a bare
76
+ dropzone sharing a parent with another list adopts it — the first version of this guard was
77
+ testing nothing, and the mutation run is what exposed that.
78
+
6
79
  ## [1.23.0] — 2026-08-13
7
80
  Consumption round 6 (`improvement-notes/006`). The reporter filed two of the three as **their own**
8
81
  bugs rather than the kit's — and they were right about the code, but in both cases the kit had a way
package/COMPONENTS.md CHANGED
@@ -478,9 +478,46 @@ Click/Enter opens the file dialog; drop works too. Needs `freeday-upload.js`.
478
478
  `<input type="file">`
479
479
  - List: `.fdy-filelist` (+`--grid`) of `.fdy-file` (+`--success`, `--error`) · `__icon` `__meta`
480
480
  `__name` `__sub` `__progress` `__remove`
481
- - Attributes: `data-max-size` (bytes), `data-filelist="#id"`
481
+ - Attributes: `data-max-size` (bytes), `data-filelist="#id"`, `data-fdy-upload-simulate` (demo only
482
+ — the kit fakes a transfer to `done()`; never set it in an app)
482
483
  - A11y: the dropzone is `role="button" tabindex="0"` + `aria-label`.
483
- - Wire `fdy-upload-add` to your real upload; render explicit progress/success/error state.
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
+
489
+ **The row is yours to drive.** `fdy-upload-add` carries `detail.row`, a small state machine over the
490
+ rendered `.fdy-file`. A dropped file **rests** — it shows its size and nothing else — until you say a
491
+ transfer started; the kit never claims one it is not performing.
492
+
493
+ | `detail.row` | State it renders |
494
+ |---|---|
495
+ | *(initial)* | **rest** — chosen, not sent. Size only, no progress bar. |
496
+ | `.uploading()` | in flight — adds the progress bar |
497
+ | `.setProgress(pct)` | moves the bar (0–100) |
498
+ | `.done()` | success — drops the bar, `.fdy-file--success` |
499
+ | `.fail(msg)` | error — drops the bar, `.fdy-file--error`, `msg` replaces the sub-line |
500
+ | `.ready()` | back to **rest** (e.g. after a failed attempt the user will retry) |
501
+ | `.el` | the row element |
502
+
503
+ ```js
504
+ zone.addEventListener('fdy-upload-add', (e) => {
505
+ if (e.detail.rejected) return; // the kit already rendered the reason
506
+ const { file, row } = e.detail; // row is at rest — nothing is in flight yet
507
+ submitBtn.onclick = async () => {
508
+ row.uploading();
509
+ await send(file, (pct) => row.setProgress(pct));
510
+ row.done();
511
+ };
512
+ });
513
+ zone.addEventListener('fdy-upload-remove', (e) => forget(e.detail.file)); // same element
514
+ ```
515
+
516
+ **Bring your own row:** omit the file list entirely (no `data-filelist`, and no `.fdy-filelist`
517
+ sibling) and the enhancer renders nothing while still dispatching `fdy-upload-add` — `detail.row`
518
+ still works, its element simply isn't attached. Note the fallback when `data-filelist` is absent is
519
+ `parentNode.querySelector('.fdy-filelist')`, so a bare dropzone will adopt a list that happens to
520
+ share its parent; give the dropzone its own container if you mean "no list".
484
521
 
485
522
  ## Form validation — `data-fdy-validate`
486
523
  Native Constraint Validation wired to accessible inline errors: `aria-invalid` +
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.23.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.23.0)
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)
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.23.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v1.23.0)
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)
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) / .done() /
24
+ * .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);
@@ -95,6 +109,16 @@
95
109
  }
96
110
  return {
97
111
  el: el,
112
+ /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
113
+ a transfer, and no progress bar is shown, because nothing is in flight. Without this the
114
+ state machine had no start state: done() claims success, fail() claims an error, and
115
+ uploading() is a lie until the consumer actually sends the file. */
116
+ ready: function () {
117
+ el.classList.remove('fdy-file--error', 'fdy-file--success');
118
+ icon.innerHTML = FILE_ICON;
119
+ sub.textContent = fmtSize(file.size);
120
+ dropProgress();
121
+ },
98
122
  uploading: function () {
99
123
  el.classList.remove('fdy-file--error', 'fdy-file--success');
100
124
  icon.innerHTML = FILE_ICON;
@@ -171,19 +195,30 @@
171
195
  });
172
196
  if (input) input.addEventListener('change', function () { handleFiles(input.files); input.value = ''; });
173
197
 
198
+ /* The list is optional: rendering a row and announcing the file are separate jobs. A consumer
199
+ that wants its own markup simply provides no list — it still gets `fdy-upload-add`, and
200
+ `detail.row` still works (its element is just never attached). Gating the EVENT on the list
201
+ meant "bring your own row" silently cost you the notification that a file had arrived. */
174
202
  function handleFiles(fileList) {
175
- if (!list || !fileList) return;
203
+ if (!fileList) return;
176
204
  Array.prototype.slice.call(fileList).forEach(function (file) {
177
205
  var reason = null;
178
206
  if (!accepts(file, acceptAttr)) reason = 'Tipe berkas tidak didukung.';
179
207
  else if (maxSize && file.size > maxSize) reason = 'Ukuran melebihi batas (' + fmtSize(maxSize) + ').';
180
- var row = makeRow(file);
181
- list.appendChild(row.el);
208
+ var row = makeRow(file, zone);
209
+ if (list) list.appendChild(row.el);
182
210
  if (reason) {
183
211
  row.fail(reason);
184
- } else {
212
+ } else if (simulate) {
213
+ /* The kit is driving (demo path): keep showing a transfer, because there is one. */
185
214
  row.uploading();
186
- if (simulate) simulateUpload(row);
215
+ simulateUpload(row);
216
+ } else {
217
+ /* The CONSUMER owns the transfer. Rest until it says otherwise by calling row.uploading():
218
+ a file that has only been chosen must not claim to be uploading. A progress bar that
219
+ never moves reads as a hung upload, and gets reported as a bug against a transfer that
220
+ was never started. */
221
+ row.ready();
187
222
  }
188
223
  zone.dispatchEvent(new CustomEvent('fdy-upload-add', {
189
224
  bubbles: true, detail: { file: file, rejected: !!reason, reason: reason, row: row }
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) / .done() /
3599
+ * .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);
@@ -3670,6 +3684,16 @@
3670
3684
  }
3671
3685
  return {
3672
3686
  el: el,
3687
+ /* The state a row starts in: chosen, not yet sent. The size alone — it makes no claim about
3688
+ a transfer, and no progress bar is shown, because nothing is in flight. Without this the
3689
+ state machine had no start state: done() claims success, fail() claims an error, and
3690
+ uploading() is a lie until the consumer actually sends the file. */
3691
+ ready: function () {
3692
+ el.classList.remove('fdy-file--error', 'fdy-file--success');
3693
+ icon.innerHTML = FILE_ICON;
3694
+ sub.textContent = fmtSize(file.size);
3695
+ dropProgress();
3696
+ },
3673
3697
  uploading: function () {
3674
3698
  el.classList.remove('fdy-file--error', 'fdy-file--success');
3675
3699
  icon.innerHTML = FILE_ICON;
@@ -3746,19 +3770,30 @@
3746
3770
  });
3747
3771
  if (input) input.addEventListener('change', function () { handleFiles(input.files); input.value = ''; });
3748
3772
 
3773
+ /* The list is optional: rendering a row and announcing the file are separate jobs. A consumer
3774
+ that wants its own markup simply provides no list — it still gets `fdy-upload-add`, and
3775
+ `detail.row` still works (its element is just never attached). Gating the EVENT on the list
3776
+ meant "bring your own row" silently cost you the notification that a file had arrived. */
3749
3777
  function handleFiles(fileList) {
3750
- if (!list || !fileList) return;
3778
+ if (!fileList) return;
3751
3779
  Array.prototype.slice.call(fileList).forEach(function (file) {
3752
3780
  var reason = null;
3753
3781
  if (!accepts(file, acceptAttr)) reason = 'Tipe berkas tidak didukung.';
3754
3782
  else if (maxSize && file.size > maxSize) reason = 'Ukuran melebihi batas (' + fmtSize(maxSize) + ').';
3755
- var row = makeRow(file);
3756
- list.appendChild(row.el);
3783
+ var row = makeRow(file, zone);
3784
+ if (list) list.appendChild(row.el);
3757
3785
  if (reason) {
3758
3786
  row.fail(reason);
3759
- } else {
3787
+ } else if (simulate) {
3788
+ /* The kit is driving (demo path): keep showing a transfer, because there is one. */
3760
3789
  row.uploading();
3761
- if (simulate) simulateUpload(row);
3790
+ simulateUpload(row);
3791
+ } else {
3792
+ /* The CONSUMER owns the transfer. Rest until it says otherwise by calling row.uploading():
3793
+ a file that has only been chosen must not claim to be uploading. A progress bar that
3794
+ never moves reads as a hung upload, and gets reported as a bug against a transfer that
3795
+ was never started. */
3796
+ row.ready();
3762
3797
  }
3763
3798
  zone.dispatchEvent(new CustomEvent('fdy-upload-add', {
3764
3799
  bubbles: true, detail: { file: file, rejected: !!reason, reason: reason, row: row }
@@ -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.23.0"` (public npm package). `dist/` is
187
+ Lands in `package.json` as `"@cahyo-dimas/freeday": "^1.25.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.23.0",
3
+ "version": "1.25.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",
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",
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"