@cahyo-dimas/freeday 1.41.0 → 1.46.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,137 @@
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.46.0] — 2026-08-19
7
+ The docs site catches up with the components, and writing it out as a consumer found a defect in
8
+ #016's own contract.
9
+ ### Fixed
10
+ - **`data-fdy-text-*` could not be written the way HTML allows** (#023). The keys in each enhancer's
11
+ `TEXT` table are camelCase, and `textOf` looked up `'data-fdy-text-' + key` — but **HTML lowercases
12
+ attribute names**, so `data-fdy-text-filterText` becomes `...filtertext` while
13
+ `data-fdy-text-filter-text`, the spelling anybody would actually reach for, is a DIFFERENT
14
+ attribute the enhancer never read. It failed silently, which is the only way an override can fail.
15
+ The key is kebab-cased for the lookup now; the run-together spelling still resolves, so markup
16
+ written against 1.39.0 keeps working. Nine enhancers.
17
+ It survived #016's own guard because all seven overrides that guard asserts use **single-word**
18
+ keys, which have no case to lose — the guard was not wrong, it was unrepresentative. The spec now
19
+ overrides `filterText`, reached by clicking a column filter open. Mutation-tested.
20
+ ### Docs
21
+ - **`docs/index.html` was nine releases behind** — it stated v1.34.0 and demoed none of what landed
22
+ after it. Brought level with the components, as live demos rather than a feature list, because
23
+ that is what the rest of the page is:
24
+ - **New section — Tabel beku.** `.fdy-table--sticky` / `--sticky-head`, `.fdy-table__freeze` +
25
+ `--fdy-freeze-left`, `.fdy-table-scroll--frozen` + `--fdy-table-frozen-h` (1.35.0, 1.37.0), with
26
+ a rate matrix you can actually scroll on both axes, and the reason the two axes are separate
27
+ modifiers.
28
+ - **New section — Teks enhancer (i18n).** The `data-fdy-text-*` contract (1.39.0), demoed as the
29
+ same data table rendered entirely in English — the demo that found #023.
30
+ - **Badge tone scale** `--tone-1`…`--tone-8` (1.44.0), with the distinction the note drew: semantic
31
+ variants state a judgement, tones distinguish without implying one.
32
+ - **Date picker** now states the days → months → years drill (1.36.0); **Choose-from-list** states
33
+ `multiple` for the typed wrappers, not only the enhancer's `data-fdy-cfl-multiple` (1.42.0).
34
+ - Verified in a browser rather than by reading the markup: the i18n demo renders `Showing 1–3 of 5`
35
+ and a `Previous` pager, and the frozen table computes `border-collapse:separate` with
36
+ `position:sticky` cells.
37
+
38
+ ## [1.45.0] — 2026-08-19
39
+ Two defects in 1.42.0's multi-select CFL, found by the app that asked for it (IDU_EMATE_APPL_WEB, #022).
40
+ ### Fixed
41
+ - **The row tick rendered as the browser's checkbox, not the kit's** (#022), in all three adapters.
42
+ They shipped `class="fdy-check"` on a bare `<input>`; that class styles a wrapping `<label>` and
43
+ its `input` DESCENDANT, so on the input itself it applied `inline-flex` and a cursor and nothing
44
+ else — a raw UA control in the middle of a styled dialog. `COMPONENTS.md` already said which class
45
+ a bare input in a table takes: `.fdy-checkbox`. Every behavioural assertion in `browser/cfl-multi`
46
+ passed while it was wrong, because none of them looked at the rendering.
47
+ - **`aria-selected` broke `vue-tsc` in consuming apps** (#022). `String(isPicked(row))` widens to
48
+ `string`, and Vue types the attribute as `Booleanish` — so a strict app type-checking the kit's own
49
+ `.vue` source failed on a file it does not own. Now `'true' : 'false'`.
50
+ ### Added — guards
51
+ - `browser/cfl-multi.mjs` asserts the tick's computed `appearance` is `none`, which is the tell that
52
+ the kit painted the box rather than the UA. Mutation-tested.
53
+
54
+ ## [1.44.0] — 2026-08-19
55
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #021).
56
+ ### Added
57
+ - **`--tone-1`…`--tone-8` on `.fdy-badge`** — a status vocabulary larger than the semantic palette.
58
+ `.fdy-avatar--tone-*` and `.fdy-chip--tone-*` already carried the categorical scale; the badge,
59
+ which is the component that actually renders status, did not. The reporting app's document lists
60
+ carry **ten distinct statuses in one column** — Draft, Submitted, Approved, Completed, Settled,
61
+ Closed, Transferred, InDeclaration, Open, Rejected — against five modifiers, so Approved, Closed
62
+ and Completed came out the same green and Draft, InDeclaration and Transferred the same grey.
63
+ Semantics still come first: a state that IS good, bad or waiting takes `--success` / `--danger` /
64
+ `--warning`, and these are for the rest. Same `--tone-*` tokens and the same 18%/50% mix as the
65
+ other two, so the existing contrast test already measures them.
66
+ ### Added — guards
67
+ - `test/css.test.mjs` asserts all eight bind their own token (a gap silently reuses a colour) and
68
+ that the mix stays identical to the avatar's and the chip's — one contrast test covers the three,
69
+ so a badge that drifted to its own ratio would be gated by a test measuring something else.
70
+ `test/contrast.test.mjs` renamed from "avatar tones" to "categorical tones" to say so.
71
+
72
+ ## [1.43.0] — 2026-08-19
73
+ Three crowding reports from one settlement screen (IDU_EMATE_APPL_WEB, #020). All three measured in
74
+ Chromium rather than read off the CSS — each is a layout outcome, invisible in the stylesheet.
75
+ ### Fixed
76
+ - **A control in a table column collapsed** (#020). `.fdy-input` is `width:100%`, which in an
77
+ auto-layout table contributes **no intrinsic width**: the column shrank to its header text and took
78
+ the control with it. A NOTE box measured **36px** — too narrow to read what you typed into it.
79
+ Controls that are direct children of a `td` now keep a `7rem` floor (112px measured). Per-control
80
+ rather than per-column, because the table cannot know which columns hold controls. `width:100%` was
81
+ never wrong; it is only in a table that 100% of nothing is nothing.
82
+ - **A stat value wrapped between the currency and the number** (#020). `.fdy-stat__value` is
83
+ `--text-3xl`; `IDR 300,000.00` needs **224px** at that size and `.fdy-stats` tracks are
84
+ `minmax(11rem,1fr)`, so a three-across row at 660px gave it ~205px and it broke onto two lines.
85
+ It now shrinks **only when its own column cannot hold it** — a 205px track renders 22.7px on one
86
+ line, a 420px track still renders exactly 31px. Shrinking the token instead would have taken 31px
87
+ from every consumer to fix a column that was 19px short. Scoped to a stat inside `.fdy-stats`,
88
+ where the width comes from the grid track: `container-type:inline-size` on a standalone
89
+ `.fdy-stat` would stop it sizing to its own content, and the unconditional rule stays as the
90
+ fallback where `@container` is missing.
91
+ - **`.fdy-filelist` claimed space above itself and none below** (#020), so the "Add files" button
92
+ every upload UI puts after it sat flush against the last row — a measured **0px**. Symmetric now,
93
+ with `:last-child` dropping the bottom margin so nothing pays for room it does not need.
94
+ ### Added — guards
95
+ - `browser/crowding.mjs` measures all three. The stat case asserts **both** directions — one line
96
+ when narrow, still exactly `31px` when wide — because a fix that quietly shrank every dashboard
97
+ would pass a "does it wrap?" test.
98
+
99
+ ## [1.42.0] — 2026-08-19
100
+ One note from the back-office app (IDU_EMATE_APPL_WEB, #019), and a string the #009 guard could not see.
101
+ ### Added
102
+ - **`multiple` on the typed `FdyCfl`** (Vue · React · Blazor). The enhancer has offered
103
+ `data-fdy-cfl-multiple` since it shipped and `COMPONENTS.md` documented it; all three wrappers were
104
+ single-valued **at the type level**, so there was nothing to widen from the outside. An app that
105
+ started on the enhancer and later adopted the wrapper silently lost a capability — which is the
106
+ actual defect, more than the missing feature. The settlement screen that reported it gathers six or
107
+ eight approved claims onto one document and was doing six open→search→click cycles.
108
+ Rows tick instead of committing, the footer counts them, and **Confirm** commits the set. Vue and
109
+ React widen their model to `Row[] | null`; Blazor takes `Values` / `ValuesChanged` instead, because
110
+ a nullable union is not a C# shape. New strings: `selectedText` (`{n} selected`), `confirmText`,
111
+ `hintText` — the last one making the single-mode footer overridable too, which it never was.
112
+ ### Notes on the shape of the fix
113
+ - **The ticks are component state, not the model.** Closing without Confirm has to leave the caller's
114
+ value exactly as it was, so the dialog seeds its ticks from the bound value on every open. Binding
115
+ them straight to the model would make Cancel a lie.
116
+ - **The field states `{n} selected`.** `display()` takes ONE row: naming one of six would be wrong and
117
+ naming all six does not fit a 22rem control.
118
+ - **Blazor gained a modal footer**, which it never had — Vue and React both commit from one.
119
+ - The tick checkbox is `aria-hidden` and inert. `.fdy-cfl__check input` is already
120
+ `pointer-events:none` in the kit's CSS, so the row owns the click and a handler on the box would
121
+ have been unreachable code; `aria-selected` on the row is what carries the state.
122
+ ### Fixed
123
+ - **`FdyCfl`'s second retry button said `Coba lagi`** (Vue · React). The error state inside the
124
+ results branch had a hard-coded Indonesian literal while the button two states above it correctly
125
+ read `retryText ?? 'Try again'` — so the component both had the prop and ignored it. #009's guard
126
+ missed it because **neither `coba` nor `lagi` is in its word list**, which is #015's lesson landing
127
+ a third time: a word list is a floor, not a ceiling. Both now read `retryText`.
128
+ ### Added — guards
129
+ - **The multi flow is driven by real clicks, in both adapters** (#019). `browser/cfl-multi.mjs`
130
+ asserts that a click ticks rather than commits, that the dialog stays open, that `aria-selected`
131
+ and the footer count follow, that a second click unticks, that Confirm hands back an **array** in
132
+ order, and that closing without Confirm discards the ticks *and* does not remember them on re-open.
133
+ Vue and React run the same script, so a divergence between the two fails here instead of in an app.
134
+ A typecheck cannot tell you whether a mode ever renders. Mutation-tested — committing
135
+ `picked[0]` instead of the array fails it by name.
136
+
6
137
  ## [1.41.0] — 2026-08-19
7
138
  One note from the account app (IDU_EMATE_ACCT_WEB, #018) — the first raised from that repo.
8
139
  ### 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">
@@ -767,7 +773,9 @@ pagination. Needs `freeday-table.js`. Wrap the whole thing in `.fdy-datatable` +
767
773
  wrapper**.
768
774
  - **Overriding an enhancer's strings.** Each enhancer keeps its strings in one `TEXT` table and
769
775
  reads them through `textOf()`, so any of them can be replaced per element with
770
- **`data-fdy-text-<key>`** — no forking, and no rendering the nodes yourself:
776
+ **`data-fdy-text-<key>`**, the key kebab-cased — no forking, and no rendering the nodes yourself.
777
+ (HTML lowercases attribute names, so a camelCase key like `filterText` is written
778
+ `data-fdy-text-filter-text`; the run-together spelling still resolves.):
771
779
 
772
780
  ```html
773
781
  <div data-fdy-table
@@ -1100,6 +1108,12 @@ while it stays a real `<button>`/`<a>` with real keyboard semantics.
1100
1108
  Inline status pill: `--success` `--warning` `--danger` `--info` `--outline`. Never colour-only —
1101
1109
  the text carries the meaning.
1102
1110
 
1111
+ `--tone-1`…`--tone-8` for a status vocabulary larger than the semantic palette. Semantics first —
1112
+ a state that IS good, bad or waiting takes `--success` / `--danger` / `--warning`; these are for
1113
+ the rest, so two states a workflow distinguishes never share a look. Map each status to a FIXED
1114
+ tone, never to a hash of the string: a status is a closed set, and a hash reassigns colours the
1115
+ moment the set grows.
1116
+
1103
1117
  **Notification badge is the overlay variant**, not a separate component: wrap anything in
1104
1118
  `.fdy-badge-wrap` and add `.fdy-badge-ov` (`--primary` `--danger` `--accent` `--info` `--success`
1105
1119
  `--warning`, `--dot` for a bare dot, `--top-left` / `--bottom-right` / `--bottom-left` to move it).
@@ -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-checkbox" 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-checkbox" 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>