@cahyo-dimas/freeday 1.15.0 → 1.17.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 (48) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.id.md +18 -8
  3. package/README.md +18 -8
  4. package/adapters/blazor/CflTypes.cs +11 -0
  5. package/adapters/blazor/FdyAutocomplete.razor +19 -0
  6. package/adapters/blazor/FdyAutocomplete.razor.cs +40 -0
  7. package/adapters/blazor/FdyCascade.razor +33 -0
  8. package/adapters/blazor/FdyCascade.razor.cs +38 -0
  9. package/adapters/blazor/FdyCascadeNode.cs +8 -0
  10. package/adapters/blazor/FdyCfl.razor +93 -0
  11. package/adapters/blazor/FdyCfl.razor.cs +146 -0
  12. package/adapters/blazor/FdyChart.razor +25 -0
  13. package/adapters/blazor/FdyChart.razor.cs +90 -0
  14. package/adapters/blazor/FdyChartSeries.cs +10 -0
  15. package/adapters/blazor/FdyCombo.razor +28 -0
  16. package/adapters/blazor/FdyCombo.razor.cs +86 -0
  17. package/adapters/blazor/FdyComboOption.cs +7 -0
  18. package/adapters/blazor/FdyDateRange.razor +16 -0
  19. package/adapters/blazor/FdyDateRange.razor.cs +58 -0
  20. package/adapters/blazor/FdyDatepicker.razor +13 -0
  21. package/adapters/blazor/FdyDatepicker.razor.cs +39 -0
  22. package/adapters/blazor/FdyDrawer.razor +36 -0
  23. package/adapters/blazor/FdyDrawer.razor.cs +88 -0
  24. package/adapters/blazor/FdyModal.razor +36 -0
  25. package/adapters/blazor/FdyModal.razor.cs +88 -0
  26. package/adapters/blazor/FdyTable.razor +140 -0
  27. package/adapters/blazor/FdyTable.razor.cs +200 -0
  28. package/adapters/blazor/FdyTableFilter.razor +77 -0
  29. package/adapters/blazor/FdyTableFilter.razor.cs +117 -0
  30. package/adapters/blazor/Freeday.Blazor.csproj +27 -0
  31. package/adapters/blazor/FreedayComponentBase.cs +73 -0
  32. package/adapters/blazor/TableModel.cs +196 -0
  33. package/adapters/blazor/TableTypes.cs +69 -0
  34. package/adapters/blazor/_Imports.razor +5 -0
  35. package/adapters/blazor/freeday-blazor.js +101 -3
  36. package/dist/freeday-select.js +32 -8
  37. package/dist/freeday-toast.js +22 -1
  38. package/dist/freeday.bundle.css +27 -0
  39. package/dist/freeday.css +27 -0
  40. package/dist/freeday.d.ts +5 -0
  41. package/dist/freeday.js +54 -9
  42. package/package.json +1 -1
  43. package/src/base.css +5 -0
  44. package/src/components/avatar.css +13 -0
  45. package/src/components/card.css +4 -0
  46. package/src/components/skeleton.css +5 -0
  47. package/src/freeday-select.js +32 -8
  48. package/src/freeday-toast.js +22 -1
