@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.
@@ -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 ? (isPicked(row) ? 'true' : 'false') : 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-checkbox" 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>
@@ -26,8 +26,18 @@
26
26
  position: '{n} dari {total}',
27
27
  slide: 'Slide {n}'
28
28
  };
29
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
30
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
31
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
32
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
33
+ still resolves, for markup written against 1.39.0. */
34
+ function textAttr(root, key) {
35
+ if (!root || !root.getAttribute) return null;
36
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
37
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
38
+ }
29
39
  function textOf(root, key, vars) {
30
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
40
+ var custom = textAttr(root, key);
31
41
  var s = custom != null && custom !== '' ? custom : TEXT[key];
32
42
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
33
43
  return s;
@@ -62,8 +62,18 @@
62
62
  back: 'Kembali satu tingkat',
63
63
  submenu: '{label}, submenu'
64
64
  };
65
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
66
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
67
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
68
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
69
+ still resolves, for markup written against 1.39.0. */
70
+ function textAttr(root, key) {
71
+ if (!root || !root.getAttribute) return null;
72
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
73
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
74
+ }
65
75
  function textOf(root, key, vars) {
66
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
76
+ var custom = textAttr(root, key);
67
77
  var s = custom != null && custom !== '' ? custom : TEXT[key];
68
78
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
69
79
  return s;
@@ -41,8 +41,18 @@
41
41
  var TEXT = {
42
42
  selected: '{n} dipilih'
43
43
  };
44
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
45
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
46
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
47
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
48
+ still resolves, for markup written against 1.39.0. */
49
+ function textAttr(root, key) {
50
+ if (!root || !root.getAttribute) return null;
51
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
52
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
53
+ }
44
54
  function textOf(root, key, vars) {
45
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
55
+ var custom = textAttr(root, key);
46
56
  var s = custom != null && custom !== '' ? custom : TEXT[key];
47
57
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
48
58
  return s;
@@ -55,8 +55,13 @@
55
55
  mismatch: 'Nilai tidak cocok.',
56
56
  invalid: 'Tidak valid.'
57
57
  };
58
+ /* Kebab-cased for the lookup — see the note in the other enhancers: HTML lowercases attribute
59
+ names, so `data-fdy-text-filter-text` and `data-fdy-text-filtertext` are different attributes
60
+ and only one of them is what an author would write. */
58
61
  function textOf(root, key) {
59
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
62
+ if (!root || !root.getAttribute) return TEXT[key];
63
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
64
+ var custom = kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
60
65
  return custom != null && custom !== '' ? custom : TEXT[key];
61
66
  }
62
67
  var VALIDITY_KEYS = ['valueMissing', 'typeMismatch', 'patternMismatch', 'tooShort', 'tooLong', 'rangeUnderflow', 'rangeOverflow', 'stepMismatch', 'badInput'];
@@ -59,8 +59,18 @@
59
59
  show: 'Tampilkan kata sandi',
60
60
  hide: 'Sembunyikan kata sandi'
61
61
  };
62
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
63
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
64
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
65
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
66
+ still resolves, for markup written against 1.39.0. */
67
+ function textAttr(root, key) {
68
+ if (!root || !root.getAttribute) return null;
69
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
70
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
71
+ }
62
72
  function textOf(root, key, vars) {
63
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
73
+ var custom = textAttr(root, key);
64
74
  var s = custom != null && custom !== '' ? custom : TEXT[key];
65
75
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
66
76
  return s;
@@ -27,8 +27,18 @@
27
27
  done: 'Selesai',
28
28
  next: 'Lanjut'
29
29
  };
30
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
31
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
32
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
33
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
34
+ still resolves, for markup written against 1.39.0. */
35
+ function textAttr(root, key) {
36
+ if (!root || !root.getAttribute) return null;
37
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
38
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
39
+ }
30
40
  function textOf(root, key, vars) {
31
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
41
+ var custom = textAttr(root, key);
32
42
  var s = custom != null && custom !== '' ? custom : TEXT[key];
33
43
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
34
44
  return s;
@@ -52,8 +52,18 @@
52
52
  rows: '{n} baris',
53
53
  info: 'Menampilkan {from}–{to} dari {total}'
54
54
  };
