@cahyo-dimas/freeday 3.5.0 → 3.6.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.
@@ -5,6 +5,7 @@ import {
5
5
  cellText,
6
6
  distinctValues,
7
7
  filterRows,
8
+ isFilterActive,
8
9
  sortRows,
9
10
  paginate,
10
11
  } from '../../core/table-model.js';
@@ -29,6 +30,14 @@ import { FdyTableFooter } from './FdyTableFooter';
29
30
  // `onSortChange` / `onFiltersChange` / `onPageChange` for the caller to feed back into its query.
30
31
  // Column filters (text/enum/number/date) apply live; in server mode, debounce the callback if needed.
31
32
 
33
+ /** What the `empty` render function is told (#069). */
34
+ export interface FdyTableEmptyContext {
35
+ /** An active column filter is what emptied the table. */
36
+ filtered: boolean;
37
+ /** Clears every column filter, through `onFiltersChange` when the filters are controlled. */
38
+ clearFilters: () => void;
39
+ }
40
+
32
41
  export interface FdyTableProps<Row extends object> {
33
42
  columns: ReadonlyArray<FdyTableColumn<Row>>;
34
43
  rows: ReadonlyArray<Row>;
@@ -79,7 +88,9 @@ export interface FdyTableProps<Row extends object> {
79
88
  /** Custom cell renderer; return undefined for a column to use the default text. */
80
89
  renderCell?: (column: FdyTableColumn<Row>, row: Row, value: unknown) => ReactNode;
81
90
  toolbar?: ReactNode;
82
- empty?: ReactNode;
91
+ /** The empty state. As a function it is told whether the column filters are what emptied the
92
+ * table, and how to clear them: "no match" wants a Clear button, "nothing yet" a Create one. */
93
+ empty?: ReactNode | ((ctx: FdyTableEmptyContext) => ReactNode);
83
94
  /** Opt in to row activation: rows become focusable and call `onRowActivate` on click/Enter/Space. */
84
95
  rowActivatable?: boolean;
85
96
  /** Per-row class hook, e.g. to mark a selected row. */
@@ -96,6 +107,8 @@ export interface FdyTableProps<Row extends object> {
96
107
  renderRowDetail?: (row: Row) => ReactNode;
97
108
  /** Zebra-stripe the body rows. */
98
109
  striped?: boolean;
110
+ /** Hover only the rows that do something (`rowActivatable`); inert rows keep their colour. */
111
+ hoverActionable?: boolean;
99
112
  /** Render the checkbox column and the bulk bar. */
100
113
  selectable?: boolean;
101
114
  /** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
@@ -213,6 +226,14 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
213
226
  setClientPage(0);
214
227
  }
215
228
  }
229
+ const filtered: boolean = Object.values(effectiveFilters).some((f: FdyColumnFilter): boolean => isFilterActive(f));
230
+ function clearFilters(): void {
231
+ if (filtersControlled) props.onFiltersChange?.({});
232
+ else {
233
+ setInternalFilters({});
234
+ setClientPage(0);
235
+ }
236
+ }
216
237
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
217
238
  const nextMap: FdyFilterMap = { ...effectiveFilters };
218
239
  if (filter === null) delete nextMap[col.key];
@@ -352,7 +373,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
352
373
 
353
374
  <div className="fdy-table-scroll">
354
375
  <table
355
- className={props.striped === true ? 'fdy-table fdy-table--striped' : 'fdy-table'}
376
+ className={['fdy-table', props.striped === true ? 'fdy-table--striped' : '', props.hoverActionable === true ? 'fdy-table--hover-actionable' : ''].filter(Boolean).join(' ')}
356
377
  aria-label={props.ariaLabel}
357
378
  >
358
379
  <thead>
@@ -402,7 +423,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
402
423
  </tr>
403
424
  ) : displayRows.length === 0 ? (
404
425
  <tr>
405
- <td colSpan={colCount} className="fdy-table__state">{props.empty ?? props.emptyText ?? 'No data'}</td>
426
+ <td colSpan={colCount} className="fdy-table__state">{typeof props.empty === 'function' ? props.empty({ filtered, clearFilters }) : (props.empty ?? props.emptyText ?? 'No data')}</td>
406
427
  </tr>
407
428
  ) : (
408
429
  displayRows.map((row: Row): JSX.Element => (
@@ -38,7 +38,7 @@ export { FdyCfl, type FdyCflProps, type FdyCflHandle, type CflColumn, type CflPa
38
38
  * on the array only a `multiple` field emits. */
39
39
  export { singleRow } from '../core/cfl-value';
40
40
  export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
41
- export { FdyTable, type FdyTableProps } from './components/FdyTable';
41
+ export { FdyTable, type FdyTableProps, type FdyTableEmptyContext } from './components/FdyTable';
42
42
  /** The table's own footer, standalone, for a responsive list whose table is hidden at some
43
43
  * breakpoints and so cannot host it. */
44
44
  export { FdyTableFooter, type FdyTableFooterProps } from './components/FdyTableFooter';
@@ -34,20 +34,26 @@ const props = defineProps<{
34
34
  const emit = defineEmits<{
35
35
  'update:modelValue': [value: DateRangeValue];
36
36
  change: [value: DateRangeValue];
37
+ /** Both ends chosen, or both cleared: the only states worth reloading a list for (#063). */
38
+ settled: [value: DateRangeValue];
37
39
  }>();
38
40
 
39
41
  const startMax: ComputedRef<string | undefined> = computed((): string | undefined => props.modelValue.end ?? props.max);
40
42
  const endMin: ComputedRef<string | undefined> = computed((): string | undefined => props.modelValue.start ?? props.min);
41
43
 
42
- function setStart(start: string): void {
43
- const next: DateRangeValue = { start, end: props.modelValue.end };
44
+ function isSettled(range: DateRangeValue): boolean {
45
+ return !range.start === !range.end;
46
+ }
47
+ function commit(next: DateRangeValue): void {
44
48
  emit('update:modelValue', next);
45
49
  emit('change', next);
50
+ if (isSettled(next)) emit('settled', next);
51
+ }
52
+ function setStart(start: string): void {
53
+ commit({ start, end: props.modelValue.end });
46
54
  }
47
55
  function setEnd(end: string): void {
48
- const next: DateRangeValue = { start: props.modelValue.start, end };
49
- emit('update:modelValue', next);
50
- emit('change', next);
56
+ commit({ start: props.modelValue.start, end });
51
57
  }
52
58
  </script>
53
59
 
@@ -5,6 +5,7 @@ import {
5
5
  cellText,
6
6
  distinctValues,
7
7
  filterRows,
8
+ isFilterActive,
8
9
  sortRows,
9
10
  paginate,
10
11
  } from '../../core/table-model.js';
@@ -78,6 +79,8 @@ const props = withDefaults(defineProps<{
78
79
  expandedKeys?: ReadonlyArray<string | number>;
79
80
  /** Zebra-stripe the body rows. */
80
81
  striped?: boolean;
82
+ /** Hover only the rows that do something (`rowActivatable`); inert rows keep their colour. */
83
+ hoverActionable?: boolean;
81
84
  /** Render the checkbox column and the bulk bar. */
82
85
  selectable?: boolean;
83
86
  /** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
@@ -222,6 +225,19 @@ function onSort(col: FdyTableColumn<Row>): void {
222
225
  setClientPage(0);
223
226
  }
224
227
  }
228
+ /* "Nothing matches these filters" and "nothing here yet" are different news (#069): the first wants
229
+ a Clear button, the second a Create one. The table owns its column filters, so it is the one
230
+ that knows which it is; the `empty` slot receives both. */
231
+ const filtered: ComputedRef<boolean> = computed((): boolean =>
232
+ Object.values(effectiveFilters.value).some((f: FdyColumnFilter): boolean => isFilterActive(f)),
233
+ );
234
+ function clearFilters(): void {
235
+ if (filtersControlled.value) emit('update:filters', {});
236
+ else {
237
+ internalFilters.value = {};
238
+ setClientPage(0);
239
+ }
240
+ }
225
241
  function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
226
242
  const nextMap: FdyFilterMap = { ...effectiveFilters.value };
227
243
  if (filter === null) delete nextMap[col.key];
@@ -367,7 +383,7 @@ const colSpan: ComputedRef<number> = computed((): number =>
367
383
  </div>
368
384
 
369
385
  <div class="fdy-table-scroll">
370
- <table class="fdy-table" :class="{ 'fdy-table--striped': striped }" :aria-label="ariaLabel">
386
+ <table class="fdy-table" :class="{ 'fdy-table--striped': striped, 'fdy-table--hover-actionable': hoverActionable }" :aria-label="ariaLabel">
371
387
  <thead>
372
388
  <tr>
373
389
  <th v-if="selectable" class="fdy-table__selcol" scope="col">
@@ -406,7 +422,7 @@ const colSpan: ComputedRef<number> = computed((): number =>
406
422
  </tr>
407
423
  <tr v-else-if="displayRows.length === 0">
408
424
  <td :colspan="colSpan" class="fdy-table__state">
409
- <slot name="empty">{{ emptyText ?? 'No data' }}</slot>
425
+ <slot name="empty" :filtered="filtered" :clear-filters="clearFilters">{{ emptyText ?? 'No data' }}</slot>
410
426
  </td>
411
427
  </tr>
412
428
  <template v-else>
@@ -0,0 +1,101 @@
1
+ /* Freeday, confirm API (optional, zero-dependency). Note #060.
2
+ * Freeday.confirm({ title, message, details, acceptText, rejectText, tone }) opens a .fdy-modal
3
+ * and resolves a Promise<boolean>: true on accept; false on reject, Escape or a backdrop click.
4
+ * details: string[] shown as a list under the message, for a confirm that has to NAME its
5
+ * findings ("these three fields will be refused") instead of folding them into one
6
+ * long sentence
7
+ * tone: 'primary' (default) | 'danger', the accept button's variant
8
+ *
9
+ * One dialog, one answer: every way out resolves the same Promise, and a Promise settles once, so
10
+ * a caller never hears "no" after "yes" (a framework dialog that reports reject and then hide, or
11
+ * hide after accept, does exactly that). Text is set with textContent, never parsed as HTML.
12
+ * Focus starts on the reject button: Enter on a dialog that just opened must not delete anything.
13
+ * The dialog is removed when it closes; the native <dialog> returns focus to where it was.
14
+ * Buttons carry data-fdy-confirm-accept / data-fdy-confirm-reject, stable hooks for tests, because
15
+ * an accept labelled "Delete" collides by accessible name with every row's own Delete button.
16
+ */
17
+ (function () {
18
+ 'use strict';
19
+
20
+ /* User-facing strings, English by default. Per call, acceptText / rejectText override them; a
21
+ * label that names the action ("Delete order") is better than either default. */
22
+ var TEXT = {
23
+ accept: 'Confirm',
24
+ reject: 'Cancel'
25
+ };
26
+
27
+ var seq = 0;
28
+
29
+ function el(tag, className, text) {
30
+ var node = document.createElement(tag);
31
+ if (className) node.className = className;
32
+ if (text != null) node.textContent = text;
33
+ return node;
34
+ }
35
+
36
+ function confirm(opts) {
37
+ opts = opts || {};
38
+ return new Promise(function (resolve) {
39
+ seq += 1;
40
+ var titleId = 'fdy-confirm-title-' + seq;
41
+ var dialog = el('dialog', 'fdy-modal fdy-modal--sm');
42
+ dialog.setAttribute('aria-labelledby', titleId);
43
+ dialog.setAttribute('data-fdy-confirm', '');
44
+
45
+ var header = el('header', 'fdy-modal__header');
46
+ var title = el('h2', 'fdy-modal__title', opts.title || '');
47
+ title.id = titleId;
48
+ header.appendChild(title);
49
+
50
+ var body = el('div', 'fdy-modal__body');
51
+ if (opts.message) {
52
+ var message = el('p', null, opts.message);
53
+ message.id = titleId + '-desc';
54
+ dialog.setAttribute('aria-describedby', message.id);
55
+ body.appendChild(message);
56
+ }
57
+ var details = Array.isArray(opts.details) ? opts.details : [];
58
+ if (details.length > 0) {
59
+ var list = el('ul');
60
+ details.forEach(function (line) { list.appendChild(el('li', null, line)); });
61
+ body.appendChild(list);
62
+ }
63
+
64
+ var footer = el('footer', 'fdy-modal__footer');
65
+ var reject = el('button', 'fdy-btn fdy-btn--ghost', opts.rejectText || TEXT.reject);
66
+ reject.type = 'button';
67
+ reject.setAttribute('data-fdy-confirm-reject', '');
68
+ var accept = el('button', opts.tone === 'danger' ? 'fdy-btn fdy-btn--danger' : 'fdy-btn', opts.acceptText || TEXT.accept);
69
+ accept.type = 'button';
70
+ accept.setAttribute('data-fdy-confirm-accept', '');
71
+ footer.appendChild(reject);
72
+ footer.appendChild(accept);
73
+
74
+ dialog.appendChild(header);
75
+ dialog.appendChild(body);
76
+ dialog.appendChild(footer);
77
+
78
+ function settle(answer) {
79
+ resolve(answer);
80
+ if (dialog.open) dialog.close();
81
+ }
82
+
83
+ accept.addEventListener('click', function () { settle(true); });
84
+ reject.addEventListener('click', function () { settle(false); });
85
+ // Escape fires `cancel` then `close`; a backdrop click lands on the <dialog> itself, since its
86
+ // children fill the box edge to edge. Both mean "no".
87
+ dialog.addEventListener('close', function () {
88
+ settle(false);
89
+ if (dialog.parentNode) dialog.parentNode.removeChild(dialog);
90
+ });
91
+ dialog.addEventListener('click', function (e) { if (e.target === dialog) settle(false); });
92
+
93
+ document.body.appendChild(dialog);
94
+ dialog.showModal();
95
+ reject.focus();
96
+ });
97
+ }
98
+
99
+ window.Freeday = window.Freeday || {};
100
+ window.Freeday.confirm = confirm;
101
+ })();
@@ -0,0 +1,58 @@
1
+ /* Freeday, copy-to-clipboard enhancer (optional, zero-dependency). Note #062.
2
+ * A button carrying data-fdy-copy="<value>" copies that value and says so with a toast. For
3
+ * reference values a reader takes elsewhere: an external document number, an event ID.
4
+ *
5
+ * <dd><span class="fdy-mono">INV-2026-00042</span>
6
+ * <button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--icon fdy-btn--sm"
7
+ * data-fdy-copy="INV-2026-00042" aria-label="Copy invoice number">…svg…</button></dd>
8
+ *
9
+ * The value is the attribute, not a lookup of some neighbour's text: a framework writes it from
10
+ * the same variable as the visible text, and a row of a table needs no ids to find its own cell.
11
+ * One delegated listener on the document, so buttons rendered later need no hydration.
12
+ *
13
+ * The Clipboard API exists only in a secure context. Over plain http writeText is missing, and a
14
+ * copy that silently did nothing is the failure this refuses: the reader is told to copy by hand.
15
+ * Emits bubbling "fdy-copy" {value, ok} on the button. A click inside a clickable table row still
16
+ * reaches the row's own handler; stop it there if the row should not open. Exposes window.FreedayCopy.
17
+ */
18
+ (function () {
19
+ 'use strict';
20
+
21
+ /* User-facing strings, English by default and overridable per button with data-fdy-text-<key>,
22
+ * like every other enhancer's. */
23
+ var TEXT = {
24
+ copied: 'Copied.',
25
+ failed: 'Could not copy. Select the text and copy it manually.'
26
+ };
27
+ function textOf(el, key) {
28
+ var custom = el.getAttribute('data-fdy-text-' + key);
29
+ return custom != null && custom !== '' ? custom : TEXT[key];
30
+ }
31
+
32
+ function report(btn, value, ok) {
33
+ if (window.Freeday && typeof window.Freeday.toast === 'function') {
34
+ window.Freeday.toast(ok
35
+ ? { variant: 'success', message: textOf(btn, 'copied'), key: 'fdy-copy' }
36
+ : { variant: 'danger', message: textOf(btn, 'failed'), key: 'fdy-copy', timeout: 0 });
37
+ }
38
+ btn.dispatchEvent(new CustomEvent('fdy-copy', { bubbles: true, detail: { value: value, ok: ok } }));
39
+ }
40
+
41
+ function copy(btn) {
42
+ var value = btn.getAttribute('data-fdy-copy') || '';
43
+ if (value === '') return;
44
+ var clip = navigator.clipboard;
45
+ if (!clip || typeof clip.writeText !== 'function') { report(btn, value, false); return; }
46
+ clip.writeText(value).then(
47
+ function () { report(btn, value, true); },
48
+ function () { report(btn, value, false); }
49
+ );
50
+ }
51
+
52
+ document.addEventListener('click', function (e) {
53
+ var btn = e.target.closest && e.target.closest('[data-fdy-copy]');
54
+ if (btn && !btn.disabled) copy(btn);
55
+ });
56
+
57
+ window.FreedayCopy = { copy: copy };
58
+ })();
@@ -603,15 +603,30 @@
603
603
  fromApi.setRange(f, t); // start stays free, picking a later start clears the end below
604
604
  toApi.setRange(f, t);
605
605
  }
606
+ /* An end the new start overtook is cleared, and the clear is ANNOUNCED like a pick: a binding
607
+ * that only hears picks (Blazor's To) otherwise keeps the end the range no longer has. */
608
+ function clearEnd(el, api) {
609
+ api.clear();
610
+ el.dispatchEvent(new CustomEvent('fdy-datepicker-change', { bubbles: true, detail: { value: '' } }));
611
+ }
612
+ /* "Settled" = both ends chosen, or both empty: the only states worth a reload (#063). A pick of
613
+ * the first end, or a start that overtook the end, is half a range. */
614
+ function settle() {
615
+ var f = fromApi.getValue(), t = toApi.getValue();
616
+ if ((f === '') !== (t === '')) return;
617
+ root.dispatchEvent(new CustomEvent('fdy-daterange-settled', { bubbles: true, detail: { from: f, to: t } }));
618
+ }
606
619
  fromEl.addEventListener('fdy-datepicker-change', function () {
607
620
  var f = fromApi.getValue(), t = toApi.getValue();
608
- if (f && t && t < f) toApi.clear();
621
+ if (f && t && t < f) clearEnd(toEl, toApi);
609
622
  sync();
623
+ settle();
610
624
  });
611
625
  toEl.addEventListener('fdy-datepicker-change', function () {
612
626
  var f = fromApi.getValue(), t = toApi.getValue();
613
- if (f && t && t < f) fromApi.clear();
627
+ if (f && t && t < f) clearEnd(fromEl, fromApi);
614
628
  sync();
629
+ settle();
615
630
  });
616
631
  sync();
617
632
  }
@@ -13,7 +13,7 @@
13
13
  * data-fdy-msg-min | -max | -step | -mismatch, then generic data-fdy-msg.
14
14
  * Cross-field match (e.g. confirm password): data-fdy-match="#otherId".
15
15
  *
16
- * Timing: validates on submit (blocks + focuses the first invalid control); after
16
+ * Timing: validates on submit (blocks, reveals + focuses the first invalid control); after
17
17
  * the first submit attempt each field re-validates on input/blur so errors clear
18
18
  * live. Add data-fdy-eager to validate on blur from the start.
19
19
  *
@@ -126,6 +126,34 @@
126
126
  }
127
127
  }
128
128
 
129
+ /* focus() on a control that is not rendered does nothing and reports nothing: the field turns red
130
+ * where nobody can see it, and the submit reads as a dead button (#059). So whatever hides the
131
+ * control is undone first, each through that component's own control, so its state agrees with
132
+ * what is shown:
133
+ * closed <details> opened (accordion, tree, nav group)
134
+ * hidden [role=tabpanel] its tab is clicked; freeday-tabs selects on click
135
+ * hidden .fdy-step-panel its step button is clicked. A step not yet reached has a disabled
136
+ * button, so this only ever goes BACK and never skips a step guard.
137
+ * Anything else that hides it belongs to the app, and is left alone. */
138
+ function reveal(el) {
139
+ for (var node = el.parentElement; node; node = node.parentElement) {
140
+ if (node.localName === 'details' && !node.open) {
141
+ node.open = true;
142
+ } else if (node.hidden && node.getAttribute('role') === 'tabpanel' && node.id) {
143
+ var tab = Array.prototype.filter.call(document.querySelectorAll('[role="tab"]'), function (t) {
144
+ return t.getAttribute('aria-controls') === node.id;
145
+ })[0];
146
+ if (tab) tab.click();
147
+ } else if (node.hidden && node.classList.contains('fdy-step-panel')) {
148
+ var stepper = node.closest('[data-fdy-stepper]');
149
+ if (!stepper) continue;
150
+ var index = Array.prototype.indexOf.call(stepper.querySelectorAll('.fdy-step-panel'), node);
151
+ var btn = stepper.querySelectorAll('.fdy-step__btn')[index];
152
+ if (btn && !btn.disabled) btn.click();
153
+ }
154
+ }
155
+ }
156
+
129
157
  function initForm(form) {
130
158
  if (form.dataset.fdyFormReady === '1') return;
131
159
  form.dataset.fdyFormReady = '1';
@@ -160,6 +188,7 @@
160
188
  });
161
189
  if (invalid.length) {
162
190
  e.preventDefault();
191
+ reveal(invalid[0]);
163
192
  invalid[0].focus();
164
193
  form.dispatchEvent(new CustomEvent('fdy-form-invalid', { bubbles: true, detail: { invalid: invalid } }));
165
194
  } else {
@@ -0,0 +1,77 @@
1
+ /* Freeday, list search enhancer (optional, zero-dependency). Note #061.
2
+ * Turns an .fdy-input-group into a search box that searches while the reader pauses, at once on
3
+ * Enter, and has a clear button. All three paths end in ONE bubbling event on the group,
4
+ * "fdy-search" {term}, so an app listens once instead of wiring keyup, a timer and a click.
5
+ *
6
+ * Markup (the group, not a new block; the clear button is yours, so is its aria-label):
7
+ * <div class="fdy-input-group" data-fdy-search [data-fdy-search-delay="400"]>
8
+ * <span class="fdy-input-group__addon fdy-input-group__addon--icon">…svg…</span>
9
+ * <input class="fdy-input" type="text" aria-label="Search orders">
10
+ * <button type="button" class="fdy-input-group__btn" data-fdy-search-clear aria-label="Clear search">×</button>
11
+ * </div>
12
+ *
13
+ * type="text", not "search": WebKit's own × is unthemed and fires no Enter, which is what made
14
+ * clearing a field leave the old rows on screen until somebody pressed Enter on an empty box.
15
+ * The clear button is hidden while the box is empty. Clearing writes through the native value
16
+ * setter and fires a native `input`, so v-model, a React onChange and Blazor @bind all follow.
17
+ * `term` is trimmed; "" means "show everything again". Exposes window.FreedaySearch.
18
+ */
19
+ (function () {
20
+ 'use strict';
21
+
22
+ var DEFAULT_DELAY = 400;
23
+ var nativeValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
24
+
25
+ function initSearch(root) {
26
+ if (root.dataset.fdySearchReady === '1') return;
27
+ var input = root.querySelector('input');
28
+ if (!input) return;
29
+ root.dataset.fdySearchReady = '1';
30
+ var clearBtn = root.querySelector('[data-fdy-search-clear]');
31
+ var delayAttr = parseInt(root.getAttribute('data-fdy-search-delay'), 10);
32
+ var delay = isNaN(delayAttr) ? DEFAULT_DELAY : delayAttr;
33
+ var timer = null;
34
+
35
+ function cancel() { if (timer !== null) { clearTimeout(timer); timer = null; } }
36
+ function emit() {
37
+ cancel();
38
+ root.dispatchEvent(new CustomEvent('fdy-search', { bubbles: true, detail: { term: input.value.trim() } }));
39
+ }
40
+ function syncClear() { if (clearBtn) clearBtn.hidden = input.value === ''; }
41
+
42
+ input.addEventListener('input', function () {
43
+ syncClear();
44
+ cancel();
45
+ timer = setTimeout(emit, delay);
46
+ });
47
+ input.addEventListener('keydown', function (e) {
48
+ if (e.key !== 'Enter') return;
49
+ e.preventDefault(); // inside a <form>, Enter would submit it as well
50
+ emit();
51
+ });
52
+ if (clearBtn) {
53
+ clearBtn.addEventListener('click', function () {
54
+ nativeValue.call(input, '');
55
+ input.dispatchEvent(new Event('input', { bubbles: true }));
56
+ emit(); // at once, and it cancels the pause the input event just started
57
+ input.focus();
58
+ });
59
+ }
60
+ syncClear();
61
+ }
62
+
63
+ function initAll(context) {
64
+ var root = context || document;
65
+ /* root included: querySelectorAll never matches its own root, and a framework ref often sits ON the widget. */
66
+ if (root.matches && root.matches('[data-fdy-search]')) initSearch(root);
67
+ Array.prototype.forEach.call(root.querySelectorAll('[data-fdy-search]'), initSearch);
68
+ }
69
+
70
+ if (document.readyState === 'loading') {
71
+ document.addEventListener('DOMContentLoaded', function () { initAll(); });
72
+ } else {
73
+ initAll();
74
+ }
75
+
76
+ window.FreedaySearch = { init: initSearch, initAll: initAll };
77
+ })();
@@ -1151,6 +1151,8 @@ a { color: var(--color-primary); }
1151
1151
  .fdy-avatar--xs{width:1.5rem;height:1.5rem;font-size:var(--text-xs);}
1152
1152
  .fdy-avatar--sm{width:2rem;height:2rem;font-size:var(--text-xs);}
1153
1153
  .fdy-avatar--lg{width:3.5rem;height:3.5rem;font-size:var(--text-lg);}
1154
+ /* Square: a mark that is not a person (a company logo, a card's icon chip, #070). */
1155
+ .fdy-avatar--square{border-radius:var(--radius-md);}
1154
1156
  .fdy-avatar-group{display:inline-flex;}
1155
1157
  .fdy-avatar-group .fdy-avatar{border:2px solid var(--color-surface);margin-left:calc(var(--space-2)*-1);}
1156
1158
  .fdy-avatar-group .fdy-avatar:first-child{margin-left:0;}
@@ -1487,6 +1489,13 @@ a { color: var(--color-primary); }
1487
1489
  absolutely positioned dismiss button back into the flow. Do not merge these two rules. */
1488
1490
  :where(:is(.fdy-card,.fdy-list__row):has(.fdy-btn--stretch) :is(a[href],button,input,select,textarea,[tabindex]):not(.fdy-btn--stretch)){position:relative;}
1489
1491
  .fdy-card__body{padding:var(--space-5);}
1492
+ /* Header band (#070): an identity mark, the title, and the card's way out pinned right. Beside the
1493
+ title the action sits in one place on every card; under the content it moved each time the
1494
+ content grew. Outside __body's padding, so its rule spans the card edge to edge. The mark is an
1495
+ .fdy-avatar (--sm --square --tone-N), so its tints are the ones the contrast gate already covers. */
1496
+ .fdy-card__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4) var(--space-5);border-bottom:var(--bw) solid var(--color-border-muted);}
1497
+ .fdy-card__head .fdy-card__title{flex:1 1 auto;min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1498
+ .fdy-card__action{flex:none;display:inline-flex;align-items:center;gap:var(--space-2);}
1490
1499
  .fdy-card__title{font-family:var(--font-display);font-size:var(--text-lg);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);color:var(--color-text);margin:0 0 var(--space-1);}
1491
1500
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
1492
1501
  .fdy-card__footer{padding:var(--space-3) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:linear-gradient(180deg,var(--color-surface),var(--color-surface-2));display:flex;gap:var(--space-2);justify-content:flex-end;}
@@ -1869,6 +1878,42 @@ a { color: var(--color-primary); }
1869
1878
  .fdy-text-success{color:var(--color-success-strong);}
1870
1879
  .fdy-text-warning{color:var(--color-warning-strong);}
1871
1880
  .fdy-text-danger{color:var(--color-danger-strong);}
1881
+ /* Document columns (#065): a form or record page with an optional identity rail on the left
1882
+ * (__lead) and a summary rail on the right (__aside). The widths follow which rails are PRESENT,
1883
+ * so the layout is decided once here and not again by every screen:
1884
+ * __main alone one column
1885
+ * __main + __aside 8 / 4 (from md)
1886
+ * __lead + __main 3 / 9 (from md)
1887
+ * all three 3 / 6 / 3 (from lg; between md and lg both rails share the left)
1888
+ * Stacked, the summary rail comes FIRST: last, it sits under every section, and its button with it.
1889
+ * From md each rail is sticky and scrolls on its own, so the total and the status stay answerable
1890
+ * while the main column scrolls with the page, and a rail taller than the screen never hangs its
1891
+ * end off it. Screen only: a printed page is not held to a window. */
1892
+ .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,var(--space-4));display:grid;grid-template-columns:minmax(0,1fr);gap:var(--space-6);align-items:start;}
1893
+ /* Under the shell the sticky topbar is in the way; --fdy-page-sticky-top overrides either default. */
1894
+ .fdy-app .fdy-page__columns{--_fdy-rail-top:var(--fdy-page-sticky-top,calc(var(--space-16) + var(--space-4)));}
1895
+ .fdy-page__columns > *{min-width:0;}
1896
+ @media (max-width:959.98px){.fdy-page__aside{order:-1;}}
1897
+ @media (min-width:960px){
1898
+ .fdy-page__columns:has(> .fdy-page__aside){grid-template-columns:minmax(0,2fr) minmax(0,1fr);}
1899
+ .fdy-page__columns:has(> .fdy-page__lead){grid-template-columns:minmax(0,1fr) minmax(0,3fr);}
1900
+ /* Both rails, two columns: they stack on the left while __main spans both rows on the right. */
1901
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:span 2;}
1902
+ }
1903
+ @media (min-width:1280px){
1904
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside){grid-template-columns:minmax(0,1fr) minmax(0,2fr) minmax(0,1fr);}
1905
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > .fdy-page__main{grid-row:auto;}
1906
+ }
1907
+ /* The padding, taken back by the margin, is room for a focus ring at a card's edge: a scrolling box
1908
+ clips whatever draws outside it. */
1909
+ @media screen and (min-width:960px){
1910
+ .fdy-page__lead,.fdy-page__aside{position:sticky;top:var(--_fdy-rail-top);max-height:calc(100dvh - var(--_fdy-rail-top) - var(--space-4));overflow-y:auto;overscroll-behavior:contain;padding:var(--space-1);margin:calc(var(--space-1) * -1);}
1911
+ }
1912
+ /* Two sticky rails in ONE column would slide over each other: between md and lg, with both rails
1913
+ present, they scroll with the page instead. */
1914
+ @media screen and (min-width:960px) and (max-width:1279.98px){
1915
+ .fdy-page__columns:has(> .fdy-page__lead):has(> .fdy-page__aside) > :is(.fdy-page__lead,.fdy-page__aside){position:static;max-height:none;overflow:visible;}
1916
+ }
1872
1917
 