@@ -0,0 +1,196 @@
1
+ using System.Collections.Concurrent;
2
+ using System.Globalization;
3
+ using System.Reflection;
4
+ using System.Text.RegularExpressions;
5
+
6
+ namespace Freeday.Blazor;
7
+
8
+ /// <summary>
9
+ /// The sort / filter / paginate logic for <see cref="FdyTable{TRow}"/> — the C# counterpart of the
10
+ /// framework-agnostic adapters/core/table-model.js shared by the Vue and React adapters. Kept as a
11
+ /// separate pure-function class (no rendering, no state) so it mirrors that reference faithfully and
12
+ /// stays unit-testable. Every method is pure: inputs are never mutated (rows are copied before sort).
13
+ /// </summary>
14
+ public static class TableModel
15
+ {
16
+ private static readonly ConcurrentDictionary<(Type, string), PropertyInfo?> PropCache = new();
17
+ private static readonly Regex NonNumeric = new(@"[^\d.-]", RegexOptions.Compiled);
18
+
19
+ /// <summary>Numeric value, tolerant of formatted strings ("Rp 1.000"); non-numeric -> 0.</summary>
20
+ private static double ToNumber(object? v)
21
+ {
22
+ if (v is null) return 0;
23
+ if (v is double d) return double.IsFinite(d) ? d : 0;
24
+ if (v is IConvertible && v is not string)
25
+ {
26
+ try { return Convert.ToDouble(v, CultureInfo.InvariantCulture); } catch { /* fall through */ }
27
+ }
28
+ string cleaned = NonNumeric.Replace(v.ToString() ?? string.Empty, string.Empty);
29
+ return double.TryParse(cleaned, NumberStyles.Any, CultureInfo.InvariantCulture, out double n) ? n : 0;
30
+ }
31
+
32
+ /// <summary>Epoch ms; unparseable -> 0.</summary>
33
+ private static double ToTime(object? v)
34
+ {
35
+ if (v is null) return 0;
36
+ if (v is DateTime dt) return new DateTimeOffset(dt.ToUniversalTime()).ToUnixTimeMilliseconds();
37
+ if (v is DateTimeOffset dto) return dto.ToUnixTimeMilliseconds();
38
+ return DateTimeOffset.TryParse(v.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTimeOffset parsed)
39
+ ? parsed.ToUnixTimeMilliseconds()
40
+ : 0;
41
+ }
42
+
43
+ /// <summary>ISO calendar day (yyyy-MM-dd) — lexicographically comparable.</summary>
44
+ private static string DateOnly(object? v)
45
+ {
46
+ if (v is DateTime dt) return dt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
47
+ if (v is DateTimeOffset dto) return dto.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
48
+ string s = v?.ToString() ?? string.Empty;
49
+ return s.Length >= 10 ? s[..10] : s;
50
+ }
51
+
52
+ /// <summary>Raw cell value for a column: the accessor if present, else the property named Key.</summary>
53
+ public static object? CellValue<TRow>(TRow row, FdyTableColumn<TRow> column)
54
+ {
55
+ if (column.Value is not null) return column.Value(row);
56
+ PropertyInfo? prop = PropCache.GetOrAdd((typeof(TRow), column.Key),
57
+ static k => k.Item1.GetProperty(k.Item2, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase));
58
+ return prop?.GetValue(row);
59
+ }
60
+
61
+ /// <summary>Cell value as trimmed display text (null -> "").</summary>
62
+ public static string CellText<TRow>(TRow row, FdyTableColumn<TRow> column)
63
+ {
64
+ object? v = CellValue(row, column);
65
+ return v is null ? string.Empty : (Convert.ToString(v, CultureInfo.CurrentCulture) ?? string.Empty).Trim();
66
+ }
67
+
68
+ /// <summary>Effective comparator type: explicit SortType, else derived from the filter type.</summary>
69
+ public static FdyColumnType ColumnSortType<TRow>(FdyTableColumn<TRow> column)
70
+ {
71
+ if (column.SortType is { } t) return t;
72
+ return column.Filter switch
73
+ {
74
+ FdyColumnFilterType.Number => FdyColumnType.Number,
75
+ FdyColumnFilterType.Date => FdyColumnType.Date,
76
+ _ => FdyColumnType.Text,
77
+ };
78
+ }
79
+
80
+ /// <summary>Ascending comparison of two raw cell values under a comparator type.</summary>
81
+ public static int CompareBy(FdyColumnType type, object? a, object? b) => type switch
82
+ {
83
+ FdyColumnType.Number => ToNumber(a).CompareTo(ToNumber(b)),
84
+ FdyColumnType.Date => ToTime(a).CompareTo(ToTime(b)),
85
+ _ => string.Compare(a?.ToString() ?? string.Empty, b?.ToString() ?? string.Empty,
86
+ StringComparison.CurrentCultureIgnoreCase),
87
+ };
88
+
89
+ /// <summary>True when a filter would actually narrow the rows (worth applying / showing as active).</summary>
90
+ public static bool IsFilterActive(FdyColumnFilter? filter) => filter switch
91
+ {
92
+ FdyColumnFilter.Text t => t.Value.Trim().Length > 0,
93
+ FdyColumnFilter.Enum e => e.Values.Count > 0,
94
+ FdyColumnFilter.Number n => n.Min is not null || n.Max is not null,
95
+ FdyColumnFilter.Date d => d.From is not null || d.To is not null,
96
+ _ => false,
97
+ };
98
+
99
+ private static bool RowPassesFilter<TRow>(TRow row, FdyTableColumn<TRow> column, FdyColumnFilter filter)
100
+ {
101
+ switch (filter)
102
+ {
103
+ case FdyColumnFilter.Text t:
104
+ {
105
+ string needle = t.Value.Trim().ToLowerInvariant();
106
+ return needle.Length == 0 || CellText(row, column).ToLowerInvariant().Contains(needle);
107
+ }
108
+ case FdyColumnFilter.Enum e:
109
+ return e.Values.Count == 0 || e.Values.Contains(CellText(row, column));
110
+ case FdyColumnFilter.Number n:
111
+ {
112
+ double v = ToNumber(CellValue(row, column));
113
+ if (n.Min is { } min && v < min) return false;
114
+ if (n.Max is { } max && v > max) return false;
115
+ return true;
116
+ }
117
+ case FdyColumnFilter.Date d:
118
+ {
119
+ string day = DateOnly(CellValue(row, column));
120
+ if (d.From is { } from && string.CompareOrdinal(day, from) < 0) return false;
121
+ if (d.To is { } to && string.CompareOrdinal(day, to) > 0) return false;
122
+ return true;
123
+ }
124
+ default:
125
+ return true;
126
+ }
127
+ }
128
+
129
+ /// <summary>Rows passing every active column filter (logical AND). Input is never mutated.</summary>
130
+ public static List<TRow> FilterRows<TRow>(
131
+ IReadOnlyList<TRow> rows,
132
+ IReadOnlyList<FdyTableColumn<TRow>> columns,
133
+ IReadOnlyDictionary<string, FdyColumnFilter> filters)
134
+ {
135
+ List<(FdyTableColumn<TRow> Col, FdyColumnFilter Filter)> active = new();
136
+ foreach (FdyTableColumn<TRow> col in columns)
137
+ {
138
+ if (filters.TryGetValue(col.Key, out FdyColumnFilter? f) && IsFilterActive(f))
139
+ {
140
+ active.Add((col, f));
141
+ }
142
+ }
143
+ if (active.Count == 0) return rows.ToList();
144
+ return rows.Where(row => active.All(e => RowPassesFilter(row, e.Col, e.Filter))).ToList();
145
+ }
146
+
147
+ /// <summary>Rows sorted by the given sort state. Stable via OrderBy; input is never mutated.</summary>
148
+ public static List<TRow> SortRows<TRow>(
149
+ IReadOnlyList<TRow> rows,
150
+ IReadOnlyList<FdyTableColumn<TRow>> columns,
151
+ FdySortState? sort)
152
+ {
153
+ if (sort is null) return rows.ToList();
154
+ FdyTableColumn<TRow>? column = columns.FirstOrDefault(c => c.Key == sort.Key);
155
+ if (column is null) return rows.ToList();
156
+ FdyColumnType type = ColumnSortType(column);
157
+ int factor = sort.Dir == FdySortDir.Desc ? -1 : 1;
158
+ return rows
159
+ .OrderBy(r => r, Comparer<TRow>.Create((a, b) => CompareBy(type, CellValue(a, column), CellValue(b, column)) * factor))
160
+ .ToList();
161
+ }
162
+
163
+ /// <summary>Page slice (0-based page index). A non-positive size returns all rows.</summary>
164
+ public static List<TRow> Paginate<TRow>(IReadOnlyList<TRow> rows, int pageIndex, int pageSize)
165
+ {
166
+ if (pageSize <= 0) return rows.ToList();
167
+ return rows.Skip(pageIndex * pageSize).Take(pageSize).ToList();
168
+ }
169
+
170
+ /// <summary>Distinct non-empty cell texts for a column, naturally sorted — the enum-filter source.</summary>
171
+ public static List<string> DistinctValues<TRow>(IReadOnlyList<TRow> rows, FdyTableColumn<TRow> column)
172
+ {
173
+ HashSet<string> seen = new();
174
+ List<string> outList = new();
175
+ foreach (TRow row in rows)
176
+ {
177
+ string v = CellText(row, column);
178
+ if (v.Length > 0 && seen.Add(v)) outList.Add(v);
179
+ }
180
+ outList.Sort((a, b) => string.Compare(a, b, StringComparison.CurrentCultureIgnoreCase));
181
+ return outList;
182
+ }
183
+
184
+ /// <summary>Page-number window: first and last page always shown, current ±1, null for the
185
+ /// "ellipsis" gaps between. <paramref name="current"/> and returned page numbers are 1-based.</summary>
186
+ public static List<int?> PageWindow(int current, int totalPages)
187
+ {
188
+ List<int?> outList = new();
189
+ for (int p = 1; p <= totalPages; p++)
190
+ {
191
+ if (p == 1 || p == totalPages || (p >= current - 1 && p <= current + 1)) outList.Add(p);
192
+ else if (outList.Count == 0 || outList[^1] is not null) outList.Add(null);
193
+ }
194
+ return outList;
195
+ }
196
+ }
@@ -0,0 +1,69 @@
1
+ using Microsoft.AspNetCore.Components;
2
+
3
+ namespace Freeday.Blazor;
4
+
5
+ /// <summary>Sort direction for a column.</summary>
6
+ public enum FdySortDir { Asc, Desc }
7
+
8
+ /// <summary>A column's comparator type — how its values are ordered when sorting.</summary>
9
+ public enum FdyColumnType { Text, Number, Date }
10
+
11
+ /// <summary>Cell alignment. Maps to <c>text-align</c> on the header and body cells.</summary>
12
+ public enum FdyColumnAlign { Left, Right, Center }
13
+
14
+ /// <summary>The kind of column filter to offer (a funnel popover in the header).</summary>
15
+ public enum FdyColumnFilterType { Text, Enum, Number, Date }
16
+
17
+ /// <summary>Which column is sorted and in what direction.</summary>
18
+ public sealed record FdySortState(string Key, FdySortDir Dir);
19
+
20
+ /// <summary>Server-driven pagination state (0-based page index). Its presence on
21
+ /// <see cref="FdyTable{TRow}.Page"/> switches the table into server mode.</summary>
22
+ public sealed record FdyPageState(int Index, int Size, int Total);
23
+
24
+ /// <summary>The active filter for a single column (a closed set of shapes, one per filter type).</summary>
25
+ public abstract record FdyColumnFilter
26
+ {
27
+ public sealed record Text(string Value) : FdyColumnFilter;
28
+ public sealed record Enum(IReadOnlyList<string> Values) : FdyColumnFilter;
29
+ public sealed record Number(double? Min, double? Max) : FdyColumnFilter;
30
+ public sealed record Date(string? From, string? To) : FdyColumnFilter;
31
+ }
32
+
33
+ /// <summary>One column definition. <typeparamref name="TRow"/> is the row type.</summary>
34
+ /// <typeparam name="TRow">The row model.</typeparam>
35
+ public sealed class FdyTableColumn<TRow>
36
+ {
37
+ /// <summary>Row property key; the sort/filter identity and, when <see cref="Value"/> is unset,
38
+ /// the default cell accessor (resolved by reflection against a property of this name).</summary>
39
+ public required string Key { get; init; }
40
+
41
+ /// <summary>Header label.</summary>
42
+ public required string Label { get; init; }
43
+
44
+ /// <summary>Show a sort toggle in the header.</summary>
45
+ public bool Sortable { get; init; }
46
+
47
+ /// <summary>Offer a column filter of this type (null = no filter).</summary>
48
+ public FdyColumnFilterType? Filter { get; init; }
49
+
50
+ /// <summary>Cell alignment (null = left).</summary>
51
+ public FdyColumnAlign? Align { get; init; }
52
+
53
+ /// <summary>Render cells in the monospace data font (<c>.fdy-mono</c>).</summary>
54
+ public bool Mono { get; init; }
55
+
56
+ /// <summary>Override the comparator type; defaults to one derived from <see cref="Filter"/>, else Text.</summary>
57
+ public FdyColumnType? SortType { get; init; }
58
+
59
+ /// <summary>Typed value accessor for sort/filter/default cell text; defaults to the property named
60
+ /// <see cref="Key"/> (reflection). Prefer setting this for strongly-typed, allocation-free access.</summary>
61
+ public Func<TRow, object?>? Value { get; init; }
62
+
63
+ /// <summary>Explicit options for an enum filter; defaults to the distinct values in the current
64
+ /// rows. Provide this in server-paged mode, where the rows on screen are only one page.</summary>
65
+ public IReadOnlyList<string>? Options { get; init; }
66
+
67
+ /// <summary>Custom cell template; defaults to the accessor's text.</summary>
68
+ public RenderFragment<TRow>? Cell { get; init; }
69
+ }
@@ -0,0 +1,5 @@
1
+ @using System.Threading.Tasks
2
+ @using Microsoft.AspNetCore.Components
3
+ @using Microsoft.AspNetCore.Components.Web
4
+ @using Microsoft.JSInterop
5
+ @using Freeday.Blazor
@@ -55,11 +55,44 @@
55
55
  return token;
