@cahyo-dimas/freeday 1.41.0 → 1.43.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,71 @@
3
3
  Semua perubahan penting dicatat di sini. Format longgar mengikuti
4
4
  [Keep a Changelog](https://keepachangelog.com/); tiap versi = git tag.
5
5
 
6
+ ## [1.43.0] — 2026-08-19
7
+ Three crowding reports from one settlement screen (IDU_EMATE_APPL_WEB, #020). All three measured in
8
+ Chromium rather than read off the CSS — each is a layout outcome, invisible in the stylesheet.
9
+ ### Fixed
10
+ - **A control in a table column collapsed** (#020). `.fdy-input` is `width:100%`, which in an
11
+ auto-layout table contributes **no intrinsic width**: the column shrank to its header text and took
12
+ the control with it. A NOTE box measured **36px** — too narrow to read what you typed into it.
13
+ Controls that are direct children of a `td` now keep a `7rem` floor (112px measured). Per-control
14
+ rather than per-column, because the table cannot know which columns hold controls. `width:100%` was
15
+ never wrong; it is only in a table that 100% of nothing is nothing.
16
+ - **A stat value wrapped between the currency and the number** (#020). `.fdy-stat__value` is
17
+ `--text-3xl`; `IDR 300,000.00` needs **224px** at that size and `.fdy-stats` tracks are
18
+ `minmax(11rem,1fr)`, so a three-across row at 660px gave it ~205px and it broke onto two lines.
19
+ It now shrinks **only when its own column cannot hold it** — a 205px track renders 22.7px on one
20
+ line, a 420px track still renders exactly 31px. Shrinking the token instead would have taken 31px
21
+ from every consumer to fix a column that was 19px short. Scoped to a stat inside `.fdy-stats`,
22
+ where the width comes from the grid track: `container-type:inline-size` on a standalone
23
+ `.fdy-stat` would stop it sizing to its own content, and the unconditional rule stays as the
24
+ fallback where `@container` is missing.
25
+ - **`.fdy-filelist` claimed space above itself and none below** (#020), so the "Add files" button
26
+ every upload UI puts after it sat flush against the last row — a measured **0px**. Symmetric now,
27
+ with `:last-child` dropping the bottom margin so nothing pays for room it does not need.
28
+ ### Added — guards
29
+ - `browser/crowding.mjs` measures all three. The stat case asserts **both** directions — one line
30
+ when narrow, still exactly `31px` when wide — because a fix that quietly shrank every dashboard
31
+ would pass a "does it wrap?" test.
32
+
33
+ ## [1.42.0] — 2026-08-19
34
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #019), and a string the #009 guard could not see.
35
+ ### Added
36
+ - **`multiple` on the typed `FdyCfl`** (Vue · React · Blazor). The enhancer has offered
37
+ `data-fdy-cfl-multiple` since it shipped and `COMPONENTS.md` documented it; all three wrappers were
38
+ single-valued **at the type level**, so there was nothing to widen from the outside. An app that
39
+ started on the enhancer and later adopted the wrapper silently lost a capability — which is the
40
+ actual defect, more than the missing feature. The settlement screen that reported it gathers six or
41
+ eight approved claims onto one document and was doing six open→search→click cycles.
42
+ Rows tick instead of committing, the footer counts them, and **Confirm** commits the set. Vue and
43
+ React widen their model to `Row[] | null`; Blazor takes `Values` / `ValuesChanged` instead, because
44
+ a nullable union is not a C# shape. New strings: `selectedText` (`{n} selected`), `confirmText`,
45
+ `hintText` — the last one making the single-mode footer overridable too, which it never was.
46
+ ### Notes on the shape of the fix
47
+ - **The ticks are component state, not the model.** Closing without Confirm has to leave the caller's
48
+ value exactly as it was, so the dialog seeds its ticks from the bound value on every open. Binding
49
+ them straight to the model would make Cancel a lie.
50
+ - **The field states `{n} selected`.** `display()` takes ONE row: naming one of six would be wrong and
51
+ naming all six does not fit a 22rem control.
52
+ - **Blazor gained a modal footer**, which it never had — Vue and React both commit from one.
53
+ - The tick checkbox is `aria-hidden` and inert. `.fdy-cfl__check input` is already
54
+ `pointer-events:none` in the kit's CSS, so the row owns the click and a handler on the box would
55
+ have been unreachable code; `aria-selected` on the row is what carries the state.
56
+ ### Fixed
57
+ - **`FdyCfl`'s second retry button said `Coba lagi`** (Vue · React). The error state inside the
58
+ results branch had a hard-coded Indonesian literal while the button two states above it correctly
59
+ read `retryText ?? 'Try again'` — so the component both had the prop and ignored it. #009's guard
60
+ missed it because **neither `coba` nor `lagi` is in its word list**, which is #015's lesson landing
61
+ a third time: a word list is a floor, not a ceiling. Both now read `retryText`.
62
+ ### Added — guards
63
+ - **The multi flow is driven by real clicks, in both adapters** (#019). `browser/cfl-multi.mjs`
64
+ asserts that a click ticks rather than commits, that the dialog stays open, that `aria-selected`
65
+ and the footer count follow, that a second click unticks, that Confirm hands back an **array** in
66
+ order, and that closing without Confirm discards the ticks *and* does not remember them on re-open.
67
+ Vue and React run the same script, so a divergence between the two fails here instead of in an app.
68
+ A typecheck cannot tell you whether a mode ever renders. Mutation-tested — committing
69
+ `picked[0]` instead of the array fails it by name.
70
+
6
71
  ## [1.41.0] — 2026-08-19
7
72
  One note from the account app (IDU_EMATE_ACCT_WEB, #018) — the first raised from that repo.
8
73
  ### Fixed
package/COMPONENTS.md CHANGED
@@ -463,6 +463,12 @@ the field. The value is always **chosen, never typed**. Needs `freeday-cfl.js`.
463
463
  - Hooks: `data-fdy-cfl-search` (the search input), `data-fdy-cfl-empty`, `data-fdy-cfl-count`,
464
464
  `data-fdy-cfl-confirm` (multi only), `data-fdy-cfl-trigger`, `data-fdy-cfl-open`
465
465
  - Rows are server state — in a real app drive them from a controlled fetch, not a global store.
466
+ - **Multi-select in the typed wrappers** (Vue · React · Blazor): `multiple` ticks rows and commits
467
+ them together on **Confirm** instead of committing the row that was clicked. Vue and React widen
468
+ their model to an array (`Row[] | null`); Blazor takes a second pair, `Values` /
469
+ `ValuesChanged`, because a nullable union is not a C# shape. Closing without Confirm leaves the
470
+ bound value untouched, and re-opening seeds the ticks from it. The field states `{n} selected`
471
+ rather than one row's `display()`. Strings: `selectedText`, `confirmText`, `hintText`.
466
472
 
467
473
  ```html
468
474
  <div class="fdy-field">
@@ -63,6 +63,10 @@
63
63
  <table class="fdy-table" aria-label="@Title">
64
64
  <thead>
65
65
  <tr>
66
+ @if (Multiple)
67
+ {
68
+ <th scope="col"><span class="fdy-visually-hidden">@SelectedLabel</span></th>
69
+ }
66
70
  @foreach (CflColumn<TRow> col in Columns)
67
71
  {
68
72
  <th scope="col">@col.Header</th>
@@ -72,7 +76,15 @@
72
76
  <tbody>
73
77
  @foreach (TRow row in _rows)
74
78
  {
75
- <tr class="fdy-cfl__row" tabindex="0" @key="RowKey(row)" @onclick="() => ChooseAsync(row)">
79
+ <tr class="fdy-cfl__row" tabindex="0" @key="RowKey(row)"
80
+ aria-selected="@(Multiple ? IsPicked(row).ToString().ToLowerInvariant() : null)"
81
+ @onclick="() => RowClickAsync(row)">
82
+ @if (Multiple)
83
+ {
84
+ <td class="fdy-cfl__check">
85
+ <input class="fdy-check" type="checkbox" tabindex="-1" checked="@IsPicked(row)" aria-hidden="true" />
86
+ </td>
87
+ }
76
88
  @foreach (CflColumn<TRow> col in Columns)
77
89
  {
78
90
  <td>@col.Cell(row)</td>
@@ -99,4 +111,15 @@
99
111
  }
100
112
  </div>
101
113
  </div>
114
+
115
+ <div class="fdy-modal__footer">
116
+ <span class="fdy-cfl__count" aria-live="polite">@(Multiple ? SelectedLabel : HintText)</span>
117
+ <div class="fdy-cfl__actions">
118
+ <button class="fdy-btn fdy-btn--ghost" type="button" @onclick="CloseAsync">@CloseLabel</button>
119
+ @if (Multiple)
120
+ {
121
+ <button class="fdy-btn" type="button" @onclick="ConfirmAsync">@ConfirmText</button>
122
+ }
123
+ </div>
124
+ </div>
102
125
  </dialog>
@@ -35,6 +35,26 @@ public partial class FdyCfl<TRow>
35
35
  /// <summary>aria-label for the button that opens the picker.</summary>
36
36
  [Parameter] public string OpenLabel { get; set; } = "Open search";
37
37
 
38
+ /// <summary>Tick rows and commit them together, instead of committing the row that was clicked.
39
+ /// The kit's own enhancer offers this (<c>data-fdy-cfl-multiple</c>); a screen that gathers six
40
+ /// expense claims onto one document wants one dialog, not six. Binds <see cref="Values"/>.</summary>
41
+ [Parameter] public bool Multiple { get; set; }
42
+
43
+ /// <summary>The committed rows when <see cref="Multiple"/> is set. Vue and React widen their
44
+ /// single model to an array; C# takes a second pair instead, so neither is nullable-of-union.</summary>
45
+ [Parameter] public IReadOnlyList<TRow>? Values { get; set; }
46
+
47
+ [Parameter] public EventCallback<IReadOnlyList<TRow>?> ValuesChanged { get; set; }
48
+
49
+ /// <summary>Footer label while picking; <c>{n}</c> is replaced by the tick count.</summary>
50
+ [Parameter] public string SelectedText { get; set; } = "{n} selected";
51
+
52
+ /// <summary>The multi-select commit button.</summary>
53
+ [Parameter] public string ConfirmText { get; set; } = "Confirm";
54
+
55
+ /// <summary>Footer hint in single mode.</summary>
56
+ [Parameter] public string HintText { get; set; } = "Click a row to choose it";
57
+
38
58
  [Parameter] public bool Disabled { get; set; }
39
59
 
40
60
  /// <summary>Locked/view mode: shows the value but the search dialog can't be opened.</summary>
@@ -78,6 +98,9 @@ public partial class FdyCfl<TRow>
78
98
  if (Disabled || Readonly || _open) return;
79
99
  _open = true;
80
100
  _query = string.Empty;
101
+ /* Re-seeded per open, so Cancel really is a cancel: the ticks start as whatever the caller holds. */
102
+ _picked.Clear();
103
+ if (Multiple && Values is not null) _picked.AddRange(Values);
81
104
  await JS.InvokeVoidAsync("FreedayBlazor.dialogShow", Root);
82
105
  await LoadAsync(reset: true);
83
106
  }
@@ -141,10 +164,52 @@ public partial class FdyCfl<TRow>
141
164
  /// opens nor closes it.</summary>
142
165
  private async Task ClearAsync()
143
166
  {
167
+ _picked.Clear();
168
+ if (Multiple)
169
+ {
170
+ Values = null;
171
+ await ValuesChanged.InvokeAsync(null);
172
+ return;
173
+ }
144
174
  Value = default;
145
175
  await ValueChanged.InvokeAsync(default);
146
176
  }
147
177
 
178
+ /* The ticks live here, not in Values, because a multi dialog is only committed at Confirm:
179
+ closing it must leave the caller's value exactly as it was. Seeded from Values on open. */
180
+ private readonly List<TRow> _picked = new();
181
+
182
+ private string SelectedLabel => SelectedText.Replace("{n}", _picked.Count.ToString());
183
+
184
+ private bool IsPicked(TRow row)
185
+ {
186
+ string key = RowKey(row);
187
+ return _picked.Exists(r => RowKey(r) == key);
188
+ }
189
+
190
+ private void TogglePick(TRow row)
191
+ {
192
+ string key = RowKey(row);
193
+ int at = _picked.FindIndex(r => RowKey(r) == key);
194
+ if (at == -1) _picked.Add(row);
195
+ else _picked.RemoveAt(at);
196
+ }
197
+
198
+ /* A click means "tick this" in multi and "this is my answer" in single — the whole difference. */
199
+ private async Task RowClickAsync(TRow row)
200
+ {
201
+ if (Multiple) TogglePick(row);
202
+ else await ChooseAsync(row);
203
+ }
204
+
205
+ private async Task ConfirmAsync()
206
+ {
207
+ IReadOnlyList<TRow> picks = _picked.ToArray();
208
+ Values = picks;
209
+ await ValuesChanged.InvokeAsync(picks);
210
+ await CloseAsync();
211
+ }
212
+
148
213
  private async Task ChooseAsync(TRow row)
149
214
  {
150
215
  Value = row;
@@ -21,8 +21,11 @@ export interface CflPage<Row> {
21
21
  }
22
22
 
23
23
  export interface FdyCflProps<Row extends Record<string, unknown>> {
24
- value: Row | null;
25
- onChange: (value: Row | null) => void;
24
+ /** Single: `Row | null`. With `multiple`, an array — `Row[] | null`, where null and [] both mean
25
+ * nothing picked. The enhancer has had `data-fdy-cfl-multiple` all along; this is the typed
26
+ * wrappers catching up (#019). */
27
+ value: Row | Row[] | null;
28
+ onChange: (value: Row | Row[] | null) => void;
26
29
  fetchPage: (query: string, page: number) => Promise<CflPage<Row>>;
27
30
  columns: ReadonlyArray<CflColumn<Row>>;
28
31
  display: (row: Row) => string;
@@ -45,6 +48,16 @@ export interface FdyCflProps<Row extends Record<string, unknown>> {
45
48
  closeLabel?: string;
46
49
  /** aria-label for the button that opens the picker. Default 'Open search'. */
47
50
  openLabel?: string;
51
+ /** Tick rows and commit them together, instead of committing the row that was clicked. The kit's
52
+ * own enhancer offers this (`data-fdy-cfl-multiple`); a screen that gathers six expense claims
53
+ * onto one document wants one dialog, not six. */
54
+ multiple?: boolean;
55
+ /** Footer label while picking, `{n}` replaced by the tick count. Default '{n} selected'. */
56
+ selectedText?: string;
57
+ /** The multi-select commit button. Default 'Confirm'. */
58
+ confirmText?: string;
59
+ /** Footer hint in single mode. Default 'Click a row to choose it'. */
60
+ hintText?: string;
48
61
  placeholder?: string;
49
62
  disabled?: boolean;
50
63
  /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
@@ -71,6 +84,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
71
84
  control that still exists — the trigger beside it, since this button disappears with the value. */
72
85
  const clearLabelText: string = props.clearLabel ?? 'Clear selection';
73
86
  const clearValue = (): void => {
87
+ setPicked([]);
74
88
  props.onChange(null);
75
89
  triggerRef.current?.focus();
76
90
  };
@@ -86,6 +100,9 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
86
100
  const [loading, setLoading] = useState<boolean>(false);
87
101
  const [error, setError] = useState<Error | null>(null);
88
102
  const [activeIndex, setActiveIndex] = useState<number>(-1);
103
+ /* The ticks live here, not in `value`, because a multi dialog is only committed at Confirm:
104
+ closing it must leave the caller's value exactly as it was. Seeded from `value` on open. */
105
+ const [picked, setPicked] = useState<Row[]>([]);
89
106
 
90
107
  // Out-of-order guard: every fetch takes a monotonically increasing token; a resolved or
91
108
  // rejected page is applied only if it still owns the latest token. A newer search, a
@@ -100,10 +117,25 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
100
117
 
101
118
  const isDisabled: boolean = props.disabled === true;
102
119
  const isReadonly: boolean = props.readonly === true;
120
+ /* `display()` takes one row, so in multi the field states HOW MANY — naming one of six would be a
121
+ lie, and naming all six does not fit a control that is 22rem wide. */
122
+ const currentRows: Row[] = Array.isArray(props.value)
123
+ ? props.value
124
+ : props.value !== null
125
+ ? [props.value as Row]
126
+ : [];
127
+ const selectedTextFor = (n: number): string => (props.selectedText ?? '{n} selected').replace('{n}', String(n));
128
+ const isMultiple: boolean = props.multiple === true;
103
129
  const showClear: boolean =
104
- props.clearable === true && props.value !== null && isDisabled === false && isReadonly === false;
130
+ props.clearable === true && currentRows.length > 0 && isDisabled === false && isReadonly === false;
105
131
  const isInvalid: boolean = props.invalid === true;
106
- const displayValue: string = props.value !== null ? props.display(props.value) : '';
132
+ const displayValue: string = isMultiple
133
+ ? currentRows.length === 0
134
+ ? ''
135
+ : selectedTextFor(currentRows.length)
136
+ : props.value !== null
137
+ ? props.display(props.value as Row)
138
+ : '';
107
139
  // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
108
140
  // search input's aria refs on rows existing — otherwise they'd dangle during loading/empty/error.
109
141
  const hasRows: boolean = rows.length > 0;
@@ -179,6 +211,31 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
179
211
  document.getElementById(rowId(activeIndex))?.scrollIntoView({ block: 'nearest' });
180
212
  }, [activeIndex]);
181
213
 
214
+ function isPicked(row: Row): boolean {
215
+ const key: string = props.rowKey(row);
216
+ return picked.some((r: Row): boolean => props.rowKey(r) === key);
217
+ }
218
+
219
+ function togglePick(row: Row): void {
220
+ const key: string = props.rowKey(row);
221
+ setPicked((prev: Row[]): Row[] =>
222
+ prev.some((r: Row): boolean => props.rowKey(r) === key)
223
+ ? prev.filter((r: Row): boolean => props.rowKey(r) !== key)
224
+ : [...prev, row],
225
+ );
226
+ }
227
+
228
+ /* A click means "tick this" in multi and "this is my answer" in single — the whole difference. */
229
+ function onRowClick(row: Row): void {
230
+ if (isMultiple) togglePick(row);
231
+ else commit(row);
232
+ }
233
+
234
+ function confirmPicks(): void {
235
+ props.onChange(picked);
236
+ closeDialog();
237
+ }
238
+
182
239
  function commit(row: Row): void {
183
240
  props.onChange(row);
184
241
  closeDialog();
@@ -210,7 +267,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
210
267
  const row: Row | undefined = activeIndex >= 0 ? rows[activeIndex] : undefined;
211
268
  if (row !== undefined) {
212
269
  e.preventDefault();
213
- commit(row);
270
+ onRowClick(row);
214
271
  }
215
272
  break;
216
273
  }
@@ -233,6 +290,8 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
233
290
  setHasMore(false);
234
291
  setError(null);
235
292
  setActiveIndex(-1);
293
+ /* Re-seeded per open, so Cancel really is a cancel: the ticks start as whatever the caller holds. */
294
+ setPicked(isMultiple ? [...currentRows] : []);
236
295
  dialogRef.current?.showModal();
237
296
  searchRef.current?.focus();
238
297
  void load('', 0, false);
@@ -356,6 +415,9 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
356
415
  <table id={resultsId} className="fdy-table" aria-label="Search results">
357
416
  <thead>
358
417
  <tr>
418
+ {isMultiple ? (
419
+ <th scope="col"><span className="fdy-visually-hidden">{selectedTextFor(picked.length)}</span></th>
420
+ ) : null}
359
421
  {props.columns.map((col: CflColumn<Row>): JSX.Element => (
360
422
  <th key={col.key} scope="col">{col.label}</th>
361
423
  ))}
@@ -366,11 +428,16 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
366
428
  <tr
367
429
  id={rowId(i)}
368
430
  key={props.rowKey(row)}
369
- className="fdy-cfl__row"
370
- aria-selected={i === activeIndex ? 'true' : undefined}
371
- onClick={(): void => commit(row)}
431
+ className={i === activeIndex ? 'fdy-cfl__row is-active' : 'fdy-cfl__row'}
432
+ aria-selected={isMultiple ? isPicked(row) : i === activeIndex ? true : undefined}
433
+ onClick={(): void => onRowClick(row)}
372
434
  onMouseMove={(): void => setActive(i)}
373
435
  >
436
+ {isMultiple ? (
437
+ <td className="fdy-cfl__check">
438
+ <input className="fdy-check" type="checkbox" tabIndex={-1} checked={isPicked(row)} readOnly aria-hidden="true" />
439
+ </td>
440
+ ) : null}
374
441
  {props.columns.map((col: CflColumn<Row>): JSX.Element => (
375
442
  <td key={col.key}>{cellText(row, col.key)}</td>
376
443
  ))}
@@ -382,7 +449,7 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
382
449
  {error !== null ? (
383
450
  <div className="fdy-cfl__empty" role="alert" style={{ padding: 'var(--space-4) var(--space-5)' }}>
384
451
  <p style={{ margin: '0 0 var(--space-3)' }}>{error.message}</p>
385
- <button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>Coba lagi</button>
452
+ <button className="fdy-btn fdy-btn--sm" type="button" onClick={retry}>{props.retryText ?? 'Try again'}</button>
386
453
  </div>
387
454
  ) : hasMore ? (
388
455
  <div style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'center' }}>
@@ -397,9 +464,14 @@ export function FdyCfl<Row extends Record<string, unknown>>(props: FdyCflProps<R
397
464
  </div>
398
465
 
399
466
  <div className="fdy-modal__footer">
400
- <span className="fdy-cfl__count">Click a row to choose it</span>
467
+ <span className="fdy-cfl__count" aria-live="polite">
468
+ {isMultiple ? selectedTextFor(picked.length) : (props.hintText ?? 'Click a row to choose it')}
469
+ </span>
401
470
  <div className="fdy-cfl__actions">
402
471
  <button className="fdy-btn fdy-btn--ghost" type="button" onClick={closeDialog}>{props.closeLabel ?? 'Close'}</button>
472
+ {isMultiple ? (
473
+ <button className="fdy-btn" type="button" onClick={confirmPicks}>{props.confirmText ?? 'Confirm'}</button>
474
+ ) : null}
403
475
  </div>
404
476
  </div>
405
477
  </dialog>
@@ -23,7 +23,10 @@ interface CflPage {
23
23
  }
24
24
 
25
25
  const props = defineProps<{
26
- modelValue: Row | null;
26
+ /** Single: `Row | null`. With `multiple`, an array — `Row[] | null`, where null and [] both mean
27
+ * nothing picked. The enhancer has had `data-fdy-cfl-multiple` all along; this is the typed
28
+ * wrappers catching up (#019). */
29
+ modelValue: Row | Row[] | null;
27
30
  fetchPage: (query: string, page: number) => Promise<CflPage>;
28
31
  columns: ReadonlyArray<CflColumn>;
29
32
  display: (row: Row) => string;
@@ -46,6 +49,16 @@ const props = defineProps<{
46
49
  closeLabel?: string;
47
50
  /** aria-label for the button that opens the picker. Default 'Open search'. */
48
51
  openLabel?: string;
52
+ /** Tick rows and commit them together, instead of committing the row that was clicked. The kit's
53
+ * own enhancer offers this (`data-fdy-cfl-multiple`); a screen that gathers six expense claims
54
+ * onto one document wants one dialog, not six. */
55
+ multiple?: boolean;
56
+ /** Footer label while picking, `{n}` replaced by the tick count. Default '{n} selected'. */
57
+ selectedText?: string;
58
+ /** The multi-select commit button. Default 'Confirm'. */
59
+ confirmText?: string;
60
+ /** Footer hint in single mode. Default 'Click a row to choose it'. */
61
+ hintText?: string;
49
62
  placeholder?: string;
50
63
  disabled?: boolean;
51
64
  /** Locked/view mode: shows the picked value (focusable, copyable), but the search dialog can't be opened. Unlike `disabled`, it keeps tab order and isn't greyed. */
@@ -62,8 +75,8 @@ const props = defineProps<{
62
75
  }>();
63
76
 
64
77
  const emit = defineEmits<{
65
- 'update:modelValue': [value: Row | null];
66
- change: [value: Row | null];
78
+ 'update:modelValue': [value: Row | Row[] | null];
79
+ change: [value: Row | Row[] | null];
67
80
  }>();
68
81
 
69
82
  const baseId: string = useId();
@@ -91,13 +104,20 @@ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly
91
104
 
92
105
  const showClear: ComputedRef<boolean> = computed(
93
106
  (): boolean =>
94
- props.clearable === true && props.modelValue != null && isDisabled.value === false && isReadonly.value === false,
107
+ props.clearable === true && currentRows.value.length > 0 && isDisabled.value === false && isReadonly.value === false,
95
108
  );
96
109
  const clearLabelText: ComputedRef<string> = computed((): string => props.clearLabel ?? 'Clear selection');
97
110
  const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
98
- const displayValue: ComputedRef<string> = computed((): string =>
99
- props.modelValue !== null ? props.display(props.modelValue) : '',
111
+ /* `display()` takes one row, so in multi the field states HOW MANY — naming one of six would be a
112
+ lie, and naming all six does not fit a control that is 22rem wide. */
113
+ const currentRows: ComputedRef<Row[]> = computed((): Row[] =>
114
+ Array.isArray(props.modelValue) ? props.modelValue : props.modelValue !== null ? [props.modelValue as Row] : [],
100
115
  );
116
+ const selectedTextFor = (n: number): string => (props.selectedText ?? '{n} selected').replace('{n}', String(n));
117
+ const displayValue: ComputedRef<string> = computed((): string => {
118
+ if (props.multiple === true) return currentRows.value.length === 0 ? '' : selectedTextFor(currentRows.value.length);
119
+ return props.modelValue !== null ? props.display(props.modelValue as Row) : '';
120
+ });
101
121
  // The results <table> (owner of `resultsId`) only renders in the rows branch, so gate the
102
122
  // search input's aria refs on rows existing — otherwise they'd dangle during loading/empty/error.
103
123
  const hasRows: ComputedRef<boolean> = computed((): boolean => rows.value.length > 0);
@@ -181,6 +201,37 @@ function setActive(index: number): void {
181
201
  });
182
202
  }
183
203
 
204
+ /* The ticks live here, not in `modelValue`, because a multi dialog is only committed at Confirm:
205
+ closing it must leave the caller's value exactly as it was. Seeded from `modelValue` on open. */
206
+ const picked: Ref<Row[]> = ref([]) as Ref<Row[]>;
207
+
208
+ const pickedKeys: ComputedRef<Set<string>> = computed(
209
+ (): Set<string> => new Set(picked.value.map((r: Row): string => props.rowKey(r)))
210
+ );
211
+
212
+ function isPicked(row: Row): boolean {
213
+ return pickedKeys.value.has(props.rowKey(row));
214
+ }
215
+
216
+ function togglePick(row: Row): void {
217
+ const key: string = props.rowKey(row);
218
+ const at: number = picked.value.findIndex((r: Row): boolean => props.rowKey(r) === key);
219
+ if (at === -1) picked.value = [...picked.value, row];
220
+ else picked.value = picked.value.filter((_: Row, i: number): boolean => i !== at);
221
+ }
222
+
223
+ /* A click means "tick this" in multi and "this is my answer" in single — the whole difference. */
224
+ function onRowClick(row: Row): void {
225
+ if (props.multiple === true) togglePick(row);
226
+ else commit(row);
227
+ }
228
+
229
+ function confirmPicks(): void {
230
+ emit('update:modelValue', picked.value);
231
+ emit('change', picked.value);
232
+ closeDialog();
233
+ }
234
+
184
235
  function commit(row: Row | null): void {
185
236
  emit('update:modelValue', row);
186
237
  emit('change', row);
@@ -190,6 +241,7 @@ function commit(row: Row | null): void {
190
241
  /* Unsetting is not "picking nothing" — it must not open or close the dialog, and it must leave focus
191
242
  on a control that still exists, so focus returns to the trigger beside it. */
192
243
  function clearValue(): void {
244
+ picked.value = [];
193
245
  emit('update:modelValue', null);
194
246
  emit('change', null);
195
247
  triggerEl.value?.focus();
@@ -221,7 +273,7 @@ function onKeydown(e: KeyboardEvent): void {
221
273
  const row: Row | undefined = activeIndex.value >= 0 ? rows.value[activeIndex.value] : undefined;
222
274
  if (row !== undefined) {
223
275
  e.preventDefault();
224
- commit(row);
276
+ onRowClick(row);
225
277
  }
226
278
  break;
227
279
  }
@@ -244,6 +296,8 @@ function openDialog(): void {
244
296
  hasMore.value = false;
245
297
  error.value = null;
246
298
  activeIndex.value = -1;
299
+ /* Re-seeded per open, so Cancel really is a cancel: the ticks start as whatever the caller holds. */
300
+ picked.value = props.multiple === true ? [...currentRows.value] : [];
247
301
  dialogEl.value?.showModal();
248
302
  void nextTick((): void => searchEl.value?.focus());
249
303
  void loadPage(0, false);
@@ -361,6 +415,7 @@ onBeforeUnmount((): void => {
361
415
  <table :id="resultsId" class="fdy-table" aria-label="Search results">
362
416
  <thead>
363
417
  <tr>
418
+ <th v-if="multiple === true" scope="col"><span class="fdy-visually-hidden">{{ selectedTextFor(picked.length) }}</span></th>
364
419
  <th v-for="col in columns" :key="col.key" scope="col">{{ col.label }}</th>
365
420
  </tr>
366
421
  </thead>
@@ -370,10 +425,14 @@ onBeforeUnmount((): void => {
370
425
  :id="rowId(i)"
371
426
  :key="rowKey(row)"
372
427
  class="fdy-cfl__row"
373
- :aria-selected="i === activeIndex ? 'true' : undefined"
374
- @click="commit(row)"
428
+ :class="{ 'is-active': i === activeIndex }"
429
+ :aria-selected="multiple === true ? String(isPicked(row)) : i === activeIndex ? 'true' : undefined"
430
+ @click="onRowClick(row)"
375
431
  @mousemove="setActive(i)"
376
432
  >
433
+ <td v-if="multiple === true" class="fdy-cfl__check">
434
+ <input class="fdy-check" type="checkbox" tabindex="-1" :checked="isPicked(row)" aria-hidden="true" />
435
+ </td>
377
436
  <td v-for="col in columns" :key="col.key">{{ cellText(row, col.key) }}</td>
378
437
  </tr>
379
438
  </tbody>
@@ -381,7 +440,7 @@ onBeforeUnmount((): void => {
381
440
 
382
441
  <div v-if="error !== null" class="fdy-cfl__empty" role="alert" style="padding:var(--space-4) var(--space-5)">
383
442
  <p style="margin:0 0 var(--space-3)">{{ error.message }}</p>
384
- <button class="fdy-btn fdy-btn--sm" type="button" @click="retry">Coba lagi</button>
443
+ <button class="fdy-btn fdy-btn--sm" type="button" @click="retry">{{ retryText ?? 'Try again' }}</button>
385
444
  </div>
386
445
  <div v-else-if="hasMore" style="padding:var(--space-3) var(--space-4);text-align:center">
387
446
  <button class="fdy-btn fdy-btn--ghost fdy-btn--sm" type="button" :disabled="loading" @click="loadMore">
@@ -393,9 +452,12 @@ onBeforeUnmount((): void => {
393
452
  </div>
394
453
 
395
454
  <div class="fdy-modal__footer">
396
- <span class="fdy-cfl__count">Click a row to choose it</span>
455
+ <span class="fdy-cfl__count" aria-live="polite">
456
+ {{ multiple === true ? selectedTextFor(picked.length) : (hintText ?? 'Click a row to choose it') }}
457
+ </span>
397
458
  <div class="fdy-cfl__actions">
398
459
  <button class="fdy-btn fdy-btn--ghost" type="button" @click="closeDialog">{{ closeLabel ?? 'Close' }}</button>
460
+ <button v-if="multiple === true" class="fdy-btn" type="button" @click="confirmPicks">{{ confirmText ?? 'Confirm' }}</button>
399
461
  </div>
400
462
  </div>
401
463
  </dialog>
@@ -1089,6 +1089,18 @@ a { color: var(--color-primary); }
1089
1089
  .fdy-stat__value{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:1;color:var(--color-text);font-variant-numeric:tabular-nums;}
1090
1090
  .fdy-stat__value small{font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
1091
1091
  .fdy-stat__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);}
1092
+ /* A stat value is display type sized for a COUNT — "1,284" — and a back office puts money in it.
1093
+ "IDR 300,000.00" needs 224px at --text-3xl and the grid's own track is 11rem, so it wrapped
1094
+ between the currency and the number: two lines of 31px where the design says one. Measured, not
1095
+ guessed (#020).
1096
+ The value shrinks only when its own column is too narrow to hold it, so a wide dashboard keeps
1097
+ exactly the type it has today. Scoped to a stat INSIDE .fdy-stats, where the width comes from the
1098
+ grid track — `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
1099
+ own content. The unconditional rule above stays as the fallback wherever @container is missing. */
1100
+ @supports (container-type:inline-size){
1101
+ .fdy-stats>.fdy-stat{container-type:inline-size;}
1102
+ .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
1103
+ }
1092
1104
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
1093
1105
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
1094
1106
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -1256,7 +1268,12 @@ a { color: var(--color-primary); }
1256
1268
  .fdy-dropzone__text{display:flex;flex-direction:column;gap:var(--space-1);}
1257
1269
 
1258
1270
  /* File rows (list of added/uploaded/rejected files) */
1259
- .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);}
1271
+ /* Symmetric on purpose. The list carried margin-top and nothing below, so whatever follows it —
1272
+ an "Add files" button, in every upload UI there is — sat flush against the last row: measured
1273
+ 0px. A block that claims space on one side only is not spacing, it is a lean. */
1274
+ .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);margin-bottom:var(--space-3);}
1275
+ /* Nothing after it, nothing to separate from — so the room is not spent. */
1276
+ .fdy-filelist:last-child{margin-bottom:0;}
1260
1277
  /* Side-by-side rows on wide screens (reflow to one column when narrow). */
1261
1278
  .fdy-filelist--grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));}