1873
1918
  /* Freeday. Date picker (input-styled trigger + calendar popover).
1874
1919
  * Built by freeday-datepicker.js from an empty [data-fdy-datepicker] wrapper. */
@@ -1964,9 +2009,19 @@ a { color: var(--color-primary); }
1964
2009
  /* Freeday. Description list (key-value detail view) */
1965
2010
  .fdy-dl{display:grid;grid-template-columns:auto 1fr;gap:var(--space-2) var(--space-5);margin:0;}
1966
2011
  .fdy-dl dt{color:var(--color-text-muted);font-size:var(--text-sm);}
1967
- .fdy-dl dd{margin:0;color:var(--color-text);font-size:var(--text-sm);font-weight:var(--weight-medium);}
2012
+ /* overflow-wrap: an email, an order code or a 13-digit phone number is one unbreakable token, and
2013
+ without it the value prints over whatever sits beside it (#066). */
2014
+ .fdy-dl dd{margin:0;min-width:0;color:var(--color-text);font-size:var(--text-sm);font-weight:var(--weight-medium);overflow-wrap:anywhere;}
1968
2015
  .fdy-dl--rows{grid-template-columns:1fr;gap:var(--space-1);}
1969
2016
  .fdy-dl--rows dt{margin-top:var(--space-2);}