56
56
  }
57
57
 
58
- // Remove a subscription created by on().
58
+ // Subscribe to a daterange's child datepicker changes, forwarding {value, role} so a Blazor
59
+ // wrapper can bind From/To separately — both children emit the same fdy-datepicker-change, so
60
+ // the originating child's data-role is what tells them apart.
61
+ function dateRangeOn(element, dotNetRef, methodName) {
62
+ var handler = function (e) {
63
+ var origin = e.target && e.target.closest ? e.target.closest('[data-role]') : null;
64
+ dotNetRef.invokeMethodAsync(methodName, {
65
+ value: (e.detail && e.detail.value) || '',
66
+ role: origin ? origin.getAttribute('data-role') : null
67
+ });
68
+ };
69
+ element.addEventListener('fdy-datepicker-change', handler);
70
+ var token = ++seq;
71
+ subs[token] = { el: element, type: 'fdy-datepicker-change', fn: handler };
72
+ return token;
73
+ }
74
+
75
+ // Fire a .NET callback when a pointerdown lands outside `element`, or Escape is pressed —
76
+ // the light-dismiss primitive for Blazor-owned popovers (e.g. FdyTable's column filter, which
77
+ // is position:fixed in the top layer and so escapes the table's overflow). Returns an off() token.
78
+ function onOutside(element, dotNetRef, methodName) {
79
+ if (!element) return 0;
80
+ var onPointer = function (e) { if (!element.contains(e.target)) dotNetRef.invokeMethodAsync(methodName); };
81
+ var onKey = function (e) { if (e.key === 'Escape') dotNetRef.invokeMethodAsync(methodName); };
82
+ document.addEventListener('pointerdown', onPointer, true);
83
+ document.addEventListener('keydown', onKey, true);
84
+ var token = ++seq;
85
+ subs[token] = { el: document, type: 'pointerdown', fn: onPointer, key: onKey, capture: true };
86
+ return token;
87
+ }
88
+
89
+ // Remove a subscription created by on() / onOutside(); removes with the same capture flag it
90
+ // was added with (a bubble-phase listener can't be removed with capture:true and vice versa).
59
91
  function off(token) {
60
92
  var s = subs[token];
61
93
  if (s) {
62
- s.el.removeEventListener(s.type, s.fn);
94
+ s.el.removeEventListener(s.type, s.fn, s.capture === true);
95
+ if (s.key) s.el.removeEventListener('keydown', s.key, true);
63
96
  delete subs[token];
64
97
  }
65
98
  }