1262
1279
  .fdy-file{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);border:var(--bw) solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface);}
@@ -1768,6 +1785,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1768
1785
  .fdy-table-bulkbar__spacer{flex:1;}
1769
1786
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1770
1787
 
1788
+ /* Controls inside a data row. `.fdy-input` and friends are width:100%, which in an auto-layout
1789
+ table means they contribute NO intrinsic width — the column shrinks to whatever the header text
1790
+ needs and the control collapses with it. A NOTE column ends up a box too narrow to read what you
1791
+ typed into it. The floor is per-control rather than on the column, because the table does not
1792
+ know which of its columns hold controls. */
1793
+ .fdy-table td>.fdy-input,.fdy-table td>.fdy-textarea,.fdy-table td>.fdy-combo,.fdy-table td>.fdy-input-group,.fdy-table td>.fdy-datepicker,.fdy-table td>.fdy-timepicker{min-width:7rem;}
1794
+
1771
1795
  /* Freeday — Tabs (WAI-ARIA APG) */
1772
1796
  /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1773
1797
  .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
package/dist/freeday.css CHANGED
@@ -706,6 +706,18 @@ a { color: var(--color-primary); }
706
706
  .fdy-stat__value{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:1;color:var(--color-text);font-variant-numeric:tabular-nums;}