55
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
56
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
57
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
58
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
59
+ still resolves, for markup written against 1.39.0. */
60
+ function textAttr(root, key) {
61
+ if (!root || !root.getAttribute) return null;
62
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
63
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
64
+ }
55
65
  function textOf(root, key, vars) {
56
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
66
+ var custom = textAttr(root, key);
57
67
  var s = custom != null && custom !== '' ? custom : TEXT[key];
58
68
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
59
69
  return s;
@@ -56,8 +56,18 @@
56
56
  var TEXT = {
57
57
  close: 'Tutup'
58
58
  };
59
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
60
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
61
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
62
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
63
+ still resolves, for markup written against 1.39.0. */
64
+ function textAttr(root, key) {
65
+ if (!root || !root.getAttribute) return null;
66
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
67
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
68
+ }
59
69
  function textOf(root, key, vars) {
60
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
70
+ var custom = textAttr(root, key);
61
71
  var s = custom != null && custom !== '' ? custom : TEXT[key];
62
72
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
63
73
  return s;
@@ -64,8 +64,18 @@
64
64
  badType: 'Tipe berkas tidak didukung.',
65
65
  tooBig: 'Ukuran melebihi batas ({max}).'
66
66
  };
67
+ /* HTML lowercases attribute names, so a camelCase key like `filterText` can only ever be written
68
+ as `data-fdy-text-filtertext` — while the kebab form anybody would reach for,
69
+ `data-fdy-text-filter-text`, becomes a DIFFERENT attribute the enhancer never reads, and the
70
+ override fails silently. So the key is kebab-cased for the lookup; the run-together spelling
71
+ still resolves, for markup written against 1.39.0. */
72
+ function textAttr(root, key) {
73
+ if (!root || !root.getAttribute) return null;
74
+ var kebab = root.getAttribute('data-fdy-text-' + key.replace(/[A-Z]/g, function (c) { return '-' + c.toLowerCase(); }));
75
+ return kebab != null && kebab !== '' ? kebab : root.getAttribute('data-fdy-text-' + key);
76
+ }
67
77
  function textOf(root, key, vars) {
68
- var custom = root && root.getAttribute ? root.getAttribute('data-fdy-text-' + key) : null;
78
+ var custom = textAttr(root, key);
69
79
  var s = custom != null && custom !== '' ? custom : TEXT[key];
70
80
  if (vars) for (var k in vars) if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(vars[k]);
71
81
  return s;
@@ -635,6 +635,25 @@ a { color: var(--color-primary); }
635
635
  .fdy-badge--info{color:var(--color-info-strong);background:var(--color-info-soft);border-color:color-mix(in srgb,var(--color-info) 26%,transparent);}
636
636
  .fdy-badge--outline{background:transparent;border-color:var(--color-border-strong);color:var(--color-text-muted);box-shadow:none;}
637
637
 
638
+ /* Categorical tone badges — a status vocabulary larger than the semantic palette, from the
639
+ * general --tone-1..8 tokens. Same validated AA formula as .fdy-avatar--tone-* and
640
+ * .fdy-chip--tone-* (text >=4.5:1 both themes, gated by test/contrast.test.mjs).
641
+ *
642
+ * Semantics come first: a state that IS good, bad or waiting takes --success / --danger /
643
+ * --warning. These are for the rest — the states a workflow distinguishes but a palette of five
644
+ * cannot. A back-office document list carrying Draft, Submitted, Approved, Completed, Settled,
645
+ * Closed, Transferred, InDeclaration, Open and Rejected in ONE column collapsed to three colours,
646
+ * which is the report this shipped for. */
647
+ .fdy-badge--tone-1,.fdy-badge--tone-2,.fdy-badge--tone-3,.fdy-badge--tone-4,.fdy-badge--tone-5,.fdy-badge--tone-6,.fdy-badge--tone-7,.fdy-badge--tone-8{background:color-mix(in srgb,var(--_fdy-badge-tone) 18%,var(--color-surface));color:color-mix(in srgb,var(--_fdy-badge-tone) 50%,var(--color-text));border-color:color-mix(in srgb,var(--_fdy-badge-tone) 26%,transparent);}
648
+ .fdy-badge--tone-1{--_fdy-badge-tone:var(--tone-1);}
649
+ .fdy-badge--tone-2{--_fdy-badge-tone:var(--tone-2);}
650
+ .fdy-badge--tone-3{--_fdy-badge-tone:var(--tone-3);}
651
+ .fdy-badge--tone-4{--_fdy-badge-tone:var(--tone-4);}
652
+ .fdy-badge--tone-5{--_fdy-badge-tone:var(--tone-5);}
653
+ .fdy-badge--tone-6{--_fdy-badge-tone:var(--tone-6);}
654
+ .fdy-badge--tone-7{--_fdy-badge-tone:var(--tone-7);}
655
+ .fdy-badge--tone-8{--_fdy-badge-tone:var(--tone-8);}
656
+
638
657
  /* Overlay badge — anchors a small count/dot/icon badge to a corner of any element
639
658
  * (icon, button, text). Wrap the content in .fdy-badge-wrap; add .fdy-badge-ov as a
640
659
  * sibling. Default is a solid neutral pill; colour + dot + position via modifiers. */
@@ -1089,6 +1108,18 @@ a { color: var(--color-primary); }
1089
1108
  .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
1109
  .fdy-stat__value small{font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
1091
1110
  .fdy-stat__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);}
1111
+ /* A stat value is display type sized for a COUNT — "1,284" — and a back office puts money in it.
1112
+ "IDR 300,000.00" needs 224px at --text-3xl and the grid's own track is 11rem, so it wrapped
1113
+ between the currency and the number: two lines of 31px where the design says one. Measured, not
1114
+ guessed (#020).
1115
+ The value shrinks only when its own column is too narrow to hold it, so a wide dashboard keeps
1116
+ exactly the type it has today. Scoped to a stat INSIDE .fdy-stats, where the width comes from the
1117
+ grid track — `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
1118
+ own content. The unconditional rule above stays as the fallback wherever @container is missing. */
1119
+ @supports (container-type:inline-size){
1120
+ .fdy-stats>.fdy-stat{container-type:inline-size;}
1121
+ .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
1122
+ }
1092
1123
  .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
1124
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
1094
1125
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -1256,7 +1287,12 @@ a { color: var(--color-primary); }
1256
1287
  .fdy-dropzone__text{display:flex;flex-direction:column;gap:var(--space-1);}
1257
1288
 
1258
1289
  /* 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);}
1290
+ /* Symmetric on purpose. The list carried margin-top and nothing below, so whatever follows it —
1291
+ an "Add files" button, in every upload UI there is — sat flush against the last row: measured
1292
+ 0px. A block that claims space on one side only is not spacing, it is a lean. */
1293
+ .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);margin-bottom:var(--space-3);}
1294
+ /* Nothing after it, nothing to separate from — so the room is not spent. */
1295
+ .fdy-filelist:last-child{margin-bottom:0;}
1260
1296
  /* Side-by-side rows on wide screens (reflow to one column when narrow). */
