@cahyo-dimas/freeday 2.2.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.
@@ -76,6 +76,23 @@ const props = withDefaults(defineProps<{
76
76
  rowClass?: (row: Row) => string | undefined;
77
77
  /** Controlled: row keys whose `row-detail` slot is shown as a full-width row beneath them. */
78
78
  expandedKeys?: ReadonlyArray<string | number>;
79
+ /** Zebra-stripe the body rows. */
80
+ striped?: boolean;
81
+ /** Render the checkbox column and the bulk bar. */
82
+ selectable?: boolean;
83
+ /** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
84
+ * still works with nothing wired). */
85
+ selectedKeys?: ReadonlyArray<string | number>;
86
+ /** Bulk-bar count, `{n}` substituted. Default `{n} selected`. */
87
+ selectedText?: string;
88
+ /** Bulk-bar clear button. Default `Clear`. */
89
+ clearSelectionText?: string;
90
+ /** Accessible name of the header checkbox. Default `Select all rows on this page`. */
91
+ selectAllLabel?: string;
92
+ /** Accessible name of each row checkbox. Default `Select row`. */
93
+ selectRowLabel?: string;
94
+ /** Accessible name of the bulk bar region. Default `Bulk actions`. */
95
+ bulkLabel?: string;
79
96
  }>(), { pager: true });
80
97
 
