@cahyo-dimas/freeday 3.4.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/COMPONENTS.md +278 -12
  3. package/README.id.md +9 -5
  4. package/README.md +9 -5
  5. package/adapters/blazor/FdyDateRange.razor.cs +10 -0
  6. package/adapters/blazor/FdyTable.razor +6 -2
  7. package/adapters/blazor/FdyTable.razor.cs +21 -0
  8. package/adapters/blazor/TableTypes.cs +4 -0
  9. package/adapters/react/components/FdyDateRange.tsx +8 -2
  10. package/adapters/react/components/FdyTable.tsx +24 -3
  11. package/adapters/react/index.d.ts +1 -1
  12. package/adapters/vue/components/FdyDateRange.vue +11 -5
  13. package/adapters/vue/components/FdyTable.vue +18 -2
  14. package/dist/freeday-confirm.js +101 -0
  15. package/dist/freeday-copy.js +58 -0
  16. package/dist/freeday-datepicker.js +17 -2
  17. package/dist/freeday-form.js +30 -1
  18. package/dist/freeday-search.js +77 -0
  19. package/dist/freeday.bundle.css +98 -8
  20. package/dist/freeday.css +98 -8
  21. package/dist/freeday.d.ts +17 -2
  22. package/dist/freeday.js +286 -3
  23. package/dist/freeday.media.css +27 -0
  24. package/docs/agent-onboarding.md +10 -0
  25. package/docs/getting-started.md +1 -1
  26. package/package.json +6 -2
  27. package/src/components/avatar.css +2 -0
  28. package/src/components/breakpoints.css +7 -1
  29. package/src/components/card.css +7 -0
  30. package/src/components/composition.css +36 -0
  31. package/src/components/description-list.css +11 -1
  32. package/src/components/filterbar.css +8 -1
  33. package/src/components/selection.css +6 -0
  34. package/src/components/states.css +7 -0
  35. package/src/components/table.css +14 -5
  36. package/tokens/breakpoints.d.ts +4 -0
  37. package/tokens/breakpoints.mjs +22 -8
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > **More free days for devs. The UI kit is ready to use.**
6
6
 