1261
1297
  .fdy-filelist--grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));}
1262
1298
  .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 +1804,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1768
1804
  .fdy-table-bulkbar__spacer{flex:1;}
1769
1805
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1770
1806
 
1807
+ /* Controls inside a data row. `.fdy-input` and friends are width:100%, which in an auto-layout
1808
+ table means they contribute NO intrinsic width — the column shrinks to whatever the header text
1809
+ needs and the control collapses with it. A NOTE column ends up a box too narrow to read what you
1810
+ typed into it. The floor is per-control rather than on the column, because the table does not
1811
+ know which of its columns hold controls. */
1812
+ .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;}
1813
+
1771
1814
  /* Freeday — Tabs (WAI-ARIA APG) */
1772
1815
  /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1773
1816
  .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
@@ -252,6 +252,25 @@ a { color: var(--color-primary); }
252
252
  .fdy-badge--info{color:var(--color-info-strong);background:var(--color-info-soft);border-color:color-mix(in srgb,var(--color-info) 26%,transparent);}
253
253
  .fdy-badge--outline{background:transparent;border-color:var(--color-border-strong);color:var(--color-text-muted);box-shadow:none;}
254
254
 
255
+ /* Categorical tone badges — a status vocabulary larger than the semantic palette, from the
256
+ * general --tone-1..8 tokens. Same validated AA formula as .fdy-avatar--tone-* and
257
+ * .fdy-chip--tone-* (text >=4.5:1 both themes, gated by test/contrast.test.mjs).
258
+ *
259
+ * Semantics come first: a state that IS good, bad or waiting takes --success / --danger /
260
+ * --warning. These are for the rest — the states a workflow distinguishes but a palette of five
261
+ * cannot. A back-office document list carrying Draft, Submitted, Approved, Completed, Settled,
262
+ * Closed, Transferred, InDeclaration, Open and Rejected in ONE column collapsed to three colours,
263
+ * which is the report this shipped for. */
264
+ .fdy-badge--tone-1,.fdy-badge--tone-2,.fdy-badge--tone-3,.fdy-badge--tone-4,.fdy-badge--tone-5,.fdy-badge--tone-6,.fdy-badge--tone-7,.fdy-badge--tone-8{background:color-mix(in srgb,var(--_fdy-badge-tone) 18%,var(--color-surface));color:color-mix(in srgb,var(--_fdy-badge-tone) 50%,var(--color-text));border-color:color-mix(in srgb,var(--_fdy-badge-tone) 26%,transparent);}
265
+ .fdy-badge--tone-1{--_fdy-badge-tone:var(--tone-1);}
266
+ .fdy-badge--tone-2{--_fdy-badge-tone:var(--tone-2);}
267
+ .fdy-badge--tone-3{--_fdy-badge-tone:var(--tone-3);}
268
+ .fdy-badge--tone-4{--_fdy-badge-tone:var(--tone-4);}
269
+ .fdy-badge--tone-5{--_fdy-badge-tone:var(--tone-5);}
270
+ .fdy-badge--tone-6{--_fdy-badge-tone:var(--tone-6);}
271
+ .fdy-badge--tone-7{--_fdy-badge-tone:var(--tone-7);}
272
+ .fdy-badge--tone-8{--_fdy-badge-tone:var(--tone-8);}
273
+
255
274
  /* Overlay badge — anchors a small count/dot/icon badge to a corner of any element
256
275
  * (icon, button, text). Wrap the content in .fdy-badge-wrap; add .fdy-badge-ov as a
257
276
  * sibling. Default is a solid neutral pill; colour + dot + position via modifiers. */
