@cahyo-dimas/freeday 2.1.0 → 3.0.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/COMPONENTS.md +191 -18
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/USAGE.md +1 -1
  6. package/adapters/blazor/FdyAppShell.razor +1 -1
  7. package/adapters/blazor/FdyAppShell.razor.cs +35 -0
  8. package/adapters/blazor/FdyAutocomplete.razor +4 -1
  9. package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
  10. package/adapters/blazor/FdyCascade.razor +6 -1
  11. package/adapters/blazor/FdyCascade.razor.cs +24 -0
  12. package/adapters/blazor/FdyCombo.razor +1 -0
  13. package/adapters/blazor/FdyCombo.razor.cs +13 -0
  14. package/adapters/blazor/FdyDatepicker.razor +15 -1
  15. package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
  16. package/adapters/blazor/FdyTable.razor +44 -4
  17. package/adapters/blazor/FdyTable.razor.cs +110 -0
  18. package/adapters/blazor/freeday-blazor.js +8 -0
  19. package/adapters/react/components/FdyAppShell.tsx +52 -11
  20. package/adapters/react/components/FdyDrawer.tsx +3 -1
  21. package/adapters/react/components/FdyModal.tsx +3 -1
  22. package/adapters/react/components/FdyTable.tsx +123 -2
  23. package/adapters/vue/components/FdyAppShell.vue +41 -11
  24. package/adapters/vue/components/FdyDrawer.vue +4 -1
  25. package/adapters/vue/components/FdyModal.vue +4 -1
  26. package/adapters/vue/components/FdyTable.vue +125 -4
  27. package/dist/freeday-app-shell.js +23 -5
  28. package/dist/freeday-autocomplete.js +17 -1
  29. package/dist/freeday-busy.js +168 -0
  30. package/dist/freeday-cascade.js +53 -3
  31. package/dist/freeday-chart.js +33 -3
  32. package/dist/freeday-datepicker.js +109 -17
  33. package/dist/freeday-select.js +18 -1
  34. package/dist/freeday-stepper.js +54 -4
  35. package/dist/freeday-table.js +4 -2
  36. package/dist/freeday-timepicker.js +19 -1
  37. package/dist/freeday.bundle.css +616 -39
  38. package/dist/freeday.css +93 -11
  39. package/dist/freeday.js +499 -37
  40. package/dist/freeday.tokens.css +523 -28
  41. package/docs/agent-onboarding.md +4 -0
  42. package/docs/getting-started.md +1 -1
  43. package/package.json +4 -3
  44. package/src/components/app-shell.css +29 -5
  45. package/src/components/appbar.css +2 -2
  46. package/src/components/busy.css +33 -0
  47. package/src/components/card.css +1 -1
  48. package/src/components/drawer.css +1 -1
  49. package/src/components/menu.css +1 -1
  50. package/src/components/modal.css +1 -1
  51. package/src/components/stepper.css +11 -0
  52. package/src/components/table.css +13 -0
  53. package/tokens/tokens.json +54 -10
package/dist/freeday.js CHANGED
@@ -62,7 +62,13 @@
62
62
 
63
63
  function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
64
64
  function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
65
- function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
65
+ /* Whether the nav FLOATS. Two ways to be true, and only one of them is the viewport: below the
66
+ breakpoint it is off-canvas by definition, and above it `--nav-overlay` says the app chose to
67
+ float a nav that could have been a column. Everything downstream — which class means visible,
68
+ what the toggle does, whether the content goes inert — asks this instead of the media query,
69
+ so overlay mode reuses the drawer's whole code path rather than growing a second one. */
70
+ function isOverlayMode() { return !mqWide.matches || app.classList.contains('fdy-app--nav-overlay'); }
71
+ function navVisible() { return isOverlayMode() ? isOverlayOpen() : !isCollapsed(); }
66
72
 
67
73
  /* aria-expanded answers "is the nav showing?" in BOTH modes, the two state classes are the
68
74
  kit's business, not the reader's. */
@@ -70,7 +76,7 @@
70
76
  var visible = navVisible();
71
77
  toggle.setAttribute('aria-expanded', String(visible));
72
78
  setInert(sidebar, !visible);
73
- setInert(content, !mqWide.matches && visible);
79
+ setInert(content, isOverlayMode() && visible);
74
80
  /* Announce only real changes. The first sync() runs at init to describe the state the markup
75
81
  arrived in, which is not something a host asked for and must not look like one. */
76
82
  if (lastVisible !== null && visible !== lastVisible) {
@@ -108,7 +114,7 @@
108
114
  }
109
115
 