@@ -69,11 +102,76 @@
69
102
  if (window.Freeday && typeof window.Freeday.toast === 'function') window.Freeday.toast(options);
70
103
  }
71
104
 
105
+ // Passthrough to a combo's programmatic setter (window.FreedayCombo.setValue) so a
106
+ // Blazor @bind-Value can push an external value change onto the enhancer-owned DOM
107
+ // without the enhancer echoing an fdy-change back.
108
+ function comboSetValue(element, value) {
109
+ if (window.FreedayCombo && typeof window.FreedayCombo.setValue === 'function') {
110
+ window.FreedayCombo.setValue(element, value);
111
+ }
112
+ }
113
+
114
+ // Repaint a chart from its current data-* attributes. FdyChart keeps the outer <div> +
115
+ // attributes under Blazor's control and calls this after each render, so the SVG re-renders
116
+ // when the bound data changes (window.FreedayChart.update === renderChart).
117
+ function chartUpdate(element) {
118
+ if (element && window.FreedayChart && typeof window.FreedayChart.update === 'function') {
119
+ window.FreedayChart.update(element);
120
+ }
121
+ }
122
+
123
+ // --- Native <dialog> control for FdyModal ---------------------------------
124
+ // The kit styles `.fdy-modal` as a <dialog> but nothing drives it; Blazor can't call
125
+ // showModal()/close() from C#, and Esc/backdrop dismissal must route through .NET state
126
+ // (not close the DOM behind its back). dialogInit wires cancel + backdrop → a [JSInvokable]
127
+ // dismiss callback; showModal()/close() are guarded (showModal on an open dialog throws).
128
+ var dialogSubs = {};
129
+ var dialogSeq = 0;
130
+
131
+ function dialogInit(dialog, dotNetRef, dismissMethod, dismissible) {
132
+ if (!dialog) return 0;
133
+ var onCancel = function (e) {
134
+ e.preventDefault(); // stop the native close; app state is the single source of truth
135
+ if (dismissible) dotNetRef.invokeMethodAsync(dismissMethod);
136
+ };
137
+ var onClick = function (e) {
138
+ // The ::backdrop is not a separate element — a click whose target is the dialog box
139
+ // itself (not its content) is a backdrop click.
140
+ if (dismissible && e.target === dialog) dotNetRef.invokeMethodAsync(dismissMethod);
141
+ };
142
+ dialog.addEventListener('cancel', onCancel);
143
+ dialog.addEventListener('click', onClick);
144
+ var token = ++dialogSeq;
145
+ dialogSubs[token] = { el: dialog, cancel: onCancel, click: onClick };
146
+ return token;
147
+ }
148
+
149
+ function dialogShow(dialog) {
150
+ if (dialog && typeof dialog.showModal === 'function' && !dialog.open) dialog.showModal();
151
+ }
152
+
153
+ function dialogClose(dialog) {
154
+ if (dialog && dialog.open) dialog.close();
155
+ }
156
+
157
+ function dialogDispose(token) {
158
+ var s = dialogSubs[token];
159
+ if (s) {
160
+ s.el.removeEventListener('cancel', s.cancel);
161
+ s.el.removeEventListener('click', s.click);
162
+ delete dialogSubs[token];
163
+ }
164
+ }
165
+
72
166
  // Flip the document theme (data-theme on <html>), for the demo toggle.