707
707
  .fdy-stat__value small{font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
708
708
  .fdy-stat__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);}
709
+ /* A stat value is display type sized for a COUNT — "1,284" — and a back office puts money in it.
710
+ "IDR 300,000.00" needs 224px at --text-3xl and the grid's own track is 11rem, so it wrapped
711
+ between the currency and the number: two lines of 31px where the design says one. Measured, not
712
+ guessed (#020).
713
+ The value shrinks only when its own column is too narrow to hold it, so a wide dashboard keeps
714
+ exactly the type it has today. Scoped to a stat INSIDE .fdy-stats, where the width comes from the
715
+ grid track — `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
716
+ own content. The unconditional rule above stays as the fallback wherever @container is missing. */
717
+ @supports (container-type:inline-size){
718
+ .fdy-stats>.fdy-stat{container-type:inline-size;}
719
+ .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
720
+ }
709
721
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
710
722
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
711
723
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -873,7 +885,12 @@ a { color: var(--color-primary); }
873
885
  .fdy-dropzone__text{display:flex;flex-direction:column;gap:var(--space-1);}
874
886
 
875
887
  /* File rows (list of added/uploaded/rejected files) */
876
- .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);}
888
+ /* Symmetric on purpose. The list carried margin-top and nothing below, so whatever follows it —
889
+ an "Add files" button, in every upload UI there is — sat flush against the last row: measured
890
+ 0px. A block that claims space on one side only is not spacing, it is a lean. */
891
+ .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);margin-bottom:var(--space-3);}
892
+ /* Nothing after it, nothing to separate from — so the room is not spent. */
893
+ .fdy-filelist:last-child{margin-bottom:0;}
877
894
  /* Side-by-side rows on wide screens (reflow to one column when narrow). */