2017
+ /* Facts grid (#066): each pair a <div> (allowed inside <dl>), caption above value, as many columns
2018
+ as the list's OWN width holds, never more than three. Sized by the element rather than the
2019
+ window, so a grid in a narrow side rail stays one column with no modifier: a window breakpoint
2020
+ gave a rail two columns of about 110px. min(100%, …) keeps a single column from overflowing a
2021
+ rail narrower than the column floor. */
2022
+ .fdy-dl--grid{grid-template-columns:repeat(auto-fill,minmax(min(100%,max(var(--fdy-dl-col-min,14rem),calc((100% - 2 * var(--space-6)) / 3))),1fr));gap:var(--space-5) var(--space-6);}
2023
+ .fdy-dl--grid > div{min-width:0;}
2024
+ .fdy-dl--grid dd{margin-top:var(--space-1);}
1970
2025
 
1971
2026
  /* Freeday. Divider */
1972
2027
  .fdy-divider{border:0;border-top:var(--bw) solid var(--color-border);margin:var(--space-4) 0;}
@@ -2358,6 +2413,12 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2358
2413
  .fdy-switch input::before{content:"";position:absolute;top:2px;left:2px;width:calc(var(--control-switch-h) - 4px);height:calc(var(--control-switch-h) - 4px);border-radius:50%;background:var(--color-switch-thumb);box-shadow:var(--shadow-1);transition:transform var(--dur-base) var(--ease-spring);}