73
167
  function toggleTheme() {
74
168
  var e = document.documentElement;
75
169
  e.setAttribute('data-theme', e.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
76
170
  }
77
171
 
78
- window.FreedayBlazor = { initAll: initAll, on: on, off: off, toast: toast, toggleTheme: toggleTheme };
172
+ window.FreedayBlazor = {
173
+ initAll: initAll, on: on, off: off, onOutside: onOutside, toast: toast, toggleTheme: toggleTheme,
174
+ comboSetValue: comboSetValue, dateRangeOn: dateRangeOn, chartUpdate: chartUpdate,
175
+ dialogInit: dialogInit, dialogShow: dialogShow, dialogClose: dialogClose, dialogDispose: dialogDispose,
176
+ };
79
177
  })();
@@ -91,7 +91,11 @@
91
91
  setHighlight(-1);
92
92
  if (openClose === close) openClose = null;
93
93
  }
94
- function choose(opt) {
94
+ // Mark an option selected + reflect it in the button. `silent` skips the change
95
+ // event, close, and focus — used by the programmatic setValue() below so a host
96
+ // (e.g. a Blazor/framework wrapper) can push a value in without echoing an event
97
+ // back to itself or stealing focus. User-driven picks go through choose() (loud).
98
+ function select(opt, silent) {
95
99
  options.forEach(function (other) {
96
100
  var on = other === opt;
97
101
  other.setAttribute('aria-selected', on ? 'true' : 'false');
@@ -101,12 +105,24 @@
101
105
  root.setAttribute('data-value', opt.getAttribute('data-value') || '');
102
106
  valueEl.textContent = optionLabel(opt);
103
107
  valueEl.classList.remove('fdy-combo__value--placeholder');
104
- root.dispatchEvent(new CustomEvent('fdy-change', {
105
- bubbles: true,
106
- detail: { value: opt.getAttribute('data-value') }
107
- }));
108
- close();
109
- button.focus();
108
+ if (!silent) {
109
+ root.dispatchEvent(new CustomEvent('fdy-change', {
110
+ bubbles: true,
111
+ detail: { value: opt.getAttribute('data-value') }
112
+ }));
113
+ close();
114
+ button.focus();
115
+ }
116
+ }
117
+ function choose(opt) { select(opt, false); }
118
+ // Programmatic set (no event, no focus change). No-op if no option matches.
119
+ function setValue(value) {
120
+ for (var i = 0; i < options.length; i++) {
121
+ if ((options[i].getAttribute('data-value') || '') === String(value)) {
122
+ select(options[i], true);
123
+ return;
124
+ }
125
+ }
110
126
  }
111
127
 
112
128
  button.addEventListener('click', function () {
@@ -167,6 +183,9 @@
167
183
  root.addEventListener('focusout', function (e) {
168
184
  if (!root.contains(e.relatedTarget)) close();
169
185
  });
186
+
187
+ // Per-instance API for programmatic control (host wrappers / two-way binding).
188
+ root._fdyCombo = { setValue: setValue };
170
189
  }
171
190
 
172
191
  function initAll(context) {
@@ -190,5 +209,10 @@
190
209
  initAll();
191
210
  }
192
211
 
193
- window.FreedayCombo = { init: initCombo, initAll: initAll };
212
+ // Programmatic set on an already-initialised combo root (no fdy-change dispatched).
213
+ function setValue(root, value) {
214
+ if (root && root._fdyCombo) root._fdyCombo.setValue(value);
215
+ }
216
+
217
+ window.FreedayCombo = { init: initCombo, initAll: initAll, setValue: setValue };
194
218
  })();
@@ -1,14 +1,24 @@
1
1
  /* Freeday — toast API (optional, zero-dependency).
2
- * Freeday.toast({ variant, title, message, timeout }) shows a transient
2
+ * Freeday.toast({ variant, title, message, timeout, key }) shows a transient
3
3
  * notification in a live region (created on first use). Returns the element.
4
4
  * variant: 'success' | 'warning' | 'danger' | 'info' (default: neutral)
5
5
  * timeout: ms before auto-dismiss (default 4000; 0 = sticky)
6
+ * key: optional stable id — a new toast with the same key REPLACES the
7
+ * existing one in place instead of stacking a duplicate (e.g. a burst
8
+ * of identical failures shows one toast, refreshed).
9
+ * Freeday.dismiss(nodeOrKey) closes a toast early (pass the returned element or its key).
6
10
  * Danger toasts use role="alert"; others role="status".
7
11
  */
8
12
  (function () {
9
13
  'use strict';
10
14
 
11
15
  var region = null;
16
+ var keyed = {}; // key -> current node, for replace-in-place + dismiss-by-key
17
+
18
+ function forget(node) {
19
+ var k = node && node._fdyKey;
20
+ if (k != null && keyed[k] === node) delete keyed[k];
21
+ }
12
22
 
13
23
  function ensureRegion() {
14
24
  if (region && document.body.contains(region)) return region;
@@ -24,7 +34,9 @@
24
34
  }
25
35
 
26
36
  function dismiss(toast) {
37
+ if (typeof toast === 'string') toast = keyed[toast]; // dismiss by key
27
38
  if (!toast || toast.dataset.leaving === '1') return;
39
+ forget(toast);
28
40
  toast.dataset.leaving = '1';
29
41
  toast.classList.add('is-leaving');
30
42
  var remove = function () { if (toast.parentNode) toast.parentNode.removeChild(toast); };
@@ -64,6 +76,14 @@
64
76
  node.appendChild(accent);
65
77
  node.appendChild(body);
66
78
  node.appendChild(close);
79
+
80
+ // Replace-in-place: a same-key toast removes the previous one so bursts don't stack.
81
+ if (opts.key != null) {
82
+ var prev = keyed[opts.key];
83
+ if (prev && prev.parentNode) prev.parentNode.removeChild(prev);
84
+ node._fdyKey = opts.key;
85
+ keyed[opts.key] = node;
86
+ }
67
87
  region.appendChild(node);
68
88
 
69
89
  var timeout = opts.timeout == null ? 4000 : opts.timeout;
@@ -81,4 +101,5 @@
81
101
 
82
102
  window.Freeday = window.Freeday || {};
83
103
  window.Freeday.toast = toast;
104
+ window.Freeday.dismiss = dismiss; // dismiss(nodeOrKey)
84
105
  })();
@@ -361,6 +361,11 @@ a { color: var(--color-primary); }
361
361
  :focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-width); border-radius: var(--radius-xs); }
362
362
  :where(button, input, select, textarea) { font: inherit; }
363
363
  .fdy-visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
364
+ /* Opt-in list reset. base.css is intentionally a *light* reset — it does NOT strip list/paragraph
365
+ * margins (see the kit's own list components, which each reset themselves). If you run a utility
366
+ * framework with its preflight OFF (the recommended pairing), a semantic <ul>/<ol> keeps native
367
+ * bullets + a 40px indent; add this class (or a Freeday list component) to normalise it. */
368
+ .fdy-list-reset { list-style: none; margin: 0; padding: 0; }
364
369
  @media (prefers-reduced-motion: reduce) {
365
370
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
366
371
  }
@@ -524,6 +529,19 @@ a { color: var(--color-primary); }
524
529
  .fdy-avatar-group{display:inline-flex;}
525
530
  .fdy-avatar-group .fdy-avatar{border:2px solid var(--color-surface);margin-left:calc(var(--space-2)*-1);}
526
531
  .fdy-avatar-group .fdy-avatar:first-child{margin-left:0;}
532
+ /* Decorative identity tones — a soft tint of a categorical chart colour with a theme-aware,
533
+ * text-leaning foreground so initials keep WCAG AA (verified >=4.5:1 in light & dark by
534
+ * test/contrast.test.mjs). Use to disambiguate same-initial avatars (e.g. many "PT …" names):
535
+ * hash a stable 1..8 index off the FULL name in your app, then apply .fdy-avatar--tone-<n>. */
536
+ .fdy-avatar--tone-1,.fdy-avatar--tone-2,.fdy-avatar--tone-3,.fdy-avatar--tone-4,.fdy-avatar--tone-5,.fdy-avatar--tone-6,.fdy-avatar--tone-7,.fdy-avatar--tone-8{background:color-mix(in srgb,var(--_fdy-avatar-tone) 18%,var(--color-surface));color:color-mix(in srgb,var(--_fdy-avatar-tone) 50%,var(--color-text));}
537
+ .fdy-avatar--tone-1{--_fdy-avatar-tone:var(--chart-1);}
538
+ .fdy-avatar--tone-2{--_fdy-avatar-tone:var(--chart-2);}
539
+ .fdy-avatar--tone-3{--_fdy-avatar-tone:var(--chart-3);}
540
+ .fdy-avatar--tone-4{--_fdy-avatar-tone:var(--chart-4);}
541
+ .fdy-avatar--tone-5{--_fdy-avatar-tone:var(--chart-5);}
542
+ .fdy-avatar--tone-6{--_fdy-avatar-tone:var(--chart-6);}
543
+ .fdy-avatar--tone-7{--_fdy-avatar-tone:var(--chart-7);}
544
+ .fdy-avatar--tone-8{--_fdy-avatar-tone:var(--chart-8);}
527
545
 
528
546
  /* Freeday — Badge */
529
547
  .fdy-badge{display:inline-flex;align-items:center;gap:var(--space-1);font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);line-height:1;white-space:nowrap;padding:.35em .7em;border-radius:var(--radius-full);color:var(--color-primary-strong);background:var(--color-primary-soft);border:var(--bw) solid color-mix(in srgb,var(--color-primary) 22%,transparent);box-shadow:0 1px 1.5px rgba(16,14,30,.05);}