110
116
  toggle.addEventListener('click', function () {
111
- if (mqWide.matches) {
117
+ if (!isOverlayMode()) {
112
118
  app.classList.toggle('fdy-app--nav-collapsed');
113
119
  sync();
114
120
  } else if (isOverlayOpen()) {
@@ -156,7 +162,10 @@
156
162
  content inert forever: the panel becomes a static column again, and the page it is covering
157
163
  can no longer be clicked or read. */
158
164
  mqWide.addEventListener('change', function () {
159
- if (mqWide.matches && isOverlayOpen()) close(false);
165
+ /* Only when the panel stops floating. In overlay MODE it floats at every width, so widening
166
+ must leave an open panel exactly as it is — closing it there would be the shell overruling
167
+ a reader who never asked for anything. */
168
+ if (mqWide.matches && !isOverlayMode() && isOverlayOpen()) close(false);
160
169
  else sync();
161
170
  });
162
171
 
@@ -166,9 +175,14 @@
166
175
  own state needs to drive this without reaching for the class names the kit reserves. */
167
176
  app._fdyAppShell = {
168
177
  isVisible: navVisible,
178
+ /* Re-read the DOM and reconcile `inert` + `aria-expanded`. Needed when something OUTSIDE the
179
+ enhancer changes what the state classes mean — switching `--nav-overlay` on or off does
180
+ exactly that, because it moves the answer to "is the nav visible?" from `--nav-collapsed`
181
+ to `--nav-open`. Without this a mode switch leaves a visible sidebar marked inert. */
182
+ refresh: sync,
169
183
  setVisible: function (visible) {
170
184
  if (visible === navVisible()) return;
171
- if (mqWide.matches) {
185
+ if (!isOverlayMode()) {
172
186
  app.classList.toggle('fdy-app--nav-collapsed', !visible);
173
187
  sync();
174
188
  } else if (visible) {
@@ -205,6 +219,10 @@
205
219
  isVisible: function (root) {
206
220
  return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
207
221
  },
222
+ /* Call after changing `--nav-overlay` from outside; see the note on the handle above. */
223
+ refresh: function (root) {
224
+ if (root && root._fdyAppShell) root._fdyAppShell.refresh();
225
+ },
208
226
  };
209
227
  })();
210
228
 
@@ -250,6 +268,9 @@
250
268
  var _pop = null;
251
269
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(listbox, input); return _pop; }
252
270
  function open() {
271
+ /* The input carries these natively; a disabled one fires nothing, but a READONLY one still
272
+ takes focus and clicks, and the list used to open over a field nobody can edit. */
273
+ if (input.readOnly || input.disabled) return;
253
274
  if (!listbox.hidden) return;
254
275
  var p = popCtl(); if (p) p.show(); else listbox.hidden = false;
255
276
  input.setAttribute('aria-expanded', 'true');
@@ -344,7 +365,20 @@
344
365
  initAll();
345
366
  }
346
367
 
347
- window.FreedayAutocomplete = { init: initAutocomplete, initAll: initAll };
368
+ /* Same contract as the combo's: the states live on the input natively, and a host that stops
369
+ re-rendering after hydration needs a way to change them afterwards. */
370
+ function setState(root, state) {
371
+ var input = root ? root.querySelector('input') : null;
372
+ if (!input || !state) return;
373
+ if (state.disabled != null) input.disabled = !!state.disabled;
374
+ if (state.readonly != null) input.readOnly = !!state.readonly;
375
+ if (state.invalid != null) {
376
+ if (state.invalid) input.setAttribute('aria-invalid', 'true');
377
+ else input.removeAttribute('aria-invalid');
378
+ }
379
+ }
380
+
381
+ window.FreedayAutocomplete = { init: initAutocomplete, initAll: initAll, setState: setState };
348
382
  })();
349
383
 
350
384
  /* Freeday, breakpoint provider (optional, zero-dependency).
@@ -399,6 +433,175 @@
399
433
  };
400
434
  })();
401
435
 
436
+ /* Freeday, busy overlay (optional, zero-dependency).
437
+ *
438
+ * Freeday.busy({ caption, delay, mark }) block the screen while an operation runs
439
+ * Freeday.idle() release it
440
+ *
441
+ * Imperative on purpose, like Freeday.toast(): a component API invites two instances, and two
442
+ * blocking overlays with two captions is the failure this exists to prevent. A second busy() while
443
+ * one is up REPLACES the caption rather than stacking.
444
+ *
445
+ * caption what is happening. Announced politely, so make it a sentence a reader would want read
446
+ * out, not a spinner label. Omitted, it falls back to the kit default, which a page
447
+ * overrides once with `data-fdy-text-caption` on <html>.
448
+ * delay ms to wait before it appears (default 120; 0 shows immediately). An operation that
449
+ * finishes in 80ms should never flash a scrim — that reads as a glitch, not as progress.
450
+ * mark an Element to use instead of the default spinner. Element only, never an HTML string:
451
+ * a string here would be an injection point in every app that passed user text through.
452
+ *
453
+ * Not a dialog. Interaction is removed with `inert` on everything else, so there is nothing to trap
454
+ * focus against and nothing to dismiss. Focus is parked on the panel and given back on idle(),
455
+ * because the element it was on is inert by then and the browser would otherwise drop it to <body>.
456
+ */
457
+ (function () {
458
+ 'use strict';
459
+
460
+ var DEFAULT_DELAY = 120;
461
+
462
+ var TEXT = {
463
+ caption: 'Working…'
464
+ };
465
+ /* The overlay has no root of its own to carry an override — it is created, not hydrated — so the
466
+ lookup goes to <html>, the one element every page has before this runs. Kebab-cased for the
467
+ same reason as everywhere else: HTML lowercases attribute names, so a camelCase key could only
468
+ ever be written run-together and the override would fail silently. */
469
+ function textOf(key) {
470
+ var root = document.documentElement;
471
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
472
+ var custom = kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
473
+ return custom != null && custom !== '' ? custom : TEXT[key];
474
+ }
475
+
476
+ var node = null;
477
+ var showTimer = null;
478
+ var inerted = [];
479
+ var returnFocusTo = null;
480
+
481
+ function build() {
482
+ var el = document.createElement('div');
483
+ el.className = 'fdy-busy';
484
+ el.setAttribute('popover', 'manual');
485
+ el.setAttribute('aria-busy', 'true');
486
+ el.tabIndex = -1;
487
+
488
+ var panel = document.createElement('div');
489
+ panel.className = 'fdy-busy__panel';
490
+
491
+ var mark = document.createElement('div');
492
+ mark.className = 'fdy-busy__mark';
493
+ // aria-hidden: the caption below is the message. A second announcement from the spinner's own
494
+ // role="status" would say "busy" twice and name nothing.
495
+ mark.setAttribute('aria-hidden', 'true');
496
+ mark.appendChild(defaultMark());
497
+
498
+ var caption = document.createElement('p');
499
+ caption.className = 'fdy-busy__caption';
500
+ // role="status" rather than a dialog role: this reports a state, it does not ask a question.
501
+ caption.setAttribute('role', 'status');
502
+
503
+ panel.appendChild(mark);
504
+ panel.appendChild(caption);
505
+ el.appendChild(panel);
506
+ return el;
507
+ }
508
+
509
+ function defaultMark() {
510
+ var spinner = document.createElement('span');
511
+ spinner.className = 'fdy-spinner fdy-spinner--lg';
512
+ return spinner;
513
+ }
514
+
515
+ /** inert everything else, remembering ONLY what we set so an app's own inert is never cleared. */
516
+ function block(on) {
517
+ var i;
518
+ if (on) {
519
+ var kids = document.body.children;
520
+ for (i = 0; i < kids.length; i++) {
521
+ var child = kids[i];
522
+ if (child === node || child.hasAttribute('inert')) continue;
523
+ child.setAttribute('inert', '');
524
+ inerted.push(child);
525
+ }
526
+ return;
527
+ }
528
+ for (i = 0; i < inerted.length; i++) inerted[i].removeAttribute('inert');
529
+ inerted = [];
530
+ }
531
+
532
+ function isOpen() {
533
+ return node !== null && node.classList.contains('is-open');
534
+ }
535
+
536
+ function setCaption(text) {
537
+ node.querySelector('.fdy-busy__caption').textContent = text;
538
+ }
539
+
540
+ function setMark(el) {
541
+ var slot = node.querySelector('.fdy-busy__mark');
542
+ while (slot.firstChild) slot.removeChild(slot.firstChild);
543
+ slot.appendChild(el instanceof Element ? el : defaultMark());
544
+ }
545
+
546
+ function show(opts) {
547
+ showTimer = null;
548
+ setCaption(opts.caption == null ? textOf('caption') : String(opts.caption));
549
+ if (opts.mark !== undefined) setMark(opts.mark);
550
+
551
+ returnFocusTo = document.activeElement;
552
+ document.body.appendChild(node);
553
+ node.classList.add('is-open');
554
+ // Top layer, so it also covers an open <dialog>. Where the API is missing the z-index in
555
+ // busy.css is the fallback; it cannot clear a modal, and that is stated in COMPONENTS.md.
556
+ if (typeof node.showPopover === 'function') {
557
+ try { node.showPopover(); } catch (e) { /* already open, or not connected yet */ }
558
+ }
559
+ block(true);
560
+ node.focus();
561
+ }
562
+
563
+ function busy(options) {
564
+ var opts = options || {};
565
+ if (node === null) node = build();
566
+
567
+ // Already up: this is a second owner talking. Update what it says, do not stack.
568
+ if (isOpen()) {
569
+ if (opts.caption != null) setCaption(String(opts.caption));
570
+ if (opts.mark !== undefined) setMark(opts.mark);
571
+ return node;
572
+ }
573
+
574
+ var delay = opts.delay == null ? DEFAULT_DELAY : Number(opts.delay);
575
+ if (showTimer !== null) clearTimeout(showTimer);
576
+ if (delay > 0) showTimer = setTimeout(function () { show(opts); }, delay);
577
+ else show(opts);
578
+ return node;
579
+ }
580
+
581
+ function idle() {
582
+ // Cancels a pending show too: an operation that beat the delay must leave nothing behind.
583
+ if (showTimer !== null) { clearTimeout(showTimer); showTimer = null; }
584
+ if (node === null || !isOpen()) return;
585
+
586
+ block(false);
587
+ if (typeof node.hidePopover === 'function') {
588
+ try { node.hidePopover(); } catch (e) { /* was never in the top layer */ }
589
+ }
590
+ node.classList.remove('is-open');
591
+ if (node.parentNode !== null) node.parentNode.removeChild(node);
592
+
593
+ // Give focus back to whatever had it, now that its ancestor is no longer inert.
594
+ if (returnFocusTo !== null && typeof returnFocusTo.focus === 'function' && returnFocusTo.isConnected) {
595
+ returnFocusTo.focus();
596
+ }
597
+ returnFocusTo = null;
598
+ }
599
+
600
+ window.Freeday = window.Freeday || {};
601
+ window.Freeday.busy = busy;
602
+ window.Freeday.idle = idle;
603
+ })();
604
+
402
605
  /* Freeday, carousel enhancer (optional, zero-dependency).
403
606
  * Scroll-snap slider with prev/next arrows, generated dot indicators, keyboard (←/→),
404
607
  * and optional autoplay (data-fdy-autoplay="4000", pauses on hover/focus). Auto-inits
@@ -603,6 +806,8 @@
603
806
  * forking this file. Keeping them in ONE table is also what lets a guard prove none is
604
807
  * hard-coded further down. */
605
808
  var TEXT = {
809
+ label: 'Select',
810
+ placeholder: 'Select…',
606
811
  back: 'Back one level',
607
812
  submenu: '{label}, submenu'
608
813
  };
@@ -632,8 +837,8 @@
632
837
  var root = sourceUl ? parse(sourceUl) : [];
633
838
  if (sourceUl) sourceUl.remove();
634
839
 
635
- var label = wrap.getAttribute('data-label') || 'Select';
636
- var placeholder = wrap.getAttribute('data-placeholder') || 'Select…';
840
+ var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
841
+ var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, 'placeholder');
637
842
  var sep = wrap.getAttribute('data-separator') || ' / ';
638
843
 
639
844
  var trigger = document.createElement('button');
@@ -748,7 +953,44 @@
748
953
 
749
954
  var _pop = null;
750
955
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
956
+
957
+ /* The three field states the CSS has always styled (`:disabled`, `[aria-readonly="true"]`,
958
+ `[aria-invalid="true"]`) and this enhancer never set, so only the stacks that re-implement
959
+ the control natively had them. Read from the seed at init and settable afterwards, because
960
+ a host that renders once — every Blazor wrapper does, `ShouldRender => false` — cannot
961
+ express a later change any other way. */
962
+ function flagOf(name) {
963
+ var v = wrap.getAttribute('data-' + name);
964
+ return v != null && v !== 'false';
965
+ }
966
+ /* Named `state*` to match the datepicker, where `is*` collided with an older function. */
967
+ var stateDisabled = flagOf('disabled');
968
+ var stateReadonly = flagOf('readonly');
969
+ var stateInvalid = flagOf('invalid');
970
+ /* `data-id` and `data-describedby`: the trigger this enhancer BUILDS is the element a form
971
+ has to point its label and its error text at, and the raw path had no way to say so. */
972
+ if (wrap.getAttribute('data-id')) trigger.id = wrap.getAttribute('data-id');
973
+ if (wrap.getAttribute('data-describedby')) trigger.setAttribute('aria-describedby', wrap.getAttribute('data-describedby'));
974
+
975
+ function applyState() {
976
+ trigger.disabled = stateDisabled;
977
+ if (stateReadonly) trigger.setAttribute('aria-readonly', 'true');
978
+ else trigger.removeAttribute('aria-readonly');
979
+ if (stateInvalid) trigger.setAttribute('aria-invalid', 'true');
980
+ else trigger.removeAttribute('aria-invalid');
981
+ wrap.classList.toggle('fdy-cascade--error', stateInvalid);
982
+ if ((stateDisabled || stateReadonly) && !panel.hidden) close(false);
983
+ }
984
+ function setState(next) {
985
+ if (!next) return;
986
+ if (next.disabled != null) stateDisabled = !!next.disabled;
987
+ if (next.readonly != null) stateReadonly = !!next.readonly;
988
+ if (next.invalid != null) stateInvalid = !!next.invalid;
989
+ applyState();
990
+ }
991
+
751
992
  function open() {
993
+ if (stateDisabled || stateReadonly) return;
752
994
  if (!panel.hidden) return;
753
995
  // Re-open at the selected leaf's level for quick re-selection.
754
996
  var trail = selectedValue ? pathTo(root, selectedValue, []) : null;
@@ -803,8 +1045,11 @@
803
1045
  valueSpan.classList.add('fdy-cascade__value--placeholder');
804
1046
  }
805
1047
 
1048
+ applyState();
1049
+
806
1050
  var api = {
807
1051
  wrap: wrap,
1052
+ setState: setState,
808
1053
  getValue: function () { return selectedValue; },
809
1054
  clear: function () { selectedValue = ''; valueSpan.textContent = placeholder; valueSpan.classList.add('fdy-cascade__value--placeholder'); }
810
1055
  };
@@ -825,7 +1070,15 @@
825
1070
  initAll();
826
1071
  }
