@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.
- package/CHANGELOG.md +244 -0
- package/COMPONENTS.md +191 -18
- package/README.id.md +1 -1
- package/README.md +1 -1
- package/USAGE.md +1 -1
- package/adapters/blazor/FdyAppShell.razor +1 -1
- package/adapters/blazor/FdyAppShell.razor.cs +35 -0
- package/adapters/blazor/FdyAutocomplete.razor +4 -1
- package/adapters/blazor/FdyAutocomplete.razor.cs +26 -0
- package/adapters/blazor/FdyCascade.razor +6 -1
- package/adapters/blazor/FdyCascade.razor.cs +24 -0
- package/adapters/blazor/FdyCombo.razor +1 -0
- package/adapters/blazor/FdyCombo.razor.cs +13 -0
- package/adapters/blazor/FdyDatepicker.razor +15 -1
- package/adapters/blazor/FdyDatepicker.razor.cs +46 -0
- package/adapters/blazor/FdyTable.razor +44 -4
- package/adapters/blazor/FdyTable.razor.cs +110 -0
- package/adapters/blazor/freeday-blazor.js +8 -0
- package/adapters/react/components/FdyAppShell.tsx +52 -11
- package/adapters/react/components/FdyDrawer.tsx +3 -1
- package/adapters/react/components/FdyModal.tsx +3 -1
- package/adapters/react/components/FdyTable.tsx +123 -2
- package/adapters/vue/components/FdyAppShell.vue +41 -11
- package/adapters/vue/components/FdyDrawer.vue +4 -1
- package/adapters/vue/components/FdyModal.vue +4 -1
- package/adapters/vue/components/FdyTable.vue +125 -4
- package/dist/freeday-app-shell.js +23 -5
- package/dist/freeday-autocomplete.js +17 -1
- package/dist/freeday-busy.js +168 -0
- package/dist/freeday-cascade.js +53 -3
- package/dist/freeday-chart.js +33 -3
- package/dist/freeday-datepicker.js +109 -17
- package/dist/freeday-select.js +18 -1
- package/dist/freeday-stepper.js +54 -4
- package/dist/freeday-table.js +4 -2
- package/dist/freeday-timepicker.js +19 -1
- package/dist/freeday.bundle.css +616 -39
- package/dist/freeday.css +93 -11
- package/dist/freeday.js +499 -37
- package/dist/freeday.tokens.css +523 -28
- package/docs/agent-onboarding.md +4 -0
- package/docs/getting-started.md +1 -1
- package/package.json +4 -3
- package/src/components/app-shell.css +29 -5
- package/src/components/appbar.css +2 -2
- package/src/components/busy.css +33 -0
- package/src/components/card.css +1 -1
- package/src/components/drawer.css +1 -1
- package/src/components/menu.css +1 -1
- package/src/components/modal.css +1 -1
- package/src/components/stepper.css +11 -0
- package/src/components/table.css +13 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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')">×</button>
|
|
64
|
+
<button v-if="dismissible" class="fdy-drawer__close" type="button" :aria-label="closeLabel ?? 'Close'" @click="$emit('close')">×</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')">×</button>
|
|
71
|
+
<button v-if="dismissible" class="fdy-modal__close" type="button" :aria-label="closeLabel ?? 'Close'" @click="$emit('close')">×</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="
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
})();
|