@@ -643,6 +661,10 @@ a { color: var(--color-primary); }
643
661
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
644
662
  .fdy-card--interactive{cursor:pointer;}
645
663
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
664
+ /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
665
+ * announced — reset the UA button box. Note: it does NOT reset background/border, because
666
+ * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
667
+ .fdy-card--button{display:block;width:100%;text-align:inherit;color:inherit;appearance:none;-webkit-appearance:none;}
646
668
  .fdy-card__body{padding:var(--space-5);}
647
669
  .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);}
648
670
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -1202,6 +1224,11 @@ fieldset.fdy-field>legend{padding:0;float:none;}
1202
1224
  .fdy-skeleton--text{height:.8rem;margin:.3rem 0;}
1203
1225
  .fdy-skeleton--title{height:1.2rem;width:40%;}
1204
1226
  .fdy-skeleton--circle{border-radius:var(--radius-full);}
1227
+ /* Size-matched to .fdy-avatar (2.5rem) and its --sm/--lg, so a skeleton reserves the exact avatar
1228
+ * box and data landing causes no layout shift (a plain --circle has no dimensions of its own). */
1229
+ .fdy-skeleton--avatar{width:2.5rem;height:2.5rem;border-radius:var(--radius-full);}
1230
+ .fdy-skeleton--avatar-sm{width:2rem;height:2rem;border-radius:var(--radius-full);}
1231
+ .fdy-skeleton--avatar-lg{width:3.5rem;height:3.5rem;border-radius:var(--radius-full);}
1205
1232
  @keyframes fdy-skeleton{to{background-position:-200% 0;}}