81
98
  const emit = defineEmits<{
@@ -90,6 +107,9 @@ const emit = defineEmits<{
90
107
  'update:pageSize': [size: number];
91
108
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
92
109
  'row-activate': [row: Row];
110
+ /** Selection changed, as `rowKey` values. Fires in both modes, so a screen can watch the
111
+ * selection without owning it. */
112
+ 'update:selectedKeys': [keys: Array<string | number>];
93
113
  /** The processed page of rows (after filter/sort/paginate) plus the total row count, fires in
94
114
  * BOTH modes whenever they change. Lets a consumer render the SAME processed set elsewhere
95
115
  * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
@@ -262,6 +282,65 @@ function onRowKeydown(e: KeyboardEvent, row: Row): void {
262
282
  function isExpanded(row: Row): boolean {
263
283
  return props.expandedKeys?.includes(props.rowKey(row)) === true;
264
284
  }
285
+
286
+ /* Selection is keyed by `rowKey`, exactly as `expandedKeys` is, and for the same reason: a key
287
+ * survives the re-fetch that replaces every row object, an object identity does not. Controlled when
288
+ * `selectedKeys` is provided, internal otherwise, so the column works with nothing wired. */
289
+ const internalSelectedKeys: Ref<Array<string | number>> = ref([]);
290
+ const selectionControlled: ComputedRef<boolean> = computed((): boolean => props.selectedKeys !== undefined);
291
+ const effectiveSelectedKeys: ComputedRef<ReadonlyArray<string | number>> = computed(
292
+ (): ReadonlyArray<string | number> =>
293
+ selectionControlled.value ? (props.selectedKeys as ReadonlyArray<string | number>) : internalSelectedKeys.value,
294
+ );
295
+ const selectedCount: ComputedRef<number> = computed((): number => effectiveSelectedKeys.value.length);
296
+ /* The select-all box acts on the CURRENT PAGE, not on every filtered row: a header checkbox that
297
+ * silently selects rows the reader cannot see is how bulk deletes go wrong. Keys picked on other
298
+ * pages are preserved rather than dropped, so paging away and back does not lose them. */
299
+ const pageKeys: ComputedRef<Array<string | number>> = computed((): Array<string | number> =>
300
+ displayRows.value.map((row: Row): string | number => props.rowKey(row)),
301
+ );
302
+ const allPageSelected: ComputedRef<boolean> = computed((): boolean =>
303
+ pageKeys.value.length > 0 && pageKeys.value.every((k: string | number): boolean => effectiveSelectedKeys.value.includes(k)),
304
+ );
305
+ const somePageSelected: ComputedRef<boolean> = computed((): boolean =>
306
+ !allPageSelected.value && pageKeys.value.some((k: string | number): boolean => effectiveSelectedKeys.value.includes(k)),
307
+ );
308
+
309
+ /* Always emits, controlled or not — the same call the `update:pageSize` control makes, and for the
310
+ * same reason: a screen that only wants to WATCH the selection (a summary line, an export button)
311
+ * should not have to take ownership of it to hear about it. */
312
+ function setSelection(keys: Array<string | number>): void {
313
+ if (!selectionControlled.value) internalSelectedKeys.value = keys;
314
+ emit('update:selectedKeys', keys);
315
+ }
316
+ function isSelected(row: Row): boolean {
317
+ return effectiveSelectedKeys.value.includes(props.rowKey(row));
318
+ }
319
+ function toggleRow(row: Row, checked: boolean): void {
320
+ const key: string | number = props.rowKey(row);
321
+ const next: Array<string | number> = effectiveSelectedKeys.value.filter((k: string | number): boolean => k !== key);
322
+ if (checked) next.push(key);
323
+ setSelection(next);
324
+ }
325
+ function toggleAllOnPage(checked: boolean): void {
326
+ const onPage: Set<string | number> = new Set(pageKeys.value);
327
+ const offPage: Array<string | number> = effectiveSelectedKeys.value.filter(
328
+ (k: string | number): boolean => !onPage.has(k),
329
+ );
330
+ setSelection(checked ? offPage.concat(pageKeys.value) : offPage);
331
+ }
332
+ function clearSelection(): void {
333
+ setSelection([]);
334
+ }
335
+ function selectedLabel(n: number): string {
336
+ return (props.selectedText ?? '{n} selected').replace('{n}', String(n));
337
+ }
338
+
339
+ /* The checkbox column widens every full-width row (loading, empty, row detail) by one. Deriving it
340
+ * once is what keeps a later column change from leaving one of the three behind. */
341
+ const colSpan: ComputedRef<number> = computed((): number =>
342
+ props.columns.length + (props.selectable === true ? 1 : 0),
343
+ );
265
344
  </script>
266
345
 
267
346
  <template>
@@ -270,10 +349,38 @@ function isExpanded(row: Row): boolean {
270
349
  <slot name="toolbar" />
271
350
  </div>
272
351
 
352
+ <div
353
+ v-if="selectable"
354
+ class="fdy-table-bulkbar"
355
+ :hidden="selectedCount === 0"
356
+ role="region"
357
+ :aria-label="bulkLabel ?? 'Bulk actions'"
358
+ >
359
+ <span class="fdy-table-bulkbar__count" aria-live="polite">{{ selectedLabel(selectedCount) }}</span>
360
+ <span class="fdy-table-bulkbar__spacer"></span>
361
+ <div class="fdy-table-bulkbar__actions">
362
+ <slot name="bulk-actions" :keys="effectiveSelectedKeys" :clear="clearSelection" />
363
+ <button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--sm" @click="clearSelection">
364
+ {{ clearSelectionText ?? 'Clear' }}
365
+ </button>
366
+ </div>
367
+ </div>
368
+
273
369
  <div class="fdy-table-scroll">
274
- <table class="fdy-table" :aria-label="ariaLabel">
370
+ <table class="fdy-table" :class="{ 'fdy-table--striped': striped }" :aria-label="ariaLabel">
275
371
  <thead>
276
372
  <tr>
373
+ <th v-if="selectable" class="fdy-table__selcol" scope="col">
374
+ <input
375
+ type="checkbox"
376
+ class="fdy-checkbox"
377
+ data-fdy-select-all
378
+ :checked="allPageSelected"
379
+ :indeterminate.prop="somePageSelected"
380
+ :aria-label="selectAllLabel ?? 'Select all rows on this page'"
381
+ @change="toggleAllOnPage(($event.target as HTMLInputElement).checked)"
382
+ >
383
+ </th>
277
384
  <th v-for="col in columns" :key="col.key" scope="col" :style="alignStyle(col)" :aria-sort="ariaSortOf(col)">
278
385
  <button
279
386
  v-if="col.sortable"
@@ -295,10 +402,10 @@ function isExpanded(row: Row): boolean {
295
402
  </thead>
296
403
  <tbody>
297
404
  <tr v-if="loading">
298
- <td :colspan="columns.length" class="fdy-table__state" role="status">Loading…</td>
405
+ <td :colspan="colSpan" class="fdy-table__state" role="status">Loading…</td>
299
406
  </tr>
300
407
  <tr v-else-if="displayRows.length === 0">
301
- <td :colspan="columns.length" class="fdy-table__state">
408
+ <td :colspan="colSpan" class="fdy-table__state">
302
409
  <slot name="empty">{{ emptyText ?? 'No data' }}</slot>
303
410
  </td>
304
411
  </tr>
@@ -308,15 +415,29 @@ function isExpanded(row: Row): boolean {
308
415
  :class="rowClasses(row)"
309
416
  :tabindex="rowActivatable ? 0 : undefined"
310
417
  :aria-expanded="$slots['row-detail'] ? (isExpanded(row) ? 'true' : 'false') : undefined"
418
+ :aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
311
419
  @click="onRowClick(row)"
312
420
  @keydown="onRowKeydown($event, row)"
313
421
  >
422
+ <td v-if="selectable" class="fdy-table__selcol">
423
+ <!-- `.stop`: without it, ticking a checkbox in an activatable row also fires
424
+ `row-activate`, so selecting a row would navigate away from it. -->
425
+ <input
426
+ type="checkbox"
427
+ class="fdy-checkbox"
428
+ data-fdy-row-select
429
+ :checked="isSelected(row)"
430
+ :aria-label="selectRowLabel ?? 'Select row'"
431
+ @click.stop
432
+ @change="toggleRow(row, ($event.target as HTMLInputElement).checked)"
433
+ >
434
+ </td>
314
435
  <td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
315
436
  <slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
316
437
  </td>
317
438
  </tr>
318
439
  <tr v-if="$slots['row-detail'] && isExpanded(row)" class="fdy-table__detailrow">
319
- <td :colspan="columns.length"><slot name="row-detail" :row="row" /></td>
440
+ <td :colspan="colSpan"><slot name="row-detail" :row="row" /></td>
320
441
  </tr>
321
442
  </template>
322
443
  </template>
@@ -60,7 +60,13 @@
60
60
 
61
61
  function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
62
62
  function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
63
- function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
63
+ /* Whether the nav FLOATS. Two ways to be true, and only one of them is the viewport: below the
64
+ breakpoint it is off-canvas by definition, and above it `--nav-overlay` says the app chose to
65
+ float a nav that could have been a column. Everything downstream — which class means visible,
66
+ what the toggle does, whether the content goes inert — asks this instead of the media query,
67
+ so overlay mode reuses the drawer's whole code path rather than growing a second one. */
68
+ function isOverlayMode() { return !mqWide.matches || app.classList.contains('fdy-app--nav-overlay'); }
69
+ function navVisible() { return isOverlayMode() ? isOverlayOpen() : !isCollapsed(); }
64
70
 
65
71
  /* aria-expanded answers "is the nav showing?" in BOTH modes, the two state classes are the
66
72
  kit's business, not the reader's. */
@@ -68,7 +74,7 @@
68
74
  var visible = navVisible();
69
75
  toggle.setAttribute('aria-expanded', String(visible));
70
76
  setInert(sidebar, !visible);
71
- setInert(content, !mqWide.matches && visible);
77
+ setInert(content, isOverlayMode() && visible);
72
78
  /* Announce only real changes. The first sync() runs at init to describe the state the markup
73
79
  arrived in, which is not something a host asked for and must not look like one. */
74
80
  if (lastVisible !== null && visible !== lastVisible) {
@@ -106,7 +112,7 @@
106
112
  }
107
113
 
108
114
  toggle.addEventListener('click', function () {
109
- if (mqWide.matches) {
115
+ if (!isOverlayMode()) {
110
116
  app.classList.toggle('fdy-app--nav-collapsed');
111
117
  sync();
112
118
  } else if (isOverlayOpen()) {
@@ -154,7 +160,10 @@
154
160
  content inert forever: the panel becomes a static column again, and the page it is covering
155
161
  can no longer be clicked or read. */
156
162
  mqWide.addEventListener('change', function () {
157
- if (mqWide.matches && isOverlayOpen()) close(false);
163
+ /* Only when the panel stops floating. In overlay MODE it floats at every width, so widening
164
+ must leave an open panel exactly as it is — closing it there would be the shell overruling
165
+ a reader who never asked for anything. */
166
+ if (mqWide.matches && !isOverlayMode() && isOverlayOpen()) close(false);
158
167
  else sync();
159
168
  });
160
169
 
@@ -164,9 +173,14 @@
164
173
  own state needs to drive this without reaching for the class names the kit reserves. */
165
174
  app._fdyAppShell = {
166
175
  isVisible: navVisible,
176
+ /* Re-read the DOM and reconcile `inert` + `aria-expanded`. Needed when something OUTSIDE the
177
+ enhancer changes what the state classes mean — switching `--nav-overlay` on or off does
178
+ exactly that, because it moves the answer to "is the nav visible?" from `--nav-collapsed`
179
+ to `--nav-open`. Without this a mode switch leaves a visible sidebar marked inert. */
180
+ refresh: sync,
167
181
  setVisible: function (visible) {
168
182
  if (visible === navVisible()) return;
169
- if (mqWide.matches) {
183
+ if (!isOverlayMode()) {
170
184
  app.classList.toggle('fdy-app--nav-collapsed', !visible);
171
185
  sync();
172
186
  } else if (visible) {
@@ -203,5 +217,9 @@
203
217
  isVisible: function (root) {
204
218
  return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
205
219
  },
220
+ /* Call after changing `--nav-overlay` from outside; see the note on the handle above. */
221
+ refresh: function (root) {
222
+ if (root && root._fdyAppShell) root._fdyAppShell.refresh();
223
+ },
206
224
  };
207
225
  })();
@@ -0,0 +1,168 @@
1
+ /* Freeday, busy overlay (optional, zero-dependency).
2
+ *
3
+ * Freeday.busy({ caption, delay, mark }) block the screen while an operation runs
4
+ * Freeday.idle() release it
5
+ *
6
+ * Imperative on purpose, like Freeday.toast(): a component API invites two instances, and two
7
+ * blocking overlays with two captions is the failure this exists to prevent. A second busy() while
8
+ * one is up REPLACES the caption rather than stacking.
9
+ *
10
+ * caption what is happening. Announced politely, so make it a sentence a reader would want read
11
+ * out, not a spinner label. Omitted, it falls back to the kit default, which a page
12
+ * overrides once with `data-fdy-text-caption` on <html>.
13
+ * delay ms to wait before it appears (default 120; 0 shows immediately). An operation that
14
+ * finishes in 80ms should never flash a scrim — that reads as a glitch, not as progress.
15
+ * mark an Element to use instead of the default spinner. Element only, never an HTML string:
16
+ * a string here would be an injection point in every app that passed user text through.
17
+ *
18
+ * Not a dialog. Interaction is removed with `inert` on everything else, so there is nothing to trap
19
+ * focus against and nothing to dismiss. Focus is parked on the panel and given back on idle(),
20
+ * because the element it was on is inert by then and the browser would otherwise drop it to <body>.
21
+ */
22
+ (function () {
23
+ 'use strict';
24
+
25
+ var DEFAULT_DELAY = 120;
26
+
27
+ var TEXT = {
28
+ caption: 'Working…'
29
+ };
30
+ /* The overlay has no root of its own to carry an override — it is created, not hydrated — so the
31
+ lookup goes to <html>, the one element every page has before this runs. Kebab-cased for the
32
+ same reason as everywhere else: HTML lowercases attribute names, so a camelCase key could only
33
+ ever be written run-together and the override would fail silently. */
34
+ function textOf(key) {
35
+ var root = document.documentElement;
36
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
37
+ var custom = kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
38
+ return custom != null && custom !== '' ? custom : TEXT[key];
39
+ }
40
+
41
+ var node = null;
42
+ var showTimer = null;
43
+ var inerted = [];
44
+ var returnFocusTo = null;
45
+
46
+ function build() {
47
+ var el = document.createElement('div');
48
+ el.className = 'fdy-busy';
49
+ el.setAttribute('popover', 'manual');
50
+ el.setAttribute('aria-busy', 'true');
51
+ el.tabIndex = -1;
52
+
53
+ var panel = document.createElement('div');
54
+ panel.className = 'fdy-busy__panel';
55
+
56
+ var mark = document.createElement('div');
57
+ mark.className = 'fdy-busy__mark';
58
+ // aria-hidden: the caption below is the message. A second announcement from the spinner's own
59
+ // role="status" would say "busy" twice and name nothing.
60
+ mark.setAttribute('aria-hidden', 'true');
61
+ mark.appendChild(defaultMark());
62
+
63
+ var caption = document.createElement('p');
64
+ caption.className = 'fdy-busy__caption';
65
+ // role="status" rather than a dialog role: this reports a state, it does not ask a question.
66
+ caption.setAttribute('role', 'status');
67
+
68
+ panel.appendChild(mark);
69
+ panel.appendChild(caption);
70
+ el.appendChild(panel);
71
+ return el;
72
+ }
73
+
74
+ function defaultMark() {
75
+ var spinner = document.createElement('span');
76
+ spinner.className = 'fdy-spinner fdy-spinner--lg';
77
+ return spinner;
78
+ }
79
+
80
+ /** inert everything else, remembering ONLY what we set so an app's own inert is never cleared. */
81
+ function block(on) {
82
+ var i;
83
+ if (on) {
84
+ var kids = document.body.children;
85
+ for (i = 0; i < kids.length; i++) {
86
+ var child = kids[i];
87
+ if (child === node || child.hasAttribute('inert')) continue;
88
+ child.setAttribute('inert', '');
89
+ inerted.push(child);
90
+ }
91
+ return;
92
+ }
93
+ for (i = 0; i < inerted.length; i++) inerted[i].removeAttribute('inert');
94
+ inerted = [];
95
+ }
96
+
97
+ function isOpen() {
98
+ return node !== null && node.classList.contains('is-open');
99
+ }
100
+
101
+ function setCaption(text) {
102
+ node.querySelector('.fdy-busy__caption').textContent = text;
103
+ }
104
+
105
+ function setMark(el) {
106
+ var slot = node.querySelector('.fdy-busy__mark');
107
+ while (slot.firstChild) slot.removeChild(slot.firstChild);
108
+ slot.appendChild(el instanceof Element ? el : defaultMark());
109
+ }
110
+
111
+ function show(opts) {
112
+ showTimer = null;
113
+ setCaption(opts.caption == null ? textOf('caption') : String(opts.caption));
114
+ if (opts.mark !== undefined) setMark(opts.mark);
115
+
116
+ returnFocusTo = document.activeElement;
117
+ document.body.appendChild(node);
118
+ node.classList.add('is-open');
119
+ // Top layer, so it also covers an open <dialog>. Where the API is missing the z-index in
120
+ // busy.css is the fallback; it cannot clear a modal, and that is stated in COMPONENTS.md.
121
+ if (typeof node.showPopover === 'function') {
122
+ try { node.showPopover(); } catch (e) { /* already open, or not connected yet */ }
123
+ }
124
+ block(true);
125
+ node.focus();
126
+ }
127
+
128
+ function busy(options) {
129
+ var opts = options || {};
130
+ if (node === null) node = build();
131
+
132
+ // Already up: this is a second owner talking. Update what it says, do not stack.
133
+ if (isOpen()) {
134
+ if (opts.caption != null) setCaption(String(opts.caption));
135
+ if (opts.mark !== undefined) setMark(opts.mark);
136
+ return node;
137
+ }
138
+
139
+ var delay = opts.delay == null ? DEFAULT_DELAY : Number(opts.delay);
140
+ if (showTimer !== null) clearTimeout(showTimer);
141
+ if (delay > 0) showTimer = setTimeout(function () { show(opts); }, delay);
142
+ else show(opts);
143
+ return node;
144
+ }
145
+
146
+ function idle() {
147
+ // Cancels a pending show too: an operation that beat the delay must leave nothing behind.
148
+ if (showTimer !== null) { clearTimeout(showTimer); showTimer = null; }
149
+ if (node === null || !isOpen()) return;
150
+
151
+ block(false);
152
+ if (typeof node.hidePopover === 'function') {
153
+ try { node.hidePopover(); } catch (e) { /* was never in the top layer */ }
154
+ }
155
+ node.classList.remove('is-open');
156
+ if (node.parentNode !== null) node.parentNode.removeChild(node);
157
+
158
+ // Give focus back to whatever had it, now that its ancestor is no longer inert.
159
+ if (returnFocusTo !== null && typeof returnFocusTo.focus === 'function' && returnFocusTo.isConnected) {
160
+ returnFocusTo.focus();
161
+ }
162
+ returnFocusTo = null;
163
+ }
164
+
165
+ window.Freeday = window.Freeday || {};
166
+ window.Freeday.busy = busy;
167
+ window.Freeday.idle = idle;
168
+ })();
@@ -11,7 +11,9 @@
11
11
  * <div class="fdy-step-panel">…</div>… </div>
12
12
  * <div class="fdy-step-nav"><button data-fdy-step-prev>…</button>
13
13
  * <button data-fdy-step-next>…</button></div></div>
14
- * Emits bubbling "fdy-step-change" {index} and "fdy-step-finish" (Next on the last step).
14
+ * Emits bubbling "fdy-step-change" {index} and "fdy-step-finish" (Next on the last step), and a
15
+ * cancelable "fdy-step-before-change" {from, to, waitFor} that a guard refuses with
16
+ * preventDefault() or defers by assigning a promise to detail.waitFor.
15
17
  */
16
18
  (function () {
17
19
  'use strict';
@@ -90,14 +92,62 @@
90
92
  render();
91
93
  }
92
94
 
95
+ /* Leaving a step is REFUSABLE, because a wizard whose Next cannot be stopped is a wizard that
96
+ * validates nothing. Two ways to refuse, and the second is why an event alone was not enough:
97
+ *
98
+ * sync handler calls ev.preventDefault() — the answer is already known
99
+ * async handler sets ev.detail.waitFor = promise — the answer is a server round-trip away
100
+ *
101
+ * Resolving to `false` refuses; anything else advances, so a handler that forgets to return is
102
+ * not read as a rejection. HOW validity is decided stays entirely with the app: the kit has no
103
+ * opinion about form libraries and this is the line that keeps it that way. */
104
+ var deciding = false;
105
+
106
+ function lock(on) {
107
+ deciding = on;
108
+ var list = root.querySelector('.fdy-stepper');
109
+ if (list) { if (on) list.setAttribute('aria-busy', 'true'); else list.removeAttribute('aria-busy'); }
110
+ if (prevBtn) prevBtn.disabled = on || active === 0;
111
+ if (nextBtn) nextBtn.disabled = on;
112
+ }
113
+
114
+ function request(to, onAllowed) {
115
+ if (deciding) return;
116
+ var ev = new CustomEvent('fdy-step-before-change', {
117
+ bubbles: true,
118
+ cancelable: true,
119
+ detail: { from: active, to: to, waitFor: null },
120
+ });
121
+ root.dispatchEvent(ev);
122
+ if (ev.defaultPrevented) return;
123
+
124
+ var pending = ev.detail.waitFor;
125
+ if (pending === null || typeof pending.then !== 'function') { onAllowed(); return; }
126
+
127
+ lock(true);
128
+ pending.then(
129
+ function (ok) { lock(false); if (ok !== false) onAllowed(); },
130
+ // A guard that THREW decided nothing, so it must not advance. Staying put with the nav
131
+ // released is the only safe reading; the app's own error handling reports the failure.
132
+ function () { lock(false); },
133
+ );
134
+ }
135
+
93
136
  if (prevBtn) prevBtn.addEventListener('click', function () { go(active - 1); });
94
137
  if (nextBtn) nextBtn.addEventListener('click', function () {
95
- if (active < steps.length - 1) go(active + 1);
96
- else root.dispatchEvent(new CustomEvent('fdy-step-finish', { bubbles: true }));
138
+ if (active < steps.length - 1) request(active + 1, function () { go(active + 1); });
139
+ else request(active + 1, function () { root.dispatchEvent(new CustomEvent('fdy-step-finish', { bubbles: true })); });
97
140
  });
98
141
  steps.forEach(function (s, i) {
99
142
  var btn = s.querySelector('.fdy-step__btn');
100
- if (btn) btn.addEventListener('click', function () { if (i <= maxReached) go(i); });
143
+ if (!btn) return;
144
+ btn.addEventListener('click', function () {
145
+ if (i > maxReached) return;
146
+ // Going BACK is always allowed — nothing is being committed. Jumping forward to a step
147
+ // already reached still leaves the current one behind, so it asks the guard like Next does.
148
+ if (i <= active) go(i);
149
+ else request(i, function () { go(i); });
150
+ });
101
151
  });
102
152
 
103
153
  render();