878
895
  .fdy-filelist--grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));}
879
896
  .fdy-file{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);border:var(--bw) solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface);}
@@ -1385,6 +1402,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1385
1402
  .fdy-table-bulkbar__spacer{flex:1;}
1386
1403
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1387
1404
 
1405
+ /* Controls inside a data row. `.fdy-input` and friends are width:100%, which in an auto-layout
1406
+ table means they contribute NO intrinsic width — the column shrinks to whatever the header text
1407
+ needs and the control collapses with it. A NOTE column ends up a box too narrow to read what you
1408
+ typed into it. The floor is per-control rather than on the column, because the table does not
1409
+ know which of its columns hold controls. */
1410
+ .fdy-table td>.fdy-input,.fdy-table td>.fdy-textarea,.fdy-table td>.fdy-combo,.fdy-table td>.fdy-input-group,.fdy-table td>.fdy-datepicker,.fdy-table td>.fdy-timepicker{min-width:7rem;}
1411
+
1388
1412
  /* Freeday — Tabs (WAI-ARIA APG) */
1389
1413
  /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1390
1414
  .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cahyo-dimas/freeday",
3
- "version": "1.41.0",
3
+ "version": "1.43.0",
4
4
  "description": "Freeday — token-driven, framework-agnostic UI KIT (design source-of-truth).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -92,7 +92,7 @@