1206
1233
  @media (prefers-reduced-motion:reduce){.fdy-skeleton{animation:none;}}
1207
1234
 
package/dist/freeday.css CHANGED
@@ -16,6 +16,11 @@ a { color: var(--color-primary); }
16
16
  :focus-visible { outline: var(--focus-ring-width) solid var(--focus-ring); outline-offset: var(--focus-ring-width); border-radius: var(--radius-xs); }
17
17
  :where(button, input, select, textarea) { font: inherit; }
18
18
  .fdy-visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
19
+ /* Opt-in list reset. base.css is intentionally a *light* reset — it does NOT strip list/paragraph
20
+ * margins (see the kit's own list components, which each reset themselves). If you run a utility
21
+ * framework with its preflight OFF (the recommended pairing), a semantic <ul>/<ol> keeps native
22
+ * bullets + a 40px indent; add this class (or a Freeday list component) to normalise it. */
23
+ .fdy-list-reset { list-style: none; margin: 0; padding: 0; }
19
24
  @media (prefers-reduced-motion: reduce) {
20
25
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
21
26
  }
@@ -179,6 +184,19 @@ a { color: var(--color-primary); }
179
184
  .fdy-avatar-group{display:inline-flex;}
180
185
  .fdy-avatar-group .fdy-avatar{border:2px solid var(--color-surface);margin-left:calc(var(--space-2)*-1);}
181
186
  .fdy-avatar-group .fdy-avatar:first-child{margin-left:0;}