@@ -706,6 +725,18 @@ a { color: var(--color-primary); }
706
725
  .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
726
  .fdy-stat__value small{font-size:var(--text-lg);font-weight:var(--weight-semibold);color:var(--color-text-muted);}
708
727
  .fdy-stat__meta{font-size:var(--text-sm);color:var(--color-text-muted);display:flex;align-items:center;gap:var(--space-2);}
728
+ /* A stat value is display type sized for a COUNT — "1,284" — and a back office puts money in it.
729
+ "IDR 300,000.00" needs 224px at --text-3xl and the grid's own track is 11rem, so it wrapped
730
+ between the currency and the number: two lines of 31px where the design says one. Measured, not
731
+ guessed (#020).
732
+ The value shrinks only when its own column is too narrow to hold it, so a wide dashboard keeps
733
+ exactly the type it has today. Scoped to a stat INSIDE .fdy-stats, where the width comes from the
734
+ grid track — `container-type:inline-size` on a standalone .fdy-stat would stop it sizing to its
735
+ own content. The unconditional rule above stays as the fallback wherever @container is missing. */
736
+ @supports (container-type:inline-size){
737
+ .fdy-stats>.fdy-stat{container-type:inline-size;}
738
+ .fdy-stats>.fdy-stat .fdy-stat__value{font-size:clamp(var(--text-xl),11cqw,var(--text-3xl));}
739
+ }
709
740
  .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
741
  .fdy-stats--boxed .fdy-stat{padding:var(--space-5);border-right:var(--bw) solid var(--color-border-muted);}
711
742
  .fdy-stats--boxed .fdy-stat:last-child{border-right:0;}
@@ -873,7 +904,12 @@ a { color: var(--color-primary); }
873
904
  .fdy-dropzone__text{display:flex;flex-direction:column;gap:var(--space-1);}
874
905
 
875
906
  /* 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);}
907
+ /* Symmetric on purpose. The list carried margin-top and nothing below, so whatever follows it —
908
+ an "Add files" button, in every upload UI there is — sat flush against the last row: measured
909
+ 0px. A block that claims space on one side only is not spacing, it is a lean. */
910
+ .fdy-filelist{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-3);margin-bottom:var(--space-3);}
911
+ /* Nothing after it, nothing to separate from — so the room is not spent. */
912
+ .fdy-filelist:last-child{margin-bottom:0;}
877
913
  /* Side-by-side rows on wide screens (reflow to one column when narrow). */
878
914
  .fdy-filelist--grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));}
879
915
  .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 +1421,13 @@ fieldset.fdy-field>legend{padding:0;float:none;margin-bottom:var(--space-2);}
1385
1421
  .fdy-table-bulkbar__spacer{flex:1;}
1386
1422
  .fdy-table-bulkbar__actions{display:flex;gap:var(--space-2);}
1387
1423
 
1424
+ /* Controls inside a data row. `.fdy-input` and friends are width:100%, which in an auto-layout
1425
+ table means they contribute NO intrinsic width — the column shrinks to whatever the header text
1426
+ needs and the control collapses with it. A NOTE column ends up a box too narrow to read what you
1427
+ typed into it. The floor is per-control rather than on the column, because the table does not
1428
+ know which of its columns hold controls. */
1429
+ .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;}
1430
+
1388
1431
  /* Freeday — Tabs (WAI-ARIA APG) */
1389
1432
  /* position:relative — containing block for out-of-flow content in the tabs (see base.css). */
1390
1433
  .fdy-tabs__list{position:relative;display:flex;gap:var(--space-1);border-bottom:var(--bw) solid var(--color-border);overflow-x:auto;}