@cahyo-dimas/freeday 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/COMPONENTS.md +191 -18
  3. package/README.id.md +1 -1
  4. package/README.md +1 -1
  5. package/USAGE.md +1 -1
  6. package/adapters/blazor/FdyAppShell.razor +1 -1
  7. package/adapters/blazor/FdyAppShell.razor.cs +35 -0
  8. package/adapters/blazor/FdyAutocomplete.razor +4 -1
  9. package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
  10. package/adapters/blazor/FdyCascade.razor +6 -1
  11. package/adapters/blazor/FdyCascade.razor.cs +24 -0
  12. package/adapters/blazor/FdyCombo.razor +1 -0
  13. package/adapters/blazor/FdyCombo.razor.cs +13 -0
  14. package/adapters/blazor/FdyDatepicker.razor +15 -1
  15. package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
  16. package/adapters/blazor/FdyTable.razor +44 -4
  17. package/adapters/blazor/FdyTable.razor.cs +110 -0
  18. package/adapters/blazor/freeday-blazor.js +8 -0
  19. package/adapters/react/components/FdyAppShell.tsx +52 -11
  20. package/adapters/react/components/FdyDrawer.tsx +3 -1
  21. package/adapters/react/components/FdyModal.tsx +3 -1
  22. package/adapters/react/components/FdyTable.tsx +123 -2
  23. package/adapters/vue/components/FdyAppShell.vue +41 -11
  24. package/adapters/vue/components/FdyDrawer.vue +4 -1
  25. package/adapters/vue/components/FdyModal.vue +4 -1
  26. package/adapters/vue/components/FdyTable.vue +125 -4
  27. package/dist/freeday-app-shell.js +23 -5
  28. package/dist/freeday-autocomplete.js +17 -1
  29. package/dist/freeday-busy.js +168 -0
  30. package/dist/freeday-cascade.js +53 -3
  31. package/dist/freeday-chart.js +33 -3
  32. package/dist/freeday-datepicker.js +109 -17
  33. package/dist/freeday-select.js +18 -1
  34. package/dist/freeday-stepper.js +54 -4
  35. package/dist/freeday-table.js +4 -2
  36. package/dist/freeday-timepicker.js +19 -1
  37. package/dist/freeday.bundle.css +616 -39
  38. package/dist/freeday.css +93 -11
  39. package/dist/freeday.js +499 -37
  40. package/dist/freeday.tokens.css +523 -28
  41. package/docs/agent-onboarding.md +4 -0
  42. package/docs/getting-started.md +1 -1
  43. package/package.json +4 -3
  44. package/src/components/app-shell.css +29 -5
  45. package/src/components/appbar.css +2 -2
  46. package/src/components/busy.css +33 -0
  47. package/src/components/card.css +1 -1
  48. package/src/components/drawer.css +1 -1
  49. package/src/components/menu.css +1 -1
  50. package/src/components/modal.css +1 -1
  51. package/src/components/stepper.css +11 -0
  52. package/src/components/table.css +13 -0
  53. package/tokens/tokens.json +54 -10
@@ -94,6 +94,27 @@ export interface FdyTableProps<Row extends object> {
94
94
  expandedKeys?: ReadonlyArray<string | number>;
95
95
  /** Renders the expandable detail row for an expanded row (React equivalent of Vue's `row-detail` slot). */
96
96
  renderRowDetail?: (row: Row) => ReactNode;
97
+ /** Zebra-stripe the body rows. */
98
+ striped?: boolean;
99
+ /** Render the checkbox column and the bulk bar. */
100
+ selectable?: boolean;
101
+ /** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
102
+ * still works with nothing wired). */
103
+ selectedKeys?: ReadonlyArray<string | number>;
104
+ /** Selection changed, as `rowKey` values. Vue emits `update:selectedKeys`. */
105
+ onSelectedKeysChange?: (keys: Array<string | number>) => void;
106
+ /** Bulk-bar count, `{n}` substituted. Default `{n} selected`. */
107
+ selectedText?: string;
108
+ /** Bulk-bar clear button. Default `Clear`. */
109
+ clearSelectionText?: string;
110
+ /** Accessible name of the header checkbox. Default `Select all rows on this page`. */
111
+ selectAllLabel?: string;
112
+ /** Accessible name of each row checkbox. Default `Select row`. */
113
+ selectRowLabel?: string;
114
+ /** Accessible name of the bulk bar region. Default `Bulk actions`. */
115
+ bulkLabel?: string;
116
+ /** Bulk-bar actions (React equivalent of Vue's `bulk-actions` slot). */
117
+ bulkActions?: ReactNode;
97
118
  }