827
1072
 
828
- window.FreedayCascade = { init: initCascade, initAll: initAll };
1073
+ window.FreedayCascade = {
1074
+ init: initCascade,
1075
+ initAll: initAll,
1076
+ /* Same reason as the datepicker's: a seed rendered once still has to be lockable later. */
1077
+ setState: function (root, state) {
1078
+ var api = root && root._fdyCascade ? root._fdyCascade : null;
1079
+ if (api && api.setState) api.setState(state);
1080
+ }
1081
+ };
829
1082
  })();
830
1083
 
831
1084
  /* Freeday, choose-from-list enhancer (optional, zero-dependency).
@@ -1104,6 +1357,28 @@
1104
1357
 
1105
1358
  var NS = 'http://www.w3.org/2000/svg';
1106
1359
 
1360
+ /* User-facing strings. Two of them, and both shipped wrong until 2.2.0: the legend's fallback
1361
+ * label read `Seri 1` — Indonesian, three months after 2.0.0 turned every enhancer English —
1362
+ * and the donut's centre caption was hard-coded, so no host could rename it. Neither was
1363
+ * reachable by the guards: one goes into the DOM through `createTextNode`, the other through
1364
+ * `innerHTML`, and both guards look for `textContent` / `setAttribute`. Overridable per element
1365
+ * with `data-fdy-text-<key>`, like every other enhancer. */
1366
+ var TEXT = {
1367
+ series: 'Series {n}',
1368
+ total: 'Total'
1369
+ };
1370
+ function textAttr(root, key) {
1371
+ if (!root || !root.getAttribute) return null;
1372
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
1373
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
1374
+ }
1375
+ function textOf(root, key, vars) {
1376
+ var custom = textAttr(root, key);
1377
+ var s = custom != null && custom !== '' ? custom : TEXT[key];
1378
+ if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
1379
+ return s;
1380
+ }
1381
+
1107
1382
  // Categorical chart palette: 8 validated fixed-order slots (--chart-1..8). Series index i
