@cahyo-dimas/freeday 1.7.0 → 1.8.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 +41 -0
- package/README.id.md +208 -0
- package/README.md +127 -121
- package/adapters/core/table-model.d.ts +78 -0
- package/adapters/core/table-model.js +156 -0
- package/adapters/react/components/FdyDrawer.tsx +59 -0
- package/adapters/react/components/FdyModal.tsx +63 -0
- package/adapters/react/components/FdyTable.tsx +234 -0
- package/adapters/react/components/FdyTableFilter.tsx +193 -0
- package/adapters/react/index.d.ts +16 -0
- package/adapters/react/index.js +3 -0
- package/adapters/vue/components/FdyDrawer.vue +69 -0
- package/adapters/vue/components/FdyModal.vue +76 -0
- package/adapters/vue/components/FdyTable.vue +245 -0
- package/adapters/vue/components/FdyTableFilter.vue +189 -0
- package/adapters/vue/index.d.ts +16 -0
- package/adapters/vue/index.js +3 -0
- package/dist/freeday.bundle.css +10 -1
- package/dist/freeday.css +10 -1
- package/package.json +2 -1
- package/src/components/drawer.css +1 -0
- package/src/components/table.css +9 -1
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { usePopover } from '../usePopover';
|
|
4
|
+
import { isFilterActive } from '../../core/table-model.js';
|
|
5
|
+
import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-model.js';
|
|
6
|
+
|
|
7
|
+
// Internal to FdyTable: one column's header funnel button + its type-aware filter popover
|
|
8
|
+
// (text / enum / number / date) over freeday's `.fdy-table__filterbtn` + `.fdy-filter*` classes.
|
|
9
|
+
// React port of adapters/vue/components/FdyTableFilter.vue. Reuses usePopover so the panel escapes
|
|
10
|
+
// the table's `overflow:hidden` via the top layer. Purely controlled — renders the current
|
|
11
|
+
// `filter`, emits the next one (or null to clear); the parent owns where it goes. Not exported.
|
|
12
|
+
|
|
13
|
+
export interface FdyTableFilterProps {
|
|
14
|
+
label: string;
|
|
15
|
+
type: FdyColumnFilterType;
|
|
16
|
+
filter: FdyColumnFilter | undefined;
|
|
17
|
+
/** Distinct values for an enum filter (computed by the parent over the full row set). */
|
|
18
|
+
options: ReadonlyArray<string>;
|
|
19
|
+
/** The next filter for this column, or null to clear it. */
|
|
20
|
+
onChange: (filter: FdyColumnFilter | null) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FdyTableFilter(props: FdyTableFilterProps): JSX.Element {
|
|
24
|
+
const rootRef = useRef<HTMLSpanElement>(null);
|
|
25
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
26
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const [open, setOpen] = useState<boolean>(false);
|
|
28
|
+
|
|
29
|
+
usePopover(panelRef, triggerRef, open);
|
|
30
|
+
// Popover attr for React 18/19 JSX typing: set once on mount (same as the other components).
|
|
31
|
+
useEffect((): void => {
|
|
32
|
+
panelRef.current?.setAttribute('popover', 'manual');
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const active: boolean = isFilterActive(props.filter);
|
|
36
|
+
const filter: FdyColumnFilter | undefined = props.filter;
|
|
37
|
+
const textValue: string = filter?.type === 'text' ? filter.text : '';
|
|
38
|
+
const enumValues: ReadonlyArray<string> = filter?.type === 'enum' ? filter.values : [];
|
|
39
|
+
const numMin: string = filter?.type === 'number' && filter.min !== null ? String(filter.min) : '';
|
|
40
|
+
const numMax: string = filter?.type === 'number' && filter.max !== null ? String(filter.max) : '';
|
|
41
|
+
const dateFrom: string = filter?.type === 'date' && filter.from !== null ? filter.from : '';
|
|
42
|
+
const dateTo: string = filter?.type === 'date' && filter.to !== null ? filter.to : '';
|
|
43
|
+
|
|
44
|
+
function apply(next: FdyColumnFilter): void {
|
|
45
|
+
props.onChange(isFilterActive(next) ? next : null);
|
|
46
|
+
}
|
|
47
|
+
function parseNum(v: string): number | null {
|
|
48
|
+
const t: string = v.trim();
|
|
49
|
+
if (t === '') return null;
|
|
50
|
+
const n: number = Number(t);
|
|
51
|
+
return Number.isNaN(n) ? null : n;
|
|
52
|
+
}
|
|
53
|
+
function onEnumToggle(value: string, checked: boolean): void {
|
|
54
|
+
const set: string[] = enumValues.filter((v: string): boolean => v !== value);
|
|
55
|
+
if (checked) set.push(value);
|
|
56
|
+
apply({ type: 'enum', values: set });
|
|
57
|
+
}
|
|
58
|
+
function close(returnFocus: boolean): void {
|
|
59
|
+
setOpen(false);
|
|
60
|
+
if (returnFocus) triggerRef.current?.focus();
|
|
61
|
+
}
|
|
62
|
+
function reset(): void {
|
|
63
|
+
props.onChange(null);
|
|
64
|
+
close(true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Dismiss on outside pointer or Escape while open.
|
|
68
|
+
useEffect((): void | (() => void) => {
|
|
69
|
+
if (!open) return;
|
|
70
|
+
function onPointerDown(e: MouseEvent): void {
|
|
71
|
+
const t: EventTarget | null = e.target;
|
|
72
|
+
if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) close(false);
|
|
73
|
+
}
|
|
74
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
75
|
+
if (e.key === 'Escape') close(true);
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
78
|
+
document.addEventListener('keydown', onKeydown);
|
|
79
|
+
return (): void => {
|
|
80
|
+
document.removeEventListener('mousedown', onPointerDown);
|
|
81
|
+
document.removeEventListener('keydown', onKeydown);
|
|
82
|
+
};
|
|
83
|
+
}, [open]);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span ref={rootRef} className="fdy-table__filterwrap">
|
|
87
|
+
<button
|
|
88
|
+
ref={triggerRef}
|
|
89
|
+
type="button"
|
|
90
|
+
className={active ? 'fdy-table__filterbtn is-active' : 'fdy-table__filterbtn'}
|
|
91
|
+
aria-haspopup="dialog"
|
|
92
|
+
aria-pressed={active}
|
|
93
|
+
aria-expanded={open}
|
|
94
|
+
aria-label={`Filter ${props.label}`}
|
|
95
|
+
onClick={(e): void => {
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
setOpen((v: boolean): boolean => !v);
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
101
|
+
<path d="M3 5h18l-7 8v5l-4 2v-7z" />
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
|
|
105
|
+
<div ref={panelRef} className="fdy-filter" hidden={!open} role="dialog" aria-label={`Filter ${props.label}`}>
|
|
106
|
+
{props.type === 'text' && (
|
|
107
|
+
<>
|
|
108
|
+
<div className="fdy-filter__title">Contains text</div>
|
|
109
|
+
<input
|
|
110
|
+
className="fdy-input"
|
|
111
|
+
type="search"
|
|
112
|
+
placeholder="Contains…"
|
|
113
|
+
value={textValue}
|
|
114
|
+
onChange={(e): void => apply({ type: 'text', text: e.target.value })}
|
|
115
|
+
/>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{props.type === 'enum' && (
|
|
120
|
+
<>
|
|
121
|
+
<div className="fdy-filter__title">Show values</div>
|
|
122
|
+
<div className="fdy-filter__list">
|
|
123
|
+
{props.options.map((val: string): JSX.Element => (
|
|
124
|
+
<label key={val} className="fdy-filter__check">
|
|
125
|
+
<input
|
|
126
|
+
type="checkbox"
|
|
127
|
+
className="fdy-checkbox"
|
|
128
|
+
checked={enumValues.includes(val)}
|
|
129
|
+
onChange={(e): void => onEnumToggle(val, e.target.checked)}
|
|
130
|
+
/>
|
|
131
|
+
{val}
|
|
132
|
+
</label>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{props.type === 'number' && (
|
|
139
|
+
<>
|
|
140
|
+
<div className="fdy-filter__title">Value range</div>
|
|
141
|
+
<div className="fdy-filter__range">
|
|
142
|
+
<input
|
|
143
|
+
className="fdy-input"
|
|
144
|
+
type="text"
|
|
145
|
+
inputMode="numeric"
|
|
146
|
+
placeholder="Min"
|
|
147
|
+
value={numMin}
|
|
148
|
+
onChange={(e): void => apply({ type: 'number', min: parseNum(e.target.value), max: parseNum(numMax) })}
|
|
149
|
+
/>
|
|
150
|
+
<span aria-hidden="true">–</span>
|
|
151
|
+
<input
|
|
152
|
+
className="fdy-input"
|
|
153
|
+
type="text"
|
|
154
|
+
inputMode="numeric"
|
|
155
|
+
placeholder="Max"
|
|
156
|
+
value={numMax}
|
|
157
|
+
onChange={(e): void => apply({ type: 'number', min: parseNum(numMin), max: parseNum(e.target.value) })}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{props.type === 'date' && (
|
|
164
|
+
<>
|
|
165
|
+
<div className="fdy-filter__title">Date range</div>
|
|
166
|
+
<div className="fdy-filter__range">
|
|
167
|
+
<input
|
|
168
|
+
className="fdy-input"
|
|
169
|
+
type="date"
|
|
170
|
+
aria-label="From"
|
|
171
|
+
value={dateFrom}
|
|
172
|
+
onChange={(e): void => apply({ type: 'date', from: e.target.value || null, to: dateTo || null })}
|
|
173
|
+
/>
|
|
174
|
+
<span aria-hidden="true">–</span>
|
|
175
|
+
<input
|
|
176
|
+
className="fdy-input"
|
|
177
|
+
type="date"
|
|
178
|
+
aria-label="To"
|
|
179
|
+
value={dateTo}
|
|
180
|
+
onChange={(e): void => apply({ type: 'date', from: dateFrom || null, to: e.target.value || null })}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
<div className="fdy-filter__foot">
|
|
187
|
+
<button type="button" className="fdy-btn fdy-btn--ghost fdy-btn--sm" onClick={reset}>Reset</button>
|
|
188
|
+
<button type="button" className="fdy-btn fdy-btn--sm" onClick={(): void => close(true)}>Close</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</span>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
@@ -35,3 +35,19 @@ export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAuto
|
|
|
35
35
|
export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
|
|
36
36
|
export { FdyCfl, type FdyCflProps, type CflColumn, type CflPage } from './components/FdyCfl';
|
|
37
37
|
export { FdyChart, type FdyChartProps, type FdyChartSeries } from './components/FdyChart';
|
|
38
|
+
export { FdyTable, type FdyTableProps } from './components/FdyTable';
|
|
39
|
+
export { FdyModal, type FdyModalProps } from './components/FdyModal';
|
|
40
|
+
export { FdyDrawer, type FdyDrawerProps } from './components/FdyDrawer';
|
|
41
|
+
|
|
42
|
+
/** Controlled data-table types (shared, framework-agnostic core). */
|
|
43
|
+
export type {
|
|
44
|
+
FdyTableColumn,
|
|
45
|
+
FdySortState,
|
|
46
|
+
FdySortDir,
|
|
47
|
+
FdyColumnType,
|
|
48
|
+
FdyColumnAlign,
|
|
49
|
+
FdyColumnFilterType,
|
|
50
|
+
FdyColumnFilter,
|
|
51
|
+
FdyFilterMap,
|
|
52
|
+
FdyPageState,
|
|
53
|
+
} from '../core/table-model';
|
package/adapters/react/index.js
CHANGED
|
@@ -7,3 +7,6 @@ export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
|
|
|
7
7
|
export { FdyCascade } from './components/FdyCascade.tsx';
|
|
8
8
|
export { FdyCfl } from './components/FdyCfl.tsx';
|
|
9
9
|
export { FdyChart } from './components/FdyChart.tsx';
|
|
10
|
+
export { FdyTable } from './components/FdyTable.tsx';
|
|
11
|
+
export { FdyModal } from './components/FdyModal.tsx';
|
|
12
|
+
export { FdyDrawer } from './components/FdyDrawer.tsx';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
// A controlled Vue wrapper over freeday's `.fdy-drawer` native <dialog> side panel
|
|
5
|
+
// (src/components/drawer.css). Same controlled contract and glue as FdyModal — showModal()/close()
|
|
6
|
+
// guarded, @cancel.prevent so Esc routes through app state, backdrop-click via `event.target ===
|
|
7
|
+
// dialogEl` — applied to a drawer that anchors left (default) or right. Native <dialog> supplies the
|
|
8
|
+
// focus trap, focus restore, top-layer stacking and inert background; `dismissible` (default true)
|
|
9
|
+
// gates Esc + backdrop dismissal.
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
open: boolean;
|
|
13
|
+
title: string;
|
|
14
|
+
side?: 'left' | 'right';
|
|
15
|
+
dismissible?: boolean;
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
close: [];
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
|
|
23
|
+
const titleId: string = `${useId()}-title`;
|
|
24
|
+
const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
|
|
25
|
+
const drawerClass: ComputedRef<string> = computed((): string =>
|
|
26
|
+
props.side === 'right' ? 'fdy-drawer fdy-drawer--right' : 'fdy-drawer',
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function sync(open: boolean): void {
|
|
30
|
+
const el: HTMLDialogElement | null = dialogEl.value;
|
|
31
|
+
if (el === null) return;
|
|
32
|
+
if (open && !el.open) el.showModal();
|
|
33
|
+
else if (!open && el.open) el.close();
|
|
34
|
+
}
|
|
35
|
+
watch((): boolean => props.open, sync, { flush: 'post' });
|
|
36
|
+
onMounted((): void => sync(props.open));
|
|
37
|
+
|
|
38
|
+
function onCancel(): void {
|
|
39
|
+
if (dismissible.value) emit('close');
|
|
40
|
+
}
|
|
41
|
+
function onClick(e: MouseEvent): void {
|
|
42
|
+
if (dismissible.value && e.target === dialogEl.value) emit('close');
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<dialog
|
|
48
|
+
ref="dialogEl"
|
|
49
|
+
:class="drawerClass"
|
|
50
|
+
:aria-labelledby="titleId"
|
|
51
|
+
@cancel.prevent="onCancel"
|
|
52
|
+
@click="onClick"
|
|
53
|
+
>
|
|
54
|
+
<div class="fdy-drawer__header">
|
|
55
|
+
<h3 :id="titleId" class="fdy-drawer__title">
|
|
56
|
+
<slot name="title">{{ title }}</slot>
|
|
57
|
+
</h3>
|
|
58
|
+
<button v-if="dismissible" class="fdy-drawer__close" type="button" aria-label="Close" @click="$emit('close')">×</button>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="fdy-drawer__body">
|
|
62
|
+
<slot />
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div v-if="$slots.footer" class="fdy-drawer__footer">
|
|
66
|
+
<slot name="footer" />
|
|
67
|
+
</div>
|
|
68
|
+
</dialog>
|
|
69
|
+
</template>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, useId, watch, type ComputedRef, type Ref, ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
// A controlled Vue wrapper over freeday's `.fdy-modal` native <dialog> (src/components/modal.css).
|
|
5
|
+
// The kit styles the dialog but nothing drives it; every consumer re-derives the same imperative
|
|
6
|
+
// glue to reconcile a reactive `open` boolean with a DOM element whose open/close is a method call.
|
|
7
|
+
// This writes that glue once: showModal()/close() guarded against the already-open/closed cases
|
|
8
|
+
// (showModal() on an open dialog throws), @cancel.prevent so Esc routes through app state instead of
|
|
9
|
+
// closing the DOM behind its back, and backdrop-click detection via `event.target === dialogEl`.
|
|
10
|
+
// Native <dialog> already provides the focus trap, focus restore, top-layer stacking and inert
|
|
11
|
+
// background — the wrapper only avoids breaking them. `dismissible` (default true) gates Esc + backdrop.
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
open: boolean;
|
|
15
|
+
title: string;
|
|
16
|
+
size?: 'sm' | 'md' | 'lg' | 'wide';
|
|
17
|
+
dismissible?: boolean;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
close: [];
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const dialogEl: Ref<HTMLDialogElement | null> = ref(null);
|
|
25
|
+
const titleId: string = `${useId()}-title`;
|
|
26
|
+
const dismissible: ComputedRef<boolean> = computed((): boolean => props.dismissible !== false);
|
|
27
|
+
const modalClass: ComputedRef<string> = computed((): string =>
|
|
28
|
+
props.size !== undefined ? `fdy-modal fdy-modal--${props.size}` : 'fdy-modal',
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Reconcile the reactive `open` with the dialog's method-driven state. Both guards matter:
|
|
32
|
+
// showModal() on an already-open dialog throws; close() on a closed one is a no-op but kept symmetric.
|
|
33
|
+
function sync(open: boolean): void {
|
|
34
|
+
const el: HTMLDialogElement | null = dialogEl.value;
|
|
35
|
+
if (el === null) return;
|
|
36
|
+
if (open && !el.open) el.showModal();
|
|
37
|
+
else if (!open && el.open) el.close();
|
|
38
|
+
}
|
|
39
|
+
watch((): boolean => props.open, sync, { flush: 'post' });
|
|
40
|
+
onMounted((): void => sync(props.open));
|
|
41
|
+
|
|
42
|
+
// Esc fires `cancel`; .prevent stops the native close so app state stays the single source of truth.
|
|
43
|
+
function onCancel(): void {
|
|
44
|
+
if (dismissible.value) emit('close');
|
|
45
|
+
}
|
|
46
|
+
// The ::backdrop is not a separate element — a click whose target is the dialog box itself (not its
|
|
47
|
+
// content) is a backdrop click.
|
|
48
|
+
function onClick(e: MouseEvent): void {
|
|
49
|
+
if (dismissible.value && e.target === dialogEl.value) emit('close');
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<dialog
|
|
55
|
+
ref="dialogEl"
|
|
56
|
+
:class="modalClass"
|
|
57
|
+
:aria-labelledby="titleId"
|
|
58
|
+
@cancel.prevent="onCancel"
|
|
59
|
+
@click="onClick"
|
|
60
|
+
>
|
|
61
|
+
<div class="fdy-modal__header">
|
|
62
|
+
<h3 :id="titleId" class="fdy-modal__title">
|
|
63
|
+
<slot name="title">{{ title }}</slot>
|
|
64
|
+
</h3>
|
|
65
|
+
<button v-if="dismissible" class="fdy-modal__close" type="button" aria-label="Close" @click="$emit('close')">×</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="fdy-modal__body">
|
|
69
|
+
<slot />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div v-if="$slots.footer" class="fdy-modal__footer">
|
|
73
|
+
<slot name="footer" />
|
|
74
|
+
</div>
|
|
75
|
+
</dialog>
|
|
76
|
+
</template>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts" generic="Row extends Record<string, unknown>">
|
|
2
|
+
import { computed, ref, watch, type ComputedRef, type Ref } from 'vue';
|
|
3
|
+
import {
|
|
4
|
+
cellValue,
|
|
5
|
+
cellText,
|
|
6
|
+
distinctValues,
|
|
7
|
+
filterRows,
|
|
8
|
+
sortRows,
|
|
9
|
+
paginate,
|
|
10
|
+
pageWindow,
|
|
11
|
+
} from '../../core/table-model.js';
|
|
12
|
+
import type {
|
|
13
|
+
FdyTableColumn,
|
|
14
|
+
FdySortState,
|
|
15
|
+
FdyColumnFilter,
|
|
16
|
+
FdyFilterMap,
|
|
17
|
+
FdyPageState,
|
|
18
|
+
} from '../../core/table-model';
|
|
19
|
+
import FdyTableFilter from './FdyTableFilter.vue';
|
|
20
|
+
|
|
21
|
+
// A controlled Vue data table over freeday's `.fdy-datatable` / `.fdy-table*` / `.fdy-filter*` /
|
|
22
|
+
// `.fdy-pagination__*` classes. Unlike the freeday-table.js enhancer (which snapshots static rows
|
|
23
|
+
// and fights a framework's patcher), this reads `rows` as the source of truth on every render, so
|
|
24
|
+
// it is safe over a `v-for` bound to reactive data. Two modes:
|
|
25
|
+
// • Client mode (no `page` prop): the component sorts/filters (and paginates when `pageSize` is
|
|
26
|
+
// set) over the full `rows`. `sort`/`filters` are controlled when provided, else internal.
|
|
27
|
+
// • Server mode (`page` prop present): `rows` are rendered exactly as given (the server already
|
|
28
|
+
// sorted/filtered/paged); the sort headers, filters and pager only emit intent
|
|
29
|
+
// (`update:sort` / `update:filters` / `update:page`) for the caller to feed back into its query.
|
|
30
|
+
// Column filters (text/enum/number/date) apply live; in server mode, debounce the emit if needed.
|
|
31
|
+
|
|
32
|
+
const props = defineProps<{
|
|
33
|
+
columns: ReadonlyArray<FdyTableColumn<Row>>;
|
|
34
|
+
rows: ReadonlyArray<Row>;
|
|
35
|
+
rowKey: (row: Row) => string | number;
|
|
36
|
+
/** Controlled sort. Provide (even as null) to own sorting; omit for internal client sort. */
|
|
37
|
+
sort?: FdySortState | null;
|
|
38
|
+
/** Controlled filter map keyed by column key. Provide to own filtering; omit for internal. */
|
|
39
|
+
filters?: FdyFilterMap;
|
|
40
|
+
/** Server pagination state (0-based index). Presence switches the table into server mode. */
|
|
41
|
+
page?: FdyPageState;
|
|
42
|
+
/** Client-side page size when `page` is absent; 0/undefined = render all rows (no pager). */
|
|
43
|
+
pageSize?: number;
|
|
44
|
+
loading?: boolean;
|
|
45
|
+
emptyText?: string;
|
|
46
|
+
ariaLabel?: string;
|
|
47
|
+
}>();
|
|
48
|
+
|
|
49
|
+
const emit = defineEmits<{
|
|
50
|
+
'update:sort': [sort: FdySortState | null];
|
|
51
|
+
'update:filters': [filters: FdyFilterMap];
|
|
52
|
+
'update:page': [page: FdyPageState];
|
|
53
|
+
}>();
|
|
54
|
+
|
|
55
|
+
const internalSort: Ref<FdySortState | null> = ref(null);
|
|
56
|
+
const internalFilters: Ref<FdyFilterMap> = ref({});
|
|
57
|
+
const internalPageIndex: Ref<number> = ref(0);
|
|
58
|
+
|
|
59
|
+
const serverPaged: ComputedRef<boolean> = computed((): boolean => props.page != null);
|
|
60
|
+
const sortControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.sort !== undefined);
|
|
61
|
+
const filtersControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.filters !== undefined);
|
|
62
|
+
|
|
63
|
+
const effectiveSort: ComputedRef<FdySortState | null> = computed((): FdySortState | null =>
|
|
64
|
+
sortControlled.value ? (props.sort ?? null) : internalSort.value,
|
|
65
|
+
);
|
|
66
|
+
const effectiveFilters: ComputedRef<FdyFilterMap> = computed((): FdyFilterMap =>
|
|
67
|
+
filtersControlled.value ? (props.filters ?? {}) : internalFilters.value,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Enum options: explicit (server mode) or the distinct values across the current rows (client mode).
|
|
71
|
+
const enumOptionsMap: ComputedRef<Record<string, ReadonlyArray<string>>> = computed(
|
|
72
|
+
(): Record<string, ReadonlyArray<string>> => {
|
|
73
|
+
const out: Record<string, ReadonlyArray<string>> = {};
|
|
74
|
+
for (const col of props.columns) {
|
|
75
|
+
if (col.filter === 'enum') out[col.key] = col.options ?? distinctValues(props.rows, col);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const filteredSorted: ComputedRef<Row[]> = computed((): Row[] => {
|
|
82
|
+
if (serverPaged.value) return props.rows.slice();
|
|
83
|
+
const filtered: Row[] = filterRows(props.rows, props.columns, effectiveFilters.value);
|
|
84
|
+
return sortRows(filtered, props.columns, effectiveSort.value);
|
|
85
|
+
});
|
|
86
|
+
const totalCount: ComputedRef<number> = computed((): number =>
|
|
87
|
+
serverPaged.value ? (props.page as FdyPageState).total : filteredSorted.value.length,
|
|
88
|
+
);
|
|
89
|
+
const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
|
|
90
|
+
if (serverPaged.value) return props.rows.slice();
|
|
91
|
+
if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, internalPageIndex.value, props.pageSize);
|
|
92
|
+
return filteredSorted.value;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
96
|
+
serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
|
|
97
|
+
);
|
|
98
|
+
const currentPage1: ComputedRef<number> = computed((): number =>
|
|
99
|
+
(serverPaged.value ? (props.page as FdyPageState).index : internalPageIndex.value) + 1,
|
|
100
|
+
);
|
|
101
|
+
const totalPages: ComputedRef<number> = computed((): number =>
|
|
102
|
+
pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
|
|
103
|
+
);
|
|
104
|
+
const hasPager: ComputedRef<boolean> = computed((): boolean => pageSizeEff.value > 0 && totalPages.value > 1);
|
|
105
|
+
const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number | 'ellipsis'> =>
|
|
106
|
+
pageWindow(currentPage1.value, totalPages.value),
|
|
107
|
+
);
|
|
108
|
+
const rangeFrom: ComputedRef<number> = computed((): number =>
|
|
109
|
+
totalCount.value === 0 ? 0 : (currentPage1.value - 1) * pageSizeEff.value + 1,
|
|
110
|
+
);
|
|
111
|
+
const rangeTo: ComputedRef<number> = computed((): number =>
|
|
112
|
+
totalCount.value === 0 ? 0 : rangeFrom.value - 1 + displayRows.value.length,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
116
|
+
watch(totalPages, (tp: number): void => {
|
|
117
|
+
if (!serverPaged.value && internalPageIndex.value > tp - 1) internalPageIndex.value = Math.max(0, tp - 1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
|
|
121
|
+
const s: FdySortState | null = effectiveSort.value;
|
|
122
|
+
if (s === null || s.key !== col.key) return undefined;
|
|
123
|
+
return s.dir === 'asc' ? 'ascending' : 'descending';
|
|
124
|
+
}
|
|
125
|
+
function onSort(col: FdyTableColumn<Row>): void {
|
|
126
|
+
if (col.sortable !== true) return;
|
|
127
|
+
const cur: FdySortState | null = effectiveSort.value;
|
|
128
|
+
const next: FdySortState =
|
|
129
|
+
cur !== null && cur.key === col.key
|
|
130
|
+
? { key: col.key, dir: cur.dir === 'asc' ? 'desc' : 'asc' }
|
|
131
|
+
: { key: col.key, dir: 'asc' };
|
|
132
|
+
if (sortControlled.value) emit('update:sort', next);
|
|
133
|
+
else {
|
|
134
|
+
internalSort.value = next;
|
|
135
|
+
internalPageIndex.value = 0;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
|
|
139
|
+
const nextMap: FdyFilterMap = { ...effectiveFilters.value };
|
|
140
|
+
if (filter === null) delete nextMap[col.key];
|
|
141
|
+
else nextMap[col.key] = filter;
|
|
142
|
+
if (filtersControlled.value) emit('update:filters', nextMap);
|
|
143
|
+
else {
|
|
144
|
+
internalFilters.value = nextMap;
|
|
145
|
+
internalPageIndex.value = 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function goTo(page1: number): void {
|
|
149
|
+
const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
|
|
150
|
+
const index0: number = clamped - 1;
|
|
151
|
+
if (serverPaged.value) {
|
|
152
|
+
const p: FdyPageState = props.page as FdyPageState;
|
|
153
|
+
emit('update:page', { index: index0, size: p.size, total: p.total });
|
|
154
|
+
} else {
|
|
155
|
+
internalPageIndex.value = index0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
160
|
+
return col.mono === true ? 'fdy-mono' : undefined;
|
|
161
|
+
}
|
|
162
|
+
function alignStyle(col: FdyTableColumn<Row>): Record<string, string> | undefined {
|
|
163
|
+
return col.align !== undefined ? { textAlign: col.align } : undefined;
|
|
164
|
+
}
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<template>
|
|
168
|
+
<div class="fdy-datatable">
|
|
169
|
+
<div v-if="$slots.toolbar" class="fdy-table-toolbar">
|
|
170
|
+
<slot name="toolbar" />
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="fdy-table-scroll">
|
|
174
|
+
<table class="fdy-table" :aria-label="ariaLabel">
|
|
175
|
+
<thead>
|
|
176
|
+
<tr>
|
|
177
|
+
<th v-for="col in columns" :key="col.key" scope="col" :style="alignStyle(col)" :aria-sort="ariaSortOf(col)">
|
|
178
|
+
<button
|
|
179
|
+
v-if="col.sortable"
|
|
180
|
+
type="button"
|
|
181
|
+
class="fdy-table__sortbtn"
|
|
182
|
+
@click="onSort(col)"
|
|
183
|
+
>{{ col.label }}</button>
|
|
184
|
+
<template v-else>{{ col.label }}</template>
|
|
185
|
+
<FdyTableFilter
|
|
186
|
+
v-if="col.filter"
|
|
187
|
+
:label="col.label"
|
|
188
|
+
:type="col.filter"
|
|
189
|
+
:filter="effectiveFilters[col.key]"
|
|
190
|
+
:options="enumOptionsMap[col.key] ?? []"
|
|
191
|
+
@change="onFilterChange(col, $event)"
|
|
192
|
+
/>
|
|
193
|
+
</th>
|
|
194
|
+
</tr>
|
|
195
|
+
</thead>
|
|
196
|
+
<tbody>
|
|
197
|
+
<tr v-if="loading">
|
|
198
|
+
<td :colspan="columns.length" class="fdy-table__state" role="status">Loading…</td>
|
|
199
|
+
</tr>
|
|
200
|
+
<tr v-else-if="displayRows.length === 0">
|
|
201
|
+
<td :colspan="columns.length" class="fdy-table__state">
|
|
202
|
+
<slot name="empty">{{ emptyText ?? 'No data' }}</slot>
|
|
203
|
+
</td>
|
|
204
|
+
</tr>
|
|
205
|
+
<tr v-for="row in displayRows" v-else :key="rowKey(row)">
|
|
206
|
+
<td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
|
|
207
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
|
|
208
|
+
</td>
|
|
209
|
+
</tr>
|
|
210
|
+
</tbody>
|
|
211
|
+
</table>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div v-if="hasPager" class="fdy-table-footer">
|
|
215
|
+
<span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ totalCount }}</span>
|
|
216
|
+
<nav aria-label="Pagination">
|
|
217
|
+
<ul class="fdy-pagination__list">
|
|
218
|
+
<li>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
class="fdy-pagination__link"
|
|
222
|
+
aria-label="Previous page"
|
|
223
|
+
:disabled="currentPage1 === 1"
|
|
224
|
+
@click="goTo(currentPage1 - 1)"
|
|
225
|
+
>‹</button>
|
|
226
|
+
</li>
|
|
227
|
+
<li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
|
|
228
|
+
<span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
|
|
229
|
+
<span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
|
|
230
|
+
<button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
|
|
231
|
+
</li>
|
|
232
|
+
<li>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
class="fdy-pagination__link"
|
|
236
|
+
aria-label="Next page"
|
|
237
|
+
:disabled="currentPage1 === totalPages"
|
|
238
|
+
@click="goTo(currentPage1 + 1)"
|
|
239
|
+
>›</button>
|
|
240
|
+
</li>
|
|
241
|
+
</ul>
|
|
242
|
+
</nav>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|