98
119
 
99
120
  export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Element {
@@ -103,6 +124,7 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
103
124
 
104
125
  const [internalSort, setInternalSort] = useState<FdySortState | null>(null);
105
126
  const [internalFilters, setInternalFilters] = useState<FdyFilterMap>({});
127
+ const [internalSelectedKeys, setInternalSelectedKeys] = useState<Array<string | number>>([]);
106
128
  const [internalPageIndex, setInternalPageIndex] = useState<number>(0);
107
129
  /* Client-mode rows-per-page. `pageSize` is a plain prop with no callback, so a footer control that
108
130
  * only reported would do nothing in the app that wired nothing, the table applies the pick itself
@@ -258,16 +280,99 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
258
280
  return cellText(row, col);
259
281
  }
260
282
 
261
- const colCount: number = props.columns.length;
283
+ /* Selection is keyed by `rowKey`, exactly as `expandedKeys` is, and for the same reason: a key
284
+ * survives the re-fetch that replaces every row object, an object identity does not. Controlled
285
+ * when `selectedKeys` is provided, internal otherwise, so the column works with nothing wired. */
286
+ const selectionControlled: boolean = props.selectedKeys !== undefined;
287
+ const effectiveSelectedKeys: ReadonlyArray<string | number> = selectionControlled
288
+ ? (props.selectedKeys as ReadonlyArray<string | number>)
289
+ : internalSelectedKeys;
290
+ const selectedCount: number = effectiveSelectedKeys.length;
291
+ /* The select-all box acts on the CURRENT PAGE, not on every filtered row: a header checkbox that
292
+ * silently selects rows the reader cannot see is how bulk deletes go wrong. Keys picked on other
293
+ * pages are preserved rather than dropped, so paging away and back does not lose them. */
294
+ const pageKeys: Array<string | number> = displayRows.map((row: Row): string | number => props.rowKey(row));
295
+ const allPageSelected: boolean =
296
+ pageKeys.length > 0 && pageKeys.every((k: string | number): boolean => effectiveSelectedKeys.includes(k));
297
+ const somePageSelected: boolean =
298
+ !allPageSelected && pageKeys.some((k: string | number): boolean => effectiveSelectedKeys.includes(k));
299
+
300
+ function setSelection(keys: Array<string | number>): void {
301
+ if (!selectionControlled) setInternalSelectedKeys(keys);
302
+ props.onSelectedKeysChange?.(keys);
303
+ }
304
+ function isSelected(row: Row): boolean {
305
+ return effectiveSelectedKeys.includes(props.rowKey(row));
306
+ }
307
+ function toggleRow(row: Row, checked: boolean): void {
308
+ const key: string | number = props.rowKey(row);
309
+ const next: Array<string | number> = effectiveSelectedKeys.filter((k: string | number): boolean => k !== key);
310
+ if (checked) next.push(key);
311
+ setSelection(next);
312
+ }
313
+ function toggleAllOnPage(checked: boolean): void {
314
+ const onPage: Set<string | number> = new Set(pageKeys);
315
+ const offPage: Array<string | number> = effectiveSelectedKeys.filter(
316
+ (k: string | number): boolean => !onPage.has(k),
317
+ );
318
+ setSelection(checked ? offPage.concat(pageKeys) : offPage);
319
+ }
320
+ function clearSelection(): void {
321
+ setSelection([]);
322
+ }
323
+ function selectedLabel(n: number): string {
324
+ return (props.selectedText ?? '{n} selected').replace('{n}', String(n));
325
+ }
326
+
327
+ /* The checkbox column widens every full-width row (loading, empty, row detail) by one. Deriving it
328
+ * once is what keeps a later column change from leaving one of the three behind. */
329
+ const colCount: number = props.columns.length + (props.selectable === true ? 1 : 0);
262
330
 
263
331
  return (
264
332
  <div className="fdy-datatable">
265
333
  {props.toolbar !== undefined && <div className="fdy-table-toolbar">{props.toolbar}</div>}
266
334
 
335
+ {props.selectable === true && (
336
+ <div
337
+ className="fdy-table-bulkbar"
338
+ hidden={selectedCount === 0}
339
+ role="region"
340
+ aria-label={props.bulkLabel ?? 'Bulk actions'}
341
+ >
342
+ <span className="fdy-table-bulkbar__count" aria-live="polite">{selectedLabel(selectedCount)}</span>
343
+ <span className="fdy-table-bulkbar__spacer" />
344
+ <div className="fdy-table-bulkbar__actions">
345
+ {props.bulkActions}
346
+ <button type="button" className="fdy-btn fdy-btn--ghost fdy-btn--sm" onClick={clearSelection}>
347
+ {props.clearSelectionText ?? 'Clear'}
348
+ </button>
349
+ </div>
350
+ </div>
351
+ )}
352
+
267
353
  <div className="fdy-table-scroll">
268
- <table className="fdy-table" aria-label={props.ariaLabel}>
354
+ <table
355
+ className={props.striped === true ? 'fdy-table fdy-table--striped' : 'fdy-table'}
356
+ aria-label={props.ariaLabel}
357
+ >
269
358
  <thead>
270
359
  <tr>
360
+ {props.selectable === true && (
361
+ <th className="fdy-table__selcol" scope="col">
362
+ <input
363
+ type="checkbox"
364
+ className="fdy-checkbox"
365
+ data-fdy-select-all
366
+ checked={allPageSelected}
367
+ ref={(el: HTMLInputElement | null): void => {
368
+ // `indeterminate` is a DOM property with no attribute, so JSX cannot set it.
369
+ if (el !== null) el.indeterminate = somePageSelected;
370
+ }}
371
+ aria-label={props.selectAllLabel ?? 'Select all rows on this page'}
372
+ onChange={(e): void => toggleAllOnPage(e.target.checked)}
373
+ />
374
+ </th>
375
+ )}
271
376
  {props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
272
377
  <th key={col.key} scope="col" style={alignStyle(col)} aria-sort={ariaSortOf(col)}>
273
378
  {col.sortable ? (
@@ -306,11 +411,27 @@ export function FdyTable<Row extends object>(props: FdyTableProps<Row>): JSX.Ele
306
411
  className={rowClassName(row)}
307
412
  tabIndex={props.rowActivatable ? 0 : undefined}
308
413
  aria-expanded={props.renderRowDetail !== undefined ? (isExpanded(row) ? 'true' : 'false') : undefined}
414
+ aria-selected={props.selectable === true ? (isSelected(row) ? 'true' : 'false') : undefined}
309
415
  onClick={(): void => {
310
416
  if (props.rowActivatable === true) props.onRowActivate?.(row);
311
417
  }}
312
418
  onKeyDown={(e): void => onRowKeydown(e, row)}
313
419
  >
420
+ {props.selectable === true && (
421
+ <td className="fdy-table__selcol">
422
+ {/* stopPropagation: without it, ticking a checkbox in an activatable row also
423
+ fires onRowActivate, so selecting a row would navigate away from it. */}
424
+ <input
425
+ type="checkbox"
426
+ className="fdy-checkbox"
427
+ data-fdy-row-select
428
+ checked={isSelected(row)}
429
+ aria-label={props.selectRowLabel ?? 'Select row'}
430
+ onClick={(e): void => e.stopPropagation()}
431
+ onChange={(e): void => toggleRow(row, e.target.checked)}
432
+ />
433
+ </td>
434
+ )}
314
435
  {props.columns.map((col: FdyTableColumn<Row>): JSX.Element => (
315
436
  <td key={col.key} className={cellClass(col)} style={alignStyle(col)}>{renderCellContent(col, row)}</td>
316
437
  ))}
@@ -26,14 +26,24 @@ const props = withDefaults(defineProps<{
26
26
  navOpen?: boolean;
27
27
  title?: string;
28
28
  toggleLabel?: string;
29
- }>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation' });
29
+ /**
30
+ * How a VISIBLE nav sits on a wide viewport: `push` (default) makes it a column that displaces
31
+ * the content, `overlay` floats it over the page with a backdrop. Below the nav breakpoint it is
32
+ * ignored — the nav is off-canvas there by definition, so there is nothing to choose.
33
+ */
34
+ navMode?: 'push' | 'overlay';
35
+ }>(), { navOpen: undefined, title: '', toggleLabel: 'Toggle navigation', navMode: 'push' });
30
36
 
31
37
  const emit = defineEmits<{
32
38
  'update:navOpen': [boolean];
33
39
  }>();
34
40
 
35
41
  const root: Ref<HTMLElement | null> = ref(null);
36
- const overlay: Ref<boolean> = ref(false);
42
+ /* The viewport half of the answer, kept separate from `overlay` now that a prop can also decide it:
43
+ a mode switch and a resize both change whether the nav floats, and only one of them is a viewport
44
+ event. */
45
+ const wide: Ref<boolean> = ref(true);
46
+ const overlay: ComputedRef<boolean> = computed((): boolean => !wide.value || props.navMode === 'overlay');
37
47
  const uncontrolled: Ref<boolean> = ref(true);
38
48
  const restoreTo: Ref<Element | null> = ref(null);
39
49
  let media: MediaQueryList | null = null;
@@ -46,8 +56,12 @@ const navVisible: ComputedRef<boolean> = computed((): boolean =>
46
56
  );
47
57
 
48
58
  const shellClass: ComputedRef<string> = computed((): string => {
49
- if (overlay.value) return navVisible.value ? 'fdy-app fdy-app--nav-open' : 'fdy-app';
50
- return navVisible.value ? 'fdy-app' : 'fdy-app fdy-app--nav-collapsed';
59
+ /* `--nav-overlay` rides on the MODE, not on the viewport: below the breakpoint the stylesheet's
60
+ own media query already makes the nav off-canvas, so the class would be redundant there, and
61
+ carrying it anyway is what lets one class answer "is this shell in overlay mode?" at any width. */
62
+ const mode: string = props.navMode === 'overlay' ? ' fdy-app--nav-overlay' : '';
63
+ if (overlay.value) return `fdy-app${mode}${navVisible.value ? ' fdy-app--nav-open' : ''}`;
64
+ return `fdy-app${mode}${navVisible.value ? '' : ' fdy-app--nav-collapsed'}`;
51
65
  });
52
66
 
53
67
  function setVisible(next: boolean): void {
@@ -102,17 +116,33 @@ function onKeydown(e: KeyboardEvent): void {
102
116
  the nav is hidden. Widening is harmless, a visible nav simply becomes the column again, and the
103
117
  watcher clears the `inert` the overlay had put on the content. */
104
118
  function onMediaChange(): void {
105
- const nowOverlay: boolean = media !== null && !media.matches;
106
- if (nowOverlay === overlay.value) return;
107
- fromResize = true;
108
- overlay.value = nowOverlay;
109
- if (nowOverlay && navVisible.value) setVisible(false);
119
+ const nowWide: boolean = media !== null && media.matches;
120
+ if (nowWide === wide.value) return;
121
+ const wasOverlay: boolean = overlay.value;
122
+ const nextOverlay: boolean = !nowWide || props.navMode === 'overlay';
123
+ /* Only when the watcher will actually fire. In overlay MODE, crossing the breakpoint changes
124
+ neither `overlay` nor `navVisible`, so nothing re-runs — and a flag left standing here would
125
+ silently swallow the focus move of the next real toggle. */
126
+ if (nextOverlay !== wasOverlay) fromResize = true;
127
+ wide.value = nowWide;
128
+ if (nextOverlay && !wasOverlay && navVisible.value) setVisible(false);
110
129
  }
111
130
 
131
+ /* Switching mode is not a reader asking to go somewhere either, so focus stays put. Turning overlay
132
+ ON over a visible column would drop a panel across a page nobody asked to leave, so it closes;
133
+ turning it OFF leaves visibility alone, because `navOpen` is the reader's answer and a mode change
134
+ is not a reason to overrule it. */
135
+ watch((): 'push' | 'overlay' => props.navMode, (mode: 'push' | 'overlay'): void => {
136
+ fromResize = true;
137
+ if (mode === 'overlay' && wide.value && navVisible.value) setVisible(false);
138
+ });
139
+
112
140
  onMounted((): void => {
113
141
  media = window.matchMedia(NAV_QUERY);
114
- overlay.value = !media.matches;
115
- uncontrolled.value = media.matches;
142
+ wide.value = media.matches;
143
+ /* An overlay nav starts CLOSED even on a wide screen: it covers the page, so opening it unasked
144
+ is the same intrusion it would be on a phone. A pushing column starts open. */
145
+ uncontrolled.value = media.matches && props.navMode !== 'overlay';
116
146
  media.addEventListener('change', onMediaChange);
117
147
  document.addEventListener('keydown', onKeydown);
118
148
  const el: HTMLElement | null = root.value;
@@ -16,6 +16,9 @@ const props = withDefaults(defineProps<{
16
16
  title: string;
17
17
  side?: 'left' | 'right';
18
18
  dismissible?: boolean;
19
+ /** aria-label for the × button. Default 'Close'. Blazor has had `CloseLabel` since it
20
+ * shipped; these two hard-coded the string, so a non-English app could not rename it. */
21
+ closeLabel?: string;
19
22
  }>(), { dismissible: true });
20
23
 
21
24
  const emit = defineEmits<{
@@ -58,7 +61,7 @@ function onClick(e: MouseEvent): void {
58
61
  <h3 :id="titleId" class="fdy-drawer__title">
59
62
  <slot name="title">{{ title }}</slot>
60
63
  </h3>
61
- <button v-if="dismissible" class="fdy-drawer__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
64
+ <button v-if="dismissible" class="fdy-drawer__close" type="button" :aria-label="closeLabel ?? 'Close'" @click="$emit('close')">&times;</button>
62
65
  </div>
63
66
 
64
67
  <div class="fdy-drawer__body">
@@ -18,6 +18,9 @@ const props = withDefaults(defineProps<{
18
18
  title: string;
19
19
  size?: 'sm' | 'md' | 'lg' | 'wide';
20
20
  dismissible?: boolean;
21
+ /** aria-label for the × button. Default 'Close'. Blazor has had `CloseLabel` since it
22
+ * shipped; these two hard-coded the string, so a non-English app could not rename it. */
23
+ closeLabel?: string;
21
24
  }>(), { dismissible: true });
22
25
 
23
26
  const emit = defineEmits<{
@@ -65,7 +68,7 @@ function onClick(e: MouseEvent): void {
65
68
  <h3 :id="titleId" class="fdy-modal__title">
66
69
  <slot name="title">{{ title }}</slot>
67
70
  </h3>
68
- <button v-if="dismissible" class="fdy-modal__close" type="button" aria-label="Close" @click="$emit('close')">&times;</button>
71
+ <button v-if="dismissible" class="fdy-modal__close" type="button" :aria-label="closeLabel ?? 'Close'" @click="$emit('close')">&times;</button>
69
72
  </div>
70
73
 
71
74
  <div class="fdy-modal__body">
@@ -76,6 +76,23 @@ const props = withDefaults(defineProps<{
76
76
  rowClass?: (row: Row) => string | undefined;
77
77
  /** Controlled: row keys whose `row-detail` slot is shown as a full-width row beneath them. */
78
78
  expandedKeys?: ReadonlyArray<string | number>;
79
+ /** Zebra-stripe the body rows. */
80
+ striped?: boolean;
81
+ /** Render the checkbox column and the bulk bar. */
82
+ selectable?: boolean;
83
+ /** Controlled selection, as `rowKey` values. Provide to own it; omit for internal (the column
84
+ * still works with nothing wired). */
85
+ selectedKeys?: ReadonlyArray<string | number>;
86
+ /** Bulk-bar count, `{n}` substituted. Default `{n} selected`. */
87
+ selectedText?: string;
88
+ /** Bulk-bar clear button. Default `Clear`. */
89
+ clearSelectionText?: string;
90
+ /** Accessible name of the header checkbox. Default `Select all rows on this page`. */
91
+ selectAllLabel?: string;
92
+ /** Accessible name of each row checkbox. Default `Select row`. */
93
+ selectRowLabel?: string;
94
+ /** Accessible name of the bulk bar region. Default `Bulk actions`. */
95
+ bulkLabel?: string;
79
96
  }>(), { pager: true });
80
97
 
81
98
  const emit = defineEmits<{
@@ -90,6 +107,9 @@ const emit = defineEmits<{
90
107
  'update:pageSize': [size: number];
91
108
  /** A row was activated (click, or Enter/Space while the row itself is focused). */
92
109
  'row-activate': [row: Row];
110
+ /** Selection changed, as `rowKey` values. Fires in both modes, so a screen can watch the
111
+ * selection without owning it. */
112
+ 'update:selectedKeys': [keys: Array<string | number>];
93
113
  /** The processed page of rows (after filter/sort/paginate) plus the total row count, fires in
94
114
  * BOTH modes whenever they change. Lets a consumer render the SAME processed set elsewhere
95
115
  * (a `< md` card list, a "selected" summary, export-to-CSV) without re-deriving the pipeline. */
@@ -262,6 +282,65 @@ function onRowKeydown(e: KeyboardEvent, row: Row): void {
262
282
  function isExpanded(row: Row): boolean {
263
283
  return props.expandedKeys?.includes(props.rowKey(row)) === true;
264
284
  }
285
+
286
+ /* Selection is keyed by `rowKey`, exactly as `expandedKeys` is, and for the same reason: a key
287
+ * survives the re-fetch that replaces every row object, an object identity does not. Controlled when
288
+ * `selectedKeys` is provided, internal otherwise, so the column works with nothing wired. */
289
+ const internalSelectedKeys: Ref<Array<string | number>> = ref([]);
290
+ const selectionControlled: ComputedRef<boolean> = computed((): boolean => props.selectedKeys !== undefined);
291
+ const effectiveSelectedKeys: ComputedRef<ReadonlyArray<string | number>> = computed(
292
+ (): ReadonlyArray<string | number> =>
293
+ selectionControlled.value ? (props.selectedKeys as ReadonlyArray<string | number>) : internalSelectedKeys.value,
294
+ );
295
+ const selectedCount: ComputedRef<number> = computed((): number => effectiveSelectedKeys.value.length);
296
+ /* The select-all box acts on the CURRENT PAGE, not on every filtered row: a header checkbox that
297
+ * silently selects rows the reader cannot see is how bulk deletes go wrong. Keys picked on other
298
+ * pages are preserved rather than dropped, so paging away and back does not lose them. */
299
+ const pageKeys: ComputedRef<Array<string | number>> = computed((): Array<string | number> =>
300
+ displayRows.value.map((row: Row): string | number => props.rowKey(row)),
301
+ );
302
+ const allPageSelected: ComputedRef<boolean> = computed((): boolean =>
303
+ pageKeys.value.length > 0 && pageKeys.value.every((k: string | number): boolean => effectiveSelectedKeys.value.includes(k)),
304
+ );
305
+ const somePageSelected: ComputedRef<boolean> = computed((): boolean =>
306
+ !allPageSelected.value && pageKeys.value.some((k: string | number): boolean => effectiveSelectedKeys.value.includes(k)),
307
+ );
308
+
309
+ /* Always emits, controlled or not — the same call the `update:pageSize` control makes, and for the
310
+ * same reason: a screen that only wants to WATCH the selection (a summary line, an export button)
311
+ * should not have to take ownership of it to hear about it. */
312
+ function setSelection(keys: Array<string | number>): void {
313
+ if (!selectionControlled.value) internalSelectedKeys.value = keys;
314
+ emit('update:selectedKeys', keys);
315
+ }
316
+ function isSelected(row: Row): boolean {
317
+ return effectiveSelectedKeys.value.includes(props.rowKey(row));
318
+ }
319
+ function toggleRow(row: Row, checked: boolean): void {
320
+ const key: string | number = props.rowKey(row);
321
+ const next: Array<string | number> = effectiveSelectedKeys.value.filter((k: string | number): boolean => k !== key);
322
+ if (checked) next.push(key);
323
+ setSelection(next);
324
+ }
325
+ function toggleAllOnPage(checked: boolean): void {
326
+ const onPage: Set<string | number> = new Set(pageKeys.value);
327
+ const offPage: Array<string | number> = effectiveSelectedKeys.value.filter(
328
+ (k: string | number): boolean => !onPage.has(k),
329
+ );
330
+ setSelection(checked ? offPage.concat(pageKeys.value) : offPage);
331
+ }
332
+ function clearSelection(): void {
333
+ setSelection([]);
334
+ }
335
+ function selectedLabel(n: number): string {
336
+ return (props.selectedText ?? '{n} selected').replace('{n}', String(n));
337
+ }
338
+
339
+ /* The checkbox column widens every full-width row (loading, empty, row detail) by one. Deriving it
340
+ * once is what keeps a later column change from leaving one of the three behind. */
341
+ const colSpan: ComputedRef<number> = computed((): number =>
342
+ props.columns.length + (props.selectable === true ? 1 : 0),
343
+ );
265
344
  </script>
266
345
 
267
346
  <template>
@@ -270,10 +349,38 @@ function isExpanded(row: Row): boolean {
270
349
  <slot name="toolbar" />
271
350
  </div>
272
351
 
352
+ <div
353
+ v-if="selectable"
354
+ class="fdy-table-bulkbar"
355
+ :hidden="selectedCount === 0"
356
+ role="region"
357
+ :aria-label="bulkLabel ?? 'Bulk actions'"
358
+ >
359
+ <span class="fdy-table-bulkbar__count" aria-live="polite">{{ selectedLabel(selectedCount) }}</span>
360
+ <span class="fdy-table-bulkbar__spacer"></span>
361
+ <div class="fdy-table-bulkbar__actions">
362
+ <slot name="bulk-actions" :keys="effectiveSelectedKeys" :clear="clearSelection" />
363
+ <button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--sm" @click="clearSelection">
364
+ {{ clearSelectionText ?? 'Clear' }}
365
+ </button>
366
+ </div>
367
+ </div>
368
+
273
369
  <div class="fdy-table-scroll">
274
- <table class="fdy-table" :aria-label="ariaLabel">
370
+ <table class="fdy-table" :class="{ 'fdy-table--striped': striped }" :aria-label="ariaLabel">
275
371
  <thead>
276
372
  <tr>
373
+ <th v-if="selectable" class="fdy-table__selcol" scope="col">
374
+ <input
375
+ type="checkbox"
376
+ class="fdy-checkbox"
377
+ data-fdy-select-all
378
+ :checked="allPageSelected"
379
+ :indeterminate.prop="somePageSelected"
380
+ :aria-label="selectAllLabel ?? 'Select all rows on this page'"
381
+ @change="toggleAllOnPage(($event.target as HTMLInputElement).checked)"
382
+ >
383
+ </th>
277
384
  <th v-for="col in columns" :key="col.key" scope="col" :style="alignStyle(col)" :aria-sort="ariaSortOf(col)">
278
385
  <button
279
386
  v-if="col.sortable"
@@ -295,10 +402,10 @@ function isExpanded(row: Row): boolean {
295
402
  </thead>
296
403
  <tbody>
297
404
  <tr v-if="loading">
298
- <td :colspan="columns.length" class="fdy-table__state" role="status">Loading…</td>
405
+ <td :colspan="colSpan" class="fdy-table__state" role="status">Loading…</td>
299
406
  </tr>
300
407
  <tr v-else-if="displayRows.length === 0">
301
- <td :colspan="columns.length" class="fdy-table__state">
408
+ <td :colspan="colSpan" class="fdy-table__state">
302
409
  <slot name="empty">{{ emptyText ?? 'No data' }}</slot>
303
410
  </td>
304
411
  </tr>
@@ -308,15 +415,29 @@ function isExpanded(row: Row): boolean {
308
415
  :class="rowClasses(row)"
309
416
  :tabindex="rowActivatable ? 0 : undefined"
310
417
  :aria-expanded="$slots['row-detail'] ? (isExpanded(row) ? 'true' : 'false') : undefined"
418
+ :aria-selected="selectable ? (isSelected(row) ? 'true' : 'false') : undefined"
311
419
  @click="onRowClick(row)"
312
420
  @keydown="onRowKeydown($event, row)"
313
421
  >
422
+ <td v-if="selectable" class="fdy-table__selcol">
423
+ <!-- `.stop`: without it, ticking a checkbox in an activatable row also fires
424
+ `row-activate`, so selecting a row would navigate away from it. -->
425
+ <input
426
+ type="checkbox"
427
+ class="fdy-checkbox"
428
+ data-fdy-row-select
429
+ :checked="isSelected(row)"
430
+ :aria-label="selectRowLabel ?? 'Select row'"
431
+ @click.stop
432
+ @change="toggleRow(row, ($event.target as HTMLInputElement).checked)"
433
+ >
434
+ </td>
314
435
  <td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
315
436
  <slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
316
437
  </td>
317
438
  </tr>
318
439
  <tr v-if="$slots['row-detail'] && isExpanded(row)" class="fdy-table__detailrow">
319
- <td :colspan="columns.length"><slot name="row-detail" :row="row" /></td>
440
+ <td :colspan="colSpan"><slot name="row-detail" :row="row" /></td>
320
441
  </tr>
321
442
  </template>
322
443
  </template>
@@ -60,7 +60,13 @@
60
60
 
61
61
  function isOverlayOpen() { return app.classList.contains('fdy-app--nav-open'); }
62
62
  function isCollapsed() { return app.classList.contains('fdy-app--nav-collapsed'); }
63
- function navVisible() { return mqWide.matches ? !isCollapsed() : isOverlayOpen(); }
63
+ /* Whether the nav FLOATS. Two ways to be true, and only one of them is the viewport: below the
64
+ breakpoint it is off-canvas by definition, and above it `--nav-overlay` says the app chose to
65
+ float a nav that could have been a column. Everything downstream — which class means visible,
66
+ what the toggle does, whether the content goes inert — asks this instead of the media query,
67
+ so overlay mode reuses the drawer's whole code path rather than growing a second one. */
68
+ function isOverlayMode() { return !mqWide.matches || app.classList.contains('fdy-app--nav-overlay'); }
69
+ function navVisible() { return isOverlayMode() ? isOverlayOpen() : !isCollapsed(); }
64
70
 
65
71
  /* aria-expanded answers "is the nav showing?" in BOTH modes, the two state classes are the
66
72
  kit's business, not the reader's. */
@@ -68,7 +74,7 @@
68
74
  var visible = navVisible();
69
75
  toggle.setAttribute('aria-expanded', String(visible));
70
76
  setInert(sidebar, !visible);
71
- setInert(content, !mqWide.matches && visible);
77
+ setInert(content, isOverlayMode() && visible);
72
78
  /* Announce only real changes. The first sync() runs at init to describe the state the markup
73
79
  arrived in, which is not something a host asked for and must not look like one. */
74
80
  if (lastVisible !== null && visible !== lastVisible) {
@@ -106,7 +112,7 @@
106
112
  }
107
113
 
108
114
  toggle.addEventListener('click', function () {
109
- if (mqWide.matches) {
115
+ if (!isOverlayMode()) {
110
116
  app.classList.toggle('fdy-app--nav-collapsed');
111
117
  sync();
112
118
  } else if (isOverlayOpen()) {
@@ -154,7 +160,10 @@
154
160
  content inert forever: the panel becomes a static column again, and the page it is covering
155
161
  can no longer be clicked or read. */
156
162
  mqWide.addEventListener('change', function () {
157
- if (mqWide.matches && isOverlayOpen()) close(false);
163
+ /* Only when the panel stops floating. In overlay MODE it floats at every width, so widening
164
+ must leave an open panel exactly as it is — closing it there would be the shell overruling
165
+ a reader who never asked for anything. */
166
+ if (mqWide.matches && !isOverlayMode() && isOverlayOpen()) close(false);
158
167
  else sync();
159
168
  });
160
169
 
@@ -164,9 +173,14 @@
164
173
  own state needs to drive this without reaching for the class names the kit reserves. */
165
174
  app._fdyAppShell = {
166
175
  isVisible: navVisible,
176
+ /* Re-read the DOM and reconcile `inert` + `aria-expanded`. Needed when something OUTSIDE the
177
+ enhancer changes what the state classes mean — switching `--nav-overlay` on or off does
178
+ exactly that, because it moves the answer to "is the nav visible?" from `--nav-collapsed`
179
+ to `--nav-open`. Without this a mode switch leaves a visible sidebar marked inert. */
180
+ refresh: sync,
167
181
  setVisible: function (visible) {
168
182
  if (visible === navVisible()) return;
169
- if (mqWide.matches) {
183
+ if (!isOverlayMode()) {
170
184
  app.classList.toggle('fdy-app--nav-collapsed', !visible);
171
185
  sync();
172
186
  } else if (visible) {
@@ -203,5 +217,9 @@
203
217
  isVisible: function (root) {
204
218
  return !!(root && root._fdyAppShell && root._fdyAppShell.isVisible());
205
219
  },
220
+ /* Call after changing `--nav-overlay` from outside; see the note on the handle above. */
221
+ refresh: function (root) {
222
+ if (root && root._fdyAppShell) root._fdyAppShell.refresh();
223
+ },
206
224
  };
207
225
  })();
@@ -40,6 +40,9 @@
40
40
  var _pop = null;
41
41
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(listbox, input); return _pop; }
42
42
  function open() {
43
+ /* The input carries these natively; a disabled one fires nothing, but a READONLY one still
44
+ takes focus and clicks, and the list used to open over a field nobody can edit. */
45
+ if (input.readOnly || input.disabled) return;
43
46
  if (!listbox.hidden) return;
44
47
  var p = popCtl(); if (p) p.show(); else listbox.hidden = false;
45
48
  input.setAttribute('aria-expanded', 'true');
@@ -134,5 +137,18 @@
134
137
  initAll();
135
138
  }
136
139
 
137
- window.FreedayAutocomplete = { init: initAutocomplete, initAll: initAll };
140
+ /* Same contract as the combo's: the states live on the input natively, and a host that stops
141
+ re-rendering after hydration needs a way to change them afterwards. */
142
+ function setState(root, state) {
143
+ var input = root ? root.querySelector('input') : null;
144
+ if (!input || !state) return;
145
+ if (state.disabled != null) input.disabled = !!state.disabled;
146
+ if (state.readonly != null) input.readOnly = !!state.readonly;
147
+ if (state.invalid != null) {
148
+ if (state.invalid) input.setAttribute('aria-invalid', 'true');
149
+ else input.removeAttribute('aria-invalid');
150
+ }
151
+ }
152
+
153
+ window.FreedayAutocomplete = { init: initAutocomplete, initAll: initAll, setState: setState };
138
154
  })();