7
7
  [![Live docs](https://img.shields.io/badge/docs-live-2050d8?style=flat-square)](https://cahyo-dimas.github.io/freeday-ui-kit/)
8
- [![Release](https://img.shields.io/badge/release-v3.4.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.4.0)
8
+ [![Release](https://img.shields.io/badge/release-v3.6.0-0078d4?style=flat-square)](https://github.com/cahyo-dimas/freeday-ui-kit/tree/v3.6.0)
9
9
 
10
10
  A token-driven, framework-agnostic UI kit. One source of truth for color, typography,
11
11
  spacing, and components. Blueprint: `docs/superpowers/specs/2026-07-21-freeday-ui-kit-design.md`.
@@ -61,7 +61,7 @@ npm i @cahyo-dimas/freeday
61
61
  ```js
62
62
  import '@cahyo-dimas/freeday/css'; // tokens + components (single file)
63
63
  import '@cahyo-dimas/freeday'; // all JS enhancers (auto-init [data-fdy-*])
64
- // granular if needed: '@cahyo-dimas/freeday/tokens' · '@cahyo-dimas/freeday/css/components' · '@cahyo-dimas/freeday/enhancers/<name>'
64
+ // granular if needed: '@cahyo-dimas/freeday/tokens' · '@cahyo-dimas/freeday/css/components' · '@cahyo-dimas/freeday/enhancers/<name>' · '@cahyo-dimas/freeday/media' (@custom-media breakpoints)
65
65
  ```
66
66
  Set the theme on your app root: `<html data-theme="light" data-density="comfortable">`. `dist/` is
67
67
  committed and published, so install needs **no build step**; minification is left to the consumer's
@@ -154,9 +154,13 @@ of scrolling.
154
154
  to shrink or grow when `data-density` changes.
155
155
  - (roadmap) `data-style` for alternative visual variants.
156
156
  - The breakpoint scale (`sm`/`md`/`lg`/`xl` = 600/960/1280/1920px, matching the
157
- `src/components/breakpoints.css` utilities) is also available in JS: `import { breakpoints } from
158
- '@cahyo-dimas/freeday/breakpoints'`. Use it to keep your app's `matchMedia`/`@media` on the same
159
- scale as Freeday's.
157
+ `src/components/breakpoints.css` utilities) is available to your app in both languages: `import
158
+ { breakpoints } from '@cahyo-dimas/freeday/breakpoints'` for JS (`matchMedia`, utility variants),
159
+ and `@import '@cahyo-dimas/freeday/media'` for CSS, which declares each one as a `@custom-media`
160
+ so a rule reads `@media (--fdy-below-sm)` instead of a retyped literal (needs PostCSS; opt-in).
161
+ The object also carries the two widths that are **not** ramp steps and must not be rounded to one:
162
+ `nav` (721, the shell's drawer↔sidebar switch) and `filterbar` (640, where `.fdy-filterbar`
163
+ stacks). See [`COMPONENTS.md`](COMPONENTS.md) for what each governs.
160
164
 
161
165
  ## Framework integration (SPA)
162
166
  > **Full library map:** [`docs/integrations.md`](docs/integrations.md). Each area (forms/validation,
@@ -13,6 +13,9 @@ public partial class FdyDateRange
13
13
  [Parameter] public string? To { get; set; }
14
14
  [Parameter] public EventCallback<string?> ToChanged { get; set; }
15
15
 
16
+ /// <summary>Both ends chosen, or both cleared: the only states worth reloading a list for (#063).</summary>
17
+ [Parameter] public EventCallback<SettledRange> Settled { get; set; }
18
+
16
19
  [Parameter] public string? AriaLabel { get; set; }
17
20
  [Parameter] public string? FromPlaceholder { get; set; }
18
21
  [Parameter] public string? ToPlaceholder { get; set; }
@@ -44,6 +47,10 @@ public partial class FdyDateRange
44
47
  To = detail.Value;
45
48
  await ToChanged.InvokeAsync(detail.Value);
46
49
  }
50
+ if (string.IsNullOrEmpty(From) == string.IsNullOrEmpty(To))
51
+ {
52
+ await Settled.InvokeAsync(new SettledRange(From, To));
53
+ }
47
54
  }
48
55
 
49
56
  protected override async ValueTask CleanupAsync()
@@ -55,4 +62,7 @@ public partial class FdyDateRange
55
62
  }
56
63
 
57
64
  public sealed record RangeDetail(string? Value, string? Role);
65
+
66
+ /// <summary>The range as it stands when it settles; both empty means "no period".</summary>
67
+ public sealed record SettledRange(string? From, string? To);
58
68
  }
@@ -30,7 +30,7 @@
30
30
  }
31
31
 
32
32
  <div class="fdy-table-scroll">
33
- <table class="fdy-table @(Striped ? "fdy-table--striped" : null)" aria-label="@AriaLabel">
33
+ <table class="fdy-table @(Striped ? "fdy-table--striped" : null) @(HoverActionable ? "fdy-table--hover-actionable" : null)" aria-label="@AriaLabel">
34
34
  <thead>
35
35
  <tr>
36
36
  @if (Selectable)
@@ -77,7 +77,11 @@
77
77
  {
78
78
  <tr>
79
79
  <td colspan="@ColSpan" class="fdy-table__state">
80
- @if (EmptyContent is not null)
80
+ @if (EmptyTemplate is not null)
81
+ {
82
+ @EmptyTemplate(new FdyTableEmptyContext(Filtered, ClearFiltersAsync))
83
+ }
84
+ else if (EmptyContent is not null)
81
85
  {
82
86
  @EmptyContent
83
87
  }
@@ -71,6 +71,9 @@ public partial class FdyTable<TRow>
71
71
  [Parameter] public string LoadingText { get; set; } = "Loading…";
72
72
  [Parameter] public string EmptyText { get; set; } = "No data";
73
73
  [Parameter] public RenderFragment? EmptyContent { get; set; }
74
+ /// <summary>The empty state, told whether the column filters emptied the table and how to clear
75
+ /// them (#069). Wins over <see cref="EmptyContent"/> when both are set.</summary>
76
+ [Parameter] public RenderFragment<FdyTableEmptyContext>? EmptyTemplate { get; set; }
74
77
  [Parameter] public string? AriaLabel { get; set; }
75
78
 
76
79
  /// <summary>Opt in to row activation: rows become focusable and raise <see cref="RowActivate"/>.</summary>
@@ -90,6 +93,9 @@ public partial class FdyTable<TRow>
90
93
  /// <summary>Zebra-stripe the body rows.</summary>
91
94
  [Parameter] public bool Striped { get; set; }
92
95
 
96
+ /// <summary>Hover only the rows that do something (<c>RowActivatable</c>); inert rows keep their colour.</summary>
97
+ [Parameter] public bool HoverActionable { get; set; }
98
+
93
99
  /// <summary>Render the checkbox column and the bulk bar.</summary>
94
100
  [Parameter] public bool Selectable { get; set; }
95
101
 
@@ -234,6 +240,21 @@ public partial class FdyTable<TRow>
234
240
  }
235
241
  }