187
+ /* Decorative identity tones — a soft tint of a categorical chart colour with a theme-aware,
188
+ * text-leaning foreground so initials keep WCAG AA (verified >=4.5:1 in light & dark by
189
+ * test/contrast.test.mjs). Use to disambiguate same-initial avatars (e.g. many "PT …" names):
190
+ * hash a stable 1..8 index off the FULL name in your app, then apply .fdy-avatar--tone-<n>. */
191
+ .fdy-avatar--tone-1,.fdy-avatar--tone-2,.fdy-avatar--tone-3,.fdy-avatar--tone-4,.fdy-avatar--tone-5,.fdy-avatar--tone-6,.fdy-avatar--tone-7,.fdy-avatar--tone-8{background:color-mix(in srgb,var(--_fdy-avatar-tone) 18%,var(--color-surface));color:color-mix(in srgb,var(--_fdy-avatar-tone) 50%,var(--color-text));}
192
+ .fdy-avatar--tone-1{--_fdy-avatar-tone:var(--chart-1);}
193
+ .fdy-avatar--tone-2{--_fdy-avatar-tone:var(--chart-2);}
194
+ .fdy-avatar--tone-3{--_fdy-avatar-tone:var(--chart-3);}
195
+ .fdy-avatar--tone-4{--_fdy-avatar-tone:var(--chart-4);}
196
+ .fdy-avatar--tone-5{--_fdy-avatar-tone:var(--chart-5);}
197
+ .fdy-avatar--tone-6{--_fdy-avatar-tone:var(--chart-6);}
198
+ .fdy-avatar--tone-7{--_fdy-avatar-tone:var(--chart-7);}
199
+ .fdy-avatar--tone-8{--_fdy-avatar-tone:var(--chart-8);}
182
200
 
183
201
  /* Freeday — Badge */
184
202
  .fdy-badge{display:inline-flex;align-items:center;gap:var(--space-1);font-family:var(--font-body);font-size:var(--text-xs);font-weight:var(--weight-semibold);line-height:1;white-space:nowrap;padding:.35em .7em;border-radius:var(--radius-full);color:var(--color-primary-strong);background:var(--color-primary-soft);border:var(--bw) solid color-mix(in srgb,var(--color-primary) 22%,transparent);box-shadow:0 1px 1.5px rgba(16,14,30,.05);}
@@ -298,6 +316,10 @@ a { color: var(--color-primary); }
298
316
  .fdy-card--elevated{box-shadow:var(--shadow-lift-hover);}
299
317
  .fdy-card--interactive{cursor:pointer;}
300
318
  .fdy-card--interactive:hover{box-shadow:var(--shadow-lift-hover);transform:translateY(-3px);}
319
+ /* When the whole card IS the control — a real <button> so the row is keyboard-reachable and
320
+ * announced — reset the UA button box. Note: it does NOT reset background/border, because
321
+ * .fdy-card already sets its own surface + border (resetting them here would strip the card). */
322
+ .fdy-card--button{display:block;width:100%;text-align:inherit;color:inherit;appearance:none;-webkit-appearance:none;}
301
323
  .fdy-card__body{padding:var(--space-5);}
302
324
  .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);}
303
325
  .fdy-card__desc{font-size:var(--text-sm);color:var(--color-text-muted);margin:0;line-height:var(--leading-normal);}
@@ -857,6 +879,11 @@ fieldset.fdy-field>legend{padding:0;float:none;}
857
879
  .fdy-skeleton--text{height:.8rem;margin:.3rem 0;}
858
880
  .fdy-skeleton--title{height:1.2rem;width:40%;}
859
881
  .fdy-skeleton--circle{border-radius:var(--radius-full);}
882
+ /* Size-matched to .fdy-avatar (2.5rem) and its --sm/--lg, so a skeleton reserves the exact avatar
883
+ * box and data landing causes no layout shift (a plain --circle has no dimensions of its own). */
884
+ .fdy-skeleton--avatar{width:2.5rem;height:2.5rem;border-radius:var(--radius-full);}
885
+ .fdy-skeleton--avatar-sm{width:2rem;height:2rem;border-radius:var(--radius-full);}
886
+ .fdy-skeleton--avatar-lg{width:3.5rem;height:3.5rem;border-radius:var(--radius-full);}
860
887
  @keyframes fdy-skeleton{to{background-position:-200% 0;}}
861
888
  @media (prefers-reduced-motion:reduce){.fdy-skeleton{animation:none;}}
862
889
 
package/dist/freeday.d.ts CHANGED
@@ -10,11 +10,16 @@ export interface FreedayToastOptions {
10
10
  message?: string;
11
11
  /** Milliseconds before auto-dismiss. Default 4000; 0 = sticky. */
12
12
  timeout?: number;
13
+ /** Stable id: a new toast with the same key replaces the existing one in place instead of
14
+ * stacking a duplicate (e.g. a burst of identical failures shows one, refreshed). */
15
+ key?: string;
13
16
  }
14
17
 
15
18
  export interface FreedayGlobal {
16
19
  /** Show a transient toast in a live region (created on first use). Returns the toast element. */
17
20
  toast(opts?: FreedayToastOptions): HTMLElement;
21
+ /** Dismiss a toast early — pass the element returned by toast(), or a key string. */
22
+ dismiss(target: HTMLElement | string): void;
18
23
  }
19
24
 
20
25
  declare global {