92
92
  "scripts": {
93
93
  "build": "node tokens/build.mjs",
94
94
  "test": "node --test",
95
- "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs",
95
+ "test:browser": "node --test browser/vanilla.mjs browser/adapter.mjs browser/layout.mjs browser/theme.mjs browser/state.mjs browser/root-init.mjs browser/upload-states.mjs browser/number.mjs browser/card-stretch.mjs browser/text-override.mjs browser/control-heights.mjs browser/cfl-multi.mjs browser/crowding.mjs",
96
96
  "prepack": "node tokens/build.mjs",
97
97
  "version": "node tokens/build.mjs && git add dist",
98
98
  "typecheck:react": "tsc -p adapters/react/tsconfig.json --noEmit"
@@ -51,6 +51,18 @@
51
51
  .fdy-stat__value{font-family:var(--font-display);font-size:var(--text-3xl);font-weight:var(--weight-bold);letter-spacing:var(--tracking-tighter);line-height:1;color:var(--color-text);font-variant-numeric:tabular-nums;}
52
52
  .fdy-stat__value small{font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
53
53
  .fdy-stat__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);}
54
+ /* A stat value is display type sized for a COUNT — "1,284" — and a back office puts money in it.
55
+ "IDR 300,000.00" needs 224px at --text-3xl and the grid's own track is 11rem, so it wrapped
56
+ between the currency and the number: two lines of 31px where the design says one. Measured, not
57
+ guessed (#020).
58
+ The value shrinks only when its own column is too narrow to hold it, so a wide dashboard keeps
59
+ exactly the type it has today. Scoped to a stat INSIDE .fdy-stats, where the width comes from the
60
+ grid track — `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
61
+ own content. The unconditional rule above stays as the fallback wherever @container is missing. */
62
+ @supports (container-type:inline-size){
63
+ .fdy-stats>.fdy-stat{container-type:inline-size;}
64
+ .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
65
+ }
54
66
  .fdy-stats--boxed{background:var(--color-surface);border:var(--bw) solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-1);gap:0;}