2359
2414
  .fdy-switch input:checked{background:var(--color-primary);}
2360
2415
  .fdy-switch input:checked::before{transform:translateX(calc(var(--control-switch-w) - var(--control-switch-h)));}
2416
+ /* State hint (#068): one line saying what the CURRENT state means, never "on means X, off means Y",
2417
+ which leaves the reader to work out which half applies. The control's aria-describedby points at
2418
+ the .fdy-help WRAPPING both lines: a hidden child of a referenced node is skipped, so a screen
2419
+ reader hears only the line on screen. Point it at the two children and both are read. */
2420
+ .fdy-field:has(:is(.fdy-switch,.fdy-check) input:checked) [data-fdy-when="off"],
2421
+ .fdy-field:not(:has(:is(.fdy-switch,.fdy-check) input:checked)) [data-fdy-when="on"]{display:none;}
2361
2422
 
2362
2423
  /* Standalone checkbox (e.g. table row/select-all), supports :indeterminate */
2363
2424
  .fdy-checkbox{appearance:none;-webkit-appearance:none;flex:none;width:var(--control-box);height:var(--control-box);margin:0;border:1.5px solid var(--color-control-border);border-radius:var(--radius-sm);background:var(--color-surface);box-shadow:inset 0 1px 2px var(--color-shadow-inset);cursor:pointer;display:grid;place-content:center;transition:background var(--dur-fast) var(--ease-standard),border-color var(--dur-fast) var(--ease-standard);}