1108
1383
  // (0-based) -> slot i+1; series beyond the 8-slot cap reuse --chart-8 (never cycled).
1109
1384
  function chartSlotVar(i) { return 'var(--chart-' + (i < 8 ? i + 1 : 8) + ')'; }
@@ -1312,7 +1587,7 @@
1312
1587
  var li = document.createElement('li');
1313
1588
  var sw = document.createElement('span'); sw.className = 'fdy-chart__swatch'; sw.style.background = colorFor(si);
1314
1589
  li.appendChild(sw);
1315
- li.appendChild(document.createTextNode(s.label || ('Seri ' + (si + 1))));
1590
+ li.appendChild(document.createTextNode(s.label || textOf(el, 'series', { n: si + 1 })));
1316
1591
  legend.appendChild(li);
1317
1592
  });
1318
1593
  el.appendChild(legend);
@@ -1486,8 +1761,16 @@
1486
1761
  ring.style.background = 'conic-gradient(' + stops.join(',') + ')';
1487
1762
  var center = document.createElement('div'); center.className = 'fdy-donut__center';
1488
1763
  var centerLabel = el.getAttribute('data-fdy-center');
1489
- center.innerHTML = centerLabel ? '<b></b>' : '<b></b><span>Total</span>';
1490
- center.querySelector('b').textContent = centerLabel != null ? centerLabel : String(total);
1764
+ var centerValue = document.createElement('b');
1765
+ centerValue.textContent = centerLabel != null ? centerLabel : String(total);
1766
+ center.appendChild(centerValue);
1767
+ /* Built rather than assigned as innerHTML: the caption is overridable now, and an author's
1768
+ string is not markup. */
1769
+ if (!centerLabel) {
1770
+ var centerCaption = document.createElement('span');
1771
+ centerCaption.textContent = textOf(el, 'total');
1772
+ center.appendChild(centerCaption);
1773
+ }
1491
1774
  ring.appendChild(center);