55
67
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
56
68
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -16,7 +16,12 @@
16
16
  .fdy-dropzone__text{display:flex;flex-direction:column;gap:var(--space-1);}
17
17
 
18
18
  /* File rows (list of added/uploaded/rejected files) */
19
- .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);}
19
+ /* Symmetric on purpose. The list carried margin-top and nothing below, so whatever follows it —
20
+ an "Add files" button, in every upload UI there is — sat flush against the last row: measured
21
+ 0px. A block that claims space on one side only is not spacing, it is a lean. */
22
+ .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);margin-bottom:var(--space-3);}
23
+ /* Nothing after it, nothing to separate from — so the room is not spent. */
24
+ .fdy-filelist:last-child{margin-bottom:0;}
20
25
  /* Side-by-side rows on wide screens (reflow to one column when narrow). */
21
26
  .fdy-filelist--grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));}
22
27
  .fdy-file{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);border:var(--bw) solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface);}
@@ -128,3 +128,10 @@
128
128
  .fdy-table-bulkbar__count{font-weight:var(--weight-semibold);white-space:nowrap;}
129
129
  .fdy-table-bulkbar__spacer{flex:1;}
130
130
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
131
+
132
+ /* Controls inside a data row. `.fdy-input` and friends are width:100%, which in an auto-layout
133
+ table means they contribute NO intrinsic width — the column shrinks to whatever the header text
134
+ needs and the control collapses with it. A NOTE column ends up a box too narrow to read what you
135
+ typed into it. The floor is per-control rather than on the column, because the table does not
136
+ know which of its columns hold controls. */
137
+ .fdy-table td>.fdy-input,.fdy-table td>.fdy-textarea,.fdy-table td>.fdy-combo,.fdy-table td>.fdy-input-group,.fdy-table td>.fdy-datepicker,.fdy-table td>.fdy-timepicker{min-width:7rem;}