@@ -2409,6 +2470,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2409
2470
  .fdy-state__text{font-size:var(--text-sm);color:var(--color-text-muted);max-width:24rem;line-height:var(--leading-normal);}
2410
2471
  .fdy-state__actions{margin-top:var(--space-2);display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:center;}
2411
2472
  .fdy-state--danger .fdy-state__icon{color:var(--color-danger-strong);}
2473
+ /* Compact (#069): the same state inside a small card, one line of icon and text instead of a
2474
+ page-sized block. Danger matters most here: a widget that renders zero for a failed request
2475
+ reads as "nothing to do", the opposite of what happened. */
2476
+ .fdy-state--compact{flex-direction:row;align-items:flex-start;text-align:left;gap:var(--space-2);padding:var(--space-2) 0;}
2477
+ .fdy-state--compact .fdy-state__icon{width:1rem;height:1rem;flex:none;}
2478
+ .fdy-state--compact .fdy-state__text{font-size:var(--text-xs);max-width:none;}
2479
+ .fdy-state--compact .fdy-state__actions{margin:0 0 0 auto;}
2412
2480
 
2413
2481
  /* Freeday. Stepper / Wizard (progress header + optional panels).
2414
2482
  * Enhanced by freeday-stepper.js for prev/next navigation. */
@@ -2465,8 +2533,17 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2465
2533
  itself, for the same reason: hover paints the full step, so the two must not be the same colour.