1492
1775
  var svg = svgEl('svg');
1493
1776
  svg.setAttribute('class', 'fdy-donut__hit');
@@ -1646,12 +1929,12 @@
1646
1929
  * Locale comes from <html lang> (via Intl), month/weekday/value formatting is not hardcoded.
1647
1930
  *
1648
1931
  * Markup contract:
1649
- * - Single: <div data-fdy-datepicker data-value="2026-07-21" data-label="Tanggal unggah"
1650
- * data-placeholder="Pilih tanggal" data-min="2026-01-01" data-max="2026-12-31"></div>
1651
- * - Range: <div data-fdy-daterange role="group" aria-label="Rentang tanggal">
1652
- * <div data-fdy-datepicker data-role="from" data-placeholder="Dari"></div>
1932
+ * - Single: <div data-fdy-datepicker data-value="2026-07-21" data-label="Upload date"
1933
+ * data-placeholder="Choose a date" data-min="2026-01-01" data-max="2026-12-31"></div>
1934
+ * - Range: <div data-fdy-daterange role="group" aria-label="Date range">
1935
+ * <div data-fdy-datepicker data-role="from" data-placeholder="From"></div>
1653
1936
  * <span class="fdy-daterange__sep">–</span>
1654
- * <div data-fdy-datepicker data-role="to" data-placeholder="Sampai"></div>
1937
+ * <div data-fdy-datepicker data-role="to" data-placeholder="To"></div>
1655
1938
  * </div>
1656
1939
  * The range links the two: the end can never precede the start (out-of-range days disable).
1657
1940
  *
@@ -1667,6 +1950,47 @@
1667
1950
  weekday names back automatically. The FALLBACK follows the kit's default language, or a
1668
1951
  page without `lang` would read English labels around Indonesian month names. */
1669
1952
  var LOCALE = document.documentElement.getAttribute('lang') || 'en';
1953
+
1954
+ /* User-facing strings. English by default, and every one overridable per element with
1955
+ * `data-fdy-text-<key>`, so a host that speaks another language (an Indonesian app on the raw
1956
+ * path, and every Blazor app, whose picker IS this enhancer) supplies its own without forking
1957
+ * this file.
1958
+ *
1959
+ * This table arrived late, in 2.2.0: the ten labels below were written as literals passed to
1960
+ * `navButton()` / `titleButton()`, so the guard that proves no enhancer string is hard-coded
1961
+ * never saw them — it looks for the line that writes to the DOM, and here that line only ever
1962
+ * sees a variable. Month and weekday names are NOT here on purpose: they come from `Intl`
1963
+ * through the page's `lang`, which is a better hatch than anything the kit could invent.
1964
+ * The `{label}` in the three title strings is the period the button drills into. */
1965
+ var TEXT = {
1966
+ label: 'Date',
1967
+ placeholder: 'Choose a date',
1968
+ prevMonth: 'Previous month',
1969
+ nextMonth: 'Next month',
1970
+ prevYear: 'Previous year',
1971
+ nextYear: 'Next year',
1972
+ prevYears: 'Previous years',
1973
+ nextYears: 'Next years',
1974
+ chooseMonth: '{label}, choose month',
1975
+ chooseYear: '{label}, choose year',
1976
+ backToMonths: '{start} to {end}, back to months'
1977
+ };
1978
+ /* HTML lowercases attribute names, so a camelCase key like `prevMonth` can only ever be written
1979
+ as `data-fdy-text-prevmonth`, while the kebab form anybody would reach for,
1980
+ `data-fdy-text-prev-month`, becomes a DIFFERENT attribute the enhancer never reads, and the
1981
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
1982
+ still resolves. */
1983
+ function textAttr(root, key) {
1984
+ if (!root || !root.getAttribute) return null;
1985
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
1986
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
1987
+ }
1988
+ function textOf(root, key, vars) {
1989
+ var custom = textAttr(root, key);
1990
+ var s = custom != null && custom !== '' ? custom : TEXT[key];
1991
+ if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
1992
+ return s;
1993
+ }
1670
1994
  var uidSeq = 0;
1671
1995
  function uid(p) { uidSeq += 1; return p + '-' + uidSeq; }
1672
1996
  function pad(n) { return n < 10 ? '0' + n : '' + n; }
@@ -1704,8 +2028,8 @@
1704
2028
  wrap.dataset.fdyDpReady = '1';
1705
2029
  wrap.classList.add('fdy-datepicker');
1706
2030
 