236
242
 
243
+ private bool Filtered => EffectiveFilters.Values.Any(TableModel.IsFilterActive);
244
+
245
+ private async Task ClearFiltersAsync()
246
+ {
247
+ if (FiltersControlled)
248
+ {
249
+ await FiltersChanged.InvokeAsync(new Dictionary<string, FdyColumnFilter>());
250
+ }
251
+ else
252
+ {
253
+ _internalFilters = new();
254
+ await SetClientPageAsync(0);
255
+ }
256
+ }
257
+
237
258
  private async Task OnFilterChange(FdyTableColumn<TRow> col, FdyColumnFilter? filter)
238
259
  {
239
260
  Dictionary<string, FdyColumnFilter> next = new(EffectiveFilters);
@@ -21,6 +21,10 @@ public sealed record FdySortState(string Key, FdySortDir Dir);
21
21
  /// <see cref="FdyTable{TRow}.Page"/> switches the table into server mode.</summary>
22
22
  public sealed record FdyPageState(int Index, int Size, int Total);
23
23
 
24
+ /// <summary>What <c>EmptyTemplate</c> is told (#069): whether an active column filter emptied the
25
+ /// table, and how to clear every filter.</summary>
26
+ public sealed record FdyTableEmptyContext(bool Filtered, Func<Task> ClearFilters);
27
+
24
28
  /// <summary>The processed page of rows (after filter/sort/paginate) plus the total row count, as
25
29
  /// raised by <see cref="FdyTable{TRow}.Process"/>. Lets a consumer render the same processed set
26
30
  /// somewhere else, a card list below the <c>md</c> breakpoint, a summary, an export, without
@@ -15,6 +15,8 @@ export interface DateRangeValue {
15
15
  export interface FdyDateRangeProps {
16
16
  value: DateRangeValue;
17
17
  onChange: (value: DateRangeValue) => void;
18
+ /** Both ends chosen, or both cleared: the only states worth reloading a list for (#063). */
19
+ onSettled?: (value: DateRangeValue) => void;
18
20
  min?: string;
19
21
  max?: string;
20
22
  locale?: string;
@@ -30,8 +32,12 @@ export interface FdyDateRangeProps {
30
32
  }
31
33
 
32
34
  export function FdyDateRange(props: FdyDateRangeProps): JSX.Element {
33
- const onStart = (start: string): void => props.onChange({ start, end: props.value.end });
34
- const onEnd = (end: string): void => props.onChange({ start: props.value.start, end });
35
+ const commit = (next: DateRangeValue): void => {
36
+ props.onChange(next);
37
+ if (!next.start === !next.end) props.onSettled?.(next);
38
+ };
39
+ const onStart = (start: string): void => commit({ start, end: props.value.end });
40
+ const onEnd = (end: string): void => commit({ start: props.value.start, end });
35
41
 
36
42
  return (
37
43
  <div
@@ -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 {