2466
2534
  Exposed as a Tier-3 token through the var() FALLBACK, never as a declaration — a custom property
2467
2535
  set here would outrank a host's own :root override. */
2468
- .fdy-table--striped tbody tr:nth-child(even){background:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));}
2469
- .fdy-table tbody tr:hover{background:var(--color-surface-2);}
2536
+ /* The resting colour is also published as --_fdy-row-rest, so a row that must not answer the
2537
+ pointer can hover back to exactly what it was (see --hover-actionable). */
2538
+ .fdy-table--striped tbody tr:nth-child(even){--_fdy-row-rest:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));background:var(--_fdy-row-rest);}
2539
+ .fdy-table tbody tr:hover{background:var(--_fdy-row-hover,var(--color-surface-2));}
2540
+ /* Opt-in (#064): only a row that DOES something answers the pointer. A tint on a row with no click
2541
+ handler promises a target that is not there: people click the row, nothing happens, and the
2542
+ button they had to aim at was elsewhere in it. Striping, not hover, is what keeps the eye on a
2543
+ line. The inert row's hover resolves to its own resting colour, so a stripe survives it; a frozen
2544
+ cell falls back to its opaque surface, because it scrolls over its neighbours. */
2545
+ .fdy-table--hover-actionable tbody tr:not(.fdy-table__row--activatable){--_fdy-row-hover:var(--_fdy-row-rest,transparent);}
2546
+ .fdy-table--hover-actionable tbody tr:not(.fdy-table__row--activatable) td.fdy-table__freeze{--_fdy-row-hover:var(--_fdy-row-rest,var(--color-surface));}
2470
2547
  /* Monospace data, ids, codes, IPs, timestamps. Alignment-neutral so it works in any cell
2471
2548
  or inline (`<span class="fdy-mono">`), not just numeric columns. */