1707
- var placeholder = wrap.getAttribute('data-placeholder') || 'Choose a date';
1708
- var label = wrap.getAttribute('data-label') || 'Date';
2031
+ var placeholder = wrap.getAttribute('data-placeholder') || textOf(wrap, 'placeholder');
2032
+ var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
1709
2033
  var selected = parseISO(wrap.getAttribute('data-value'));
1710
2034
  var minDate = parseISO(wrap.getAttribute('data-min'));
1711
2035
  var maxDate = parseISO(wrap.getAttribute('data-max'));
@@ -1803,16 +2127,16 @@
1803
2127
  panel.innerHTML = '';
1804
2128
  var head = document.createElement('div');
1805
2129
  head.className = 'fdy-cal__head';
1806
- var title = titleButton(monthFmt.format(view), monthFmt.format(view) + ', choose month', function () {
2130
+ var title = titleButton(monthFmt.format(view), textOf(wrap, 'chooseMonth', { label: monthFmt.format(view) }), function () {
1807
2131
  mode = 'months';
1808
2132
  focusMonth = view.getMonth();
1809
2133
  render();
1810
2134
  focusMonthCell();
1811
2135
  });
1812
2136
  panel.setAttribute('aria-labelledby', title.id);
1813
- head.appendChild(navButton('‹', 'Previous month', function () { view = addMonths(view, -1); render(); }));
2137
+ head.appendChild(navButton('‹', textOf(wrap, 'prevMonth'), function () { view = addMonths(view, -1); render(); }));
1814
2138
  head.appendChild(title);
1815
- head.appendChild(navButton('›', 'Next month', function () { view = addMonths(view, 1); render(); }));
2139
+ head.appendChild(navButton('›', textOf(wrap, 'nextMonth'), function () { view = addMonths(view, 1); render(); }));
1816
2140
  panel.appendChild(head);
1817
2141
 
1818
2142
  var grid = document.createElement('div');
@@ -1869,16 +2193,16 @@
1869
2193
  var year = view.getFullYear();
1870
2194
  var head = document.createElement('div');
1871
2195
  head.className = 'fdy-cal__head';
1872
- var title = titleButton(String(year), year + ', choose year', function () {
2196
+ var title = titleButton(String(year), textOf(wrap, 'chooseYear', { label: year }), function () {
1873
2197
  mode = 'years';
1874
2198
  focusYear = year;
1875
2199
  render();
1876
2200
  focusYearCell();
1877
2201
  });
1878
2202
  panel.setAttribute('aria-labelledby', title.id);
1879
- head.appendChild(navButton('‹', 'Previous year', function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
2203
+ head.appendChild(navButton('‹', textOf(wrap, 'prevYear'), function () { view = addMonths(view, -12); render(); focusMonthCell(); }));
1880
2204
  head.appendChild(title);
1881
- head.appendChild(navButton('›', 'Next year', function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
2205
+ head.appendChild(navButton('›', textOf(wrap, 'nextYear'), function () { view = addMonths(view, 12); render(); focusMonthCell(); }));
1882
2206
  panel.appendChild(head);
1883
2207
 
1884
2208
  var grid = document.createElement('div');
@@ -1930,16 +2254,16 @@
1930
2254
  var end = start + YEARS_PER_PAGE - 1;
1931
2255
  var head = document.createElement('div');
1932
2256
  head.className = 'fdy-cal__head';
1933
- var title = titleButton(start + ' – ' + end, start + ' to ' + end + ', back to months', function () {
2257
+ var title = titleButton(start + ' – ' + end, textOf(wrap, 'backToMonths', { start: start, end: end }), function () {
1934
2258
  mode = 'months';
1935
2259
  focusMonth = view.getMonth();
1936
2260
  render();
1937
2261
  focusMonthCell();
1938
2262
  });
1939
2263
  panel.setAttribute('aria-labelledby', title.id);
1940
- head.appendChild(navButton('‹', 'Previous years', function () { moveYearFocus(focusYear - YEARS_PER_PAGE); }));
2264
+ head.appendChild(navButton('‹', textOf(wrap, 'prevYears'), function () { moveYearFocus(focusYear - YEARS_PER_PAGE); }));
1941
2265
  head.appendChild(title);
1942
- head.appendChild(navButton('›', 'Next years', function () { moveYearFocus(focusYear + YEARS_PER_PAGE); }));
2266
+ head.appendChild(navButton('›', textOf(wrap, 'nextYears'), function () { moveYearFocus(focusYear + YEARS_PER_PAGE); }));
1943
2267
  panel.appendChild(head);
1944
2268
 
1945
2269
  var grid = document.createElement('div');
@@ -2100,7 +2424,46 @@
2100
2424
 
2101
2425
  var _pop = null;
2102
2426
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
2427
+
2428
+ /* The three field states the CSS has always styled (`:disabled`, `[aria-readonly="true"]`,
2429
+ `[aria-invalid="true"]`) and this enhancer never set, so only the stacks that re-implement
2430
+ the control natively had them. Read from the seed at init and settable afterwards, because
2431
+ a host that renders once — every Blazor wrapper does, `ShouldRender => false` — cannot
2432
+ express a later change any other way. */
2433
+ function flagOf(name) {
2434
+ var v = wrap.getAttribute('data-' + name);
2435
+ return v != null && v !== 'false';
2436
+ }
2437
+ /* Named `state*`, not `is*`: this file already has an `isDisabled(date)` deciding whether a
2438
+ DAY falls outside min/max, and shadowing it with a boolean made the day grid throw on every
2439
+ render — silently, since the panel still opened and only its cells went missing. */
2440
+ var stateDisabled = flagOf('disabled');
2441
+ var stateReadonly = flagOf('readonly');
2442
+ var stateInvalid = flagOf('invalid');
2443
+ /* `data-id` and `data-describedby`: the trigger this enhancer BUILDS is the element a form
2444
+ has to point its label and its error text at, and the raw path had no way to say so. */
2445
+ if (wrap.getAttribute('data-id')) trigger.id = wrap.getAttribute('data-id');
2446
+ if (wrap.getAttribute('data-describedby')) trigger.setAttribute('aria-describedby', wrap.getAttribute('data-describedby'));
2447
+
2448
+ function applyState() {
2449
+ trigger.disabled = stateDisabled;
2450
+ if (stateReadonly) trigger.setAttribute('aria-readonly', 'true');
2451
+ else trigger.removeAttribute('aria-readonly');
2452
+ if (stateInvalid) trigger.setAttribute('aria-invalid', 'true');
2453
+ else trigger.removeAttribute('aria-invalid');
2454
+ wrap.classList.toggle('fdy-datepicker--error', stateInvalid);
2455
+ if ((stateDisabled || stateReadonly) && !panel.hidden) close(false);
2456
+ }
2457
+ function setState(next) {
2458
+ if (!next) return;
2459
+ if (next.disabled != null) stateDisabled = !!next.disabled;
2460
+ if (next.readonly != null) stateReadonly = !!next.readonly;
2461
+ if (next.invalid != null) stateInvalid = !!next.invalid;
2462
+ applyState();
2463
+ }
2464
+
2103
2465
  function open() {
2466
+ if (stateDisabled || stateReadonly) return;
2104
2467
  if (!panel.hidden) return;
2105
2468
  mode = 'days';
2106
2469
  focusDate = selected || focusDate || new Date();
@@ -2130,8 +2493,11 @@
2130
2493
 
2131
2494
  updateDisplay();
2132
2495
 
2496
+ applyState();
2497
+
2133
2498
  var api = {
2134
2499
  wrap: wrap,
2500
+ setState: setState,
2135
2501
  getValue: function () { return selected ? toISO(selected) : ''; },
2136
2502
  clear: function () { selected = null; updateDisplay(); if (!panel.hidden) render(); },
2137
2503
  setMin: function (iso) { minDate = parseISO(iso); if (!panel.hidden) render(); },
@@ -2191,7 +2557,16 @@
2191
2557
  initAll();
2192
2558
  }
2193
2559
 
2194
- window.FreedayDatepicker = { init: initPicker, initAll: initAll };
2560
+ window.FreedayDatepicker = {
2561
+ init: initPicker,
2562
+ initAll: initAll,
2563
+ /* A host that rendered its seed once and cannot re-render it (Blazor) still has to be able to
2564
+ disable, lock or invalidate the field later. */
2565
+ setState: function (root, state) {
2566
+ var api = root && root._fdyDp ? root._fdyDp : null;
2567
+ if (api && api.setState) api.setState(state);
2568
+ }
2569
+ };
2195
2570
  })();
2196
2571
 
2197
2572
  /* Freeday, datetime picker composer (optional, zero-dependency).
@@ -3215,7 +3590,24 @@
3215
3590
  if (root && root._fdyCombo) root._fdyCombo.setValue(value);
3216
3591
  }
3217
3592
 
3218
- window.FreedayCombo = { init: initCombo, initAll: initAll, setValue: setValue };
3593
+ /* Beside setValue for the same reason it exists: a host that renders its markup once (every
3594
+ Blazor wrapper, `ShouldRender => false`) cannot express a later state change any other way,
3595
+ and a parameter that silently stops working after the first render is worse than none. */
3596
+ function setState(root, state) {
3597
+ var button = root ? root.querySelector('.fdy-combo__button') : null;
3598
+ if (!button || !state) return;
3599
+ if (state.disabled != null) button.disabled = !!state.disabled;
3600
+ if (state.readonly != null) {
3601
+ if (state.readonly) button.setAttribute('aria-readonly', 'true');
3602
+ else button.removeAttribute('aria-readonly');
3603
+ }
3604
+ if (state.invalid != null) {
3605
+ if (state.invalid) button.setAttribute('aria-invalid', 'true');
3606
+ else button.removeAttribute('aria-invalid');
3607
+ }
3608
+ }
3609
+
3610
+ window.FreedayCombo = { init: initCombo, initAll: initAll, setValue: setValue, setState: setState };
3219
3611
  })();
3220
3612
 
3221
3613
  /* Freeday, slider value binding (optional, zero-dependency).
@@ -3269,7 +3661,9 @@
3269
3661
  * <div class="fdy-step-panel">…</div>… </div>
3270
3662
  * <div class="fdy-step-nav"><button data-fdy-step-prev>…</button>
3271
3663
  * <button data-fdy-step-next>…</button></div></div>
3272
- * Emits bubbling "fdy-step-change" {index} and "fdy-step-finish" (Next on the last step).
3664
+ * Emits bubbling "fdy-step-change" {index} and "fdy-step-finish" (Next on the last step), and a
3665
+ * cancelable "fdy-step-before-change" {from, to, waitFor} that a guard refuses with
3666
+ * preventDefault() or defers by assigning a promise to detail.waitFor.
3273
3667
  */
3274
3668
  (function () {
3275
3669
  'use strict';
@@ -3348,14 +3742,62 @@
3348
3742
  render();
3349
3743
  }
3350
3744
 
3745
+ /* Leaving a step is REFUSABLE, because a wizard whose Next cannot be stopped is a wizard that
3746
+ * validates nothing. Two ways to refuse, and the second is why an event alone was not enough:
3747
+ *
3748
+ * sync handler calls ev.preventDefault() — the answer is already known
3749
+ * async handler sets ev.detail.waitFor = promise — the answer is a server round-trip away
3750
+ *
3751
+ * Resolving to `false` refuses; anything else advances, so a handler that forgets to return is
3752
+ * not read as a rejection. HOW validity is decided stays entirely with the app: the kit has no
3753
+ * opinion about form libraries and this is the line that keeps it that way. */
3754
+ var deciding = false;
3755
+
3756
+ function lock(on) {
3757
+ deciding = on;
3758
+ var list = root.querySelector('.fdy-stepper');
3759
+ if (list) { if (on) list.setAttribute('aria-busy', 'true'); else list.removeAttribute('aria-busy'); }
3760
+ if (prevBtn) prevBtn.disabled = on || active === 0;
3761
+ if (nextBtn) nextBtn.disabled = on;
3762
+ }
3763
+
3764
+ function request(to, onAllowed) {
3765
+ if (deciding) return;
3766
+ var ev = new CustomEvent('fdy-step-before-change', {
3767
+ bubbles: true,
3768
+ cancelable: true,
3769
+ detail: { from: active, to: to, waitFor: null },
3770
+ });
3771
+ root.dispatchEvent(ev);
3772
+ if (ev.defaultPrevented) return;
3773
+
3774
+ var pending = ev.detail.waitFor;
3775
+ if (pending === null || typeof pending.then !== 'function') { onAllowed(); return; }
3776
+
3777
+ lock(true);
3778
+ pending.then(
3779
+ function (ok) { lock(false); if (ok !== false) onAllowed(); },
3780
+ // A guard that THREW decided nothing, so it must not advance. Staying put with the nav
3781
+ // released is the only safe reading; the app's own error handling reports the failure.
3782
+ function () { lock(false); },
3783
+ );
3784
+ }
3785
+
3351
3786
  if (prevBtn) prevBtn.addEventListener('click', function () { go(active - 1); });
3352
3787
  if (nextBtn) nextBtn.addEventListener('click', function () {
3353
- if (active < steps.length - 1) go(active + 1);
3354
- else root.dispatchEvent(new CustomEvent('fdy-step-finish', { bubbles: true }));
3788
+ if (active < steps.length - 1) request(active + 1, function () { go(active + 1); });
3789
+ else request(active + 1, function () { root.dispatchEvent(new CustomEvent('fdy-step-finish', { bubbles: true })); });
3355
3790
  });
3356
3791
  steps.forEach(function (s, i) {
3357
3792
  var btn = s.querySelector('.fdy-step__btn');
3358
- if (btn) btn.addEventListener('click', function () { if (i <= maxReached) go(i); });
3793
+ if (!btn) return;
3794
+ btn.addEventListener('click', function () {
3795
+ if (i > maxReached) return;
3796
+ // Going BACK is always allowed — nothing is being committed. Jumping forward to a step
3797
+ // already reached still leaves the current one behind, so it asks the guard like Next does.
3798
+ if (i <= active) go(i);
3799
+ else request(i, function () { go(i); });
3800
+ });
3359
3801
  });
3360
3802
 
3361
3803
  render();
@@ -3426,6 +3868,8 @@
3426
3868
  filterText: 'Contains text',
3427
3869
  filterTextPlaceholder: 'Contains…',
3428
3870
  filterEnum: 'Show values',
3871
+ filterMin: 'Min',
3872
+ filterMax: 'Max',
3429
3873
  filterRange: 'Value range',
3430
3874
  reset: 'Reset',
3431
3875
  close: 'Close',
@@ -3698,8 +4142,8 @@
3698
4142
  pop.appendChild(filterTitle(textOf(root, 'filterRange')));
3699
4143
  var range = document.createElement('div');
3700
4144
  range.className = 'fdy-filter__range';
3701
- var minI = numberInput('Min', f.min);
3702
- var maxI = numberInput('Maks', f.max);
4145
+ var minI = numberInput(textOf(root, 'filterMin'), f.min);
4146
+ var maxI = numberInput(textOf(root, 'filterMax'), f.max);
3703
4147
  var applyRange = function () {
3704
4148
  f.min = minI.value !== '' ? parseNum(minI.value) : null;
3705
4149
  f.max = maxI.value !== '' ? parseNum(maxI.value) : null;
@@ -3998,6 +4442,24 @@
3998
4442
  (function () {
3999
4443
  'use strict';
4000
4444
 
4445
+ /* User-facing strings, overridable per element with `data-fdy-text-<key>`. One entry, and it
4446
+ * still needed the table: it reached the DOM as an argument to a helper, which is how it sat
4447
+ * outside every string guard until 2.2.0. */
4448
+ var TEXT = {
4449
+ label: 'Choose a time'
4450
+ };
4451
+ function textAttr(root, key) {
4452
+ if (!root || !root.getAttribute) return null;
4453
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
4454
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
4455
+ }
4456
+ function textOf(root, key, vars) {
4457
+ var custom = textAttr(root, key);
4458
+ var s = custom != null && custom !== '' ? custom : TEXT[key];
4459
+ if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
4460
+ return s;
4461
+ }
4462
+
4001
4463
  var seq = 0;
4002
4464
  var CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>';
4003
4465
 
@@ -4012,7 +4474,7 @@
4012
4474
  wrap.dataset.fdyTpReady = '1';
4013
4475
  wrap.classList.add('fdy-timepicker');
4014
4476
 
4015
- var label = wrap.getAttribute('data-label') || 'Choose a time';
4477
+ var label = wrap.getAttribute('data-label') || textOf(wrap, 'label');
4016
4478
  var placeholder = wrap.getAttribute('data-placeholder') || '--:--';
4017
4479
  var step = Math.max(1, parseInt(wrap.getAttribute('data-step') || '30', 10));
4018
4480
  var minM = valid(wrap.getAttribute('data-min')) ? toMin(wrap.getAttribute('data-min')) : 0;