2472
2549
  .fdy-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
@@ -2475,8 +2552,8 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2475
2552
  .fdy-table__num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);}
2476
2553
  /* Loading / empty state cell (FdyTable spans it across all columns). */
2477
2554
  .fdy-table__state{text-align:center;color:var(--color-text-muted);padding:var(--space-6) var(--space-4);}
2478
- /* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint already
2479
- comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
2555
+ /* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint comes from
2556
+ the base `.fdy-table tbody tr:hover` rule, which --hover-actionable keeps for these rows only. */
2480
2557
  .fdy-table__row--activatable{cursor:pointer;}
2481
2558
  .fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
2482
2559
  /* Expandable detail row (FdyTable row-detail slot): a muted panel under its row, no hover tint. */
@@ -2536,7 +2613,7 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
2536
2613
  (0,4,3) and beat the hover rule below. At (0,3,3) it ties, and the later rule wins, so a hovered
2537
2614
  row still bands unbroken across the freeze line. */
2538
2615
  .fdy-table--striped tbody tr:nth-child(even) td.fdy-table__freeze{background:var(--fdy-table-stripe,color-mix(in srgb,var(--color-surface-2) 55%,var(--color-surface)));}
2539
- .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--color-surface-2);}
2616
+ .fdy-table--sticky tbody tr:hover td.fdy-table__freeze{background:var(--_fdy-row-hover,var(--color-surface-2));}
2540
2617
  /* A frozen header cell is stuck on both axes wherever the head is frozen too, so
2541
2618
  it has to outrank both. */
2542
2619
  .fdy-table--sticky thead .fdy-table__freeze{z-index:3;}