@cahyo-dimas/freeday 1.7.1 → 1.9.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 +48 -0
- package/README.id.md +1 -1
- package/README.md +1 -1
- 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 +263 -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 +275 -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 +14 -1
- package/dist/freeday.css +14 -1
- package/package.json +1 -1
- package/src/components/drawer.css +1 -0
- package/src/components/table.css +13 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
<script setup lang="ts" generic="Row extends object">
|
|
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
|
+
/** Opt in to row activation: rows become focusable and emit `row-activate` on click/Enter/Space. */
|
|
48
|
+
rowActivatable?: boolean;
|
|
49
|
+
/** Per-row class hook, e.g. to mark a selected row. */
|
|
50
|
+
rowClass?: (row: Row) => string | undefined;
|
|
51
|
+
}>();
|
|
52
|
+
|
|
53
|
+
const emit = defineEmits<{
|
|
54
|
+
'update:sort': [sort: FdySortState | null];
|
|
55
|
+
'update:filters': [filters: FdyFilterMap];
|
|
56
|
+
'update:page': [page: FdyPageState];
|
|
57
|
+
/** A row was activated (click, or Enter/Space while the row itself is focused). */
|
|
58
|
+
'row-activate': [row: Row];
|
|
59
|
+
}>();
|
|
60
|
+
|
|
61
|
+
const internalSort: Ref<FdySortState | null> = ref(null);
|
|
62
|
+
const internalFilters: Ref<FdyFilterMap> = ref({});
|
|
63
|
+
const internalPageIndex: Ref<number> = ref(0);
|
|
64
|
+
|
|
65
|
+
const serverPaged: ComputedRef<boolean> = computed((): boolean => props.page != null);
|
|
66
|
+
const sortControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.sort !== undefined);
|
|
67
|
+
const filtersControlled: ComputedRef<boolean> = computed((): boolean => serverPaged.value || props.filters !== undefined);
|
|
68
|
+
|
|
69
|
+
const effectiveSort: ComputedRef<FdySortState | null> = computed((): FdySortState | null =>
|
|
70
|
+
sortControlled.value ? (props.sort ?? null) : internalSort.value,
|
|
71
|
+
);
|
|
72
|
+
const effectiveFilters: ComputedRef<FdyFilterMap> = computed((): FdyFilterMap =>
|
|
73
|
+
filtersControlled.value ? (props.filters ?? {}) : internalFilters.value,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Enum options: explicit (server mode) or the distinct values across the current rows (client mode).
|
|
77
|
+
const enumOptionsMap: ComputedRef<Record<string, ReadonlyArray<string>>> = computed(
|
|
78
|
+
(): Record<string, ReadonlyArray<string>> => {
|
|
79
|
+
const out: Record<string, ReadonlyArray<string>> = {};
|
|
80
|
+
for (const col of props.columns) {
|
|
81
|
+
if (col.filter === 'enum') out[col.key] = col.options ?? distinctValues(props.rows, col);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const filteredSorted: ComputedRef<Row[]> = computed((): Row[] => {
|
|
88
|
+
if (serverPaged.value) return props.rows.slice();
|
|
89
|
+
const filtered: Row[] = filterRows(props.rows, props.columns, effectiveFilters.value);
|
|
90
|
+
return sortRows(filtered, props.columns, effectiveSort.value);
|
|
91
|
+
});
|
|
92
|
+
const totalCount: ComputedRef<number> = computed((): number =>
|
|
93
|
+
serverPaged.value ? (props.page as FdyPageState).total : filteredSorted.value.length,
|
|
94
|
+
);
|
|
95
|
+
const displayRows: ComputedRef<Row[]> = computed((): Row[] => {
|
|
96
|
+
if (serverPaged.value) return props.rows.slice();
|
|
97
|
+
if (props.pageSize && props.pageSize > 0) return paginate(filteredSorted.value, internalPageIndex.value, props.pageSize);
|
|
98
|
+
return filteredSorted.value;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const pageSizeEff: ComputedRef<number> = computed((): number =>
|
|
102
|
+
serverPaged.value ? (props.page as FdyPageState).size : (props.pageSize ?? 0),
|
|
103
|
+
);
|
|
104
|
+
const currentPage1: ComputedRef<number> = computed((): number =>
|
|
105
|
+
(serverPaged.value ? (props.page as FdyPageState).index : internalPageIndex.value) + 1,
|
|
106
|
+
);
|
|
107
|
+
const totalPages: ComputedRef<number> = computed((): number =>
|
|
108
|
+
pageSizeEff.value > 0 ? Math.max(1, Math.ceil(totalCount.value / pageSizeEff.value)) : 1,
|
|
109
|
+
);
|
|
110
|
+
const hasPager: ComputedRef<boolean> = computed((): boolean => pageSizeEff.value > 0 && totalPages.value > 1);
|
|
111
|
+
const pages: ComputedRef<Array<number | 'ellipsis'>> = computed((): Array<number | 'ellipsis'> =>
|
|
112
|
+
pageWindow(currentPage1.value, totalPages.value),
|
|
113
|
+
);
|
|
114
|
+
const rangeFrom: ComputedRef<number> = computed((): number =>
|
|
115
|
+
totalCount.value === 0 ? 0 : (currentPage1.value - 1) * pageSizeEff.value + 1,
|
|
116
|
+
);
|
|
117
|
+
const rangeTo: ComputedRef<number> = computed((): number =>
|
|
118
|
+
totalCount.value === 0 ? 0 : rangeFrom.value - 1 + displayRows.value.length,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Client mode: keep the page in range when a filter shrinks the row set.
|
|
122
|
+
watch(totalPages, (tp: number): void => {
|
|
123
|
+
if (!serverPaged.value && internalPageIndex.value > tp - 1) internalPageIndex.value = Math.max(0, tp - 1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function ariaSortOf(col: FdyTableColumn<Row>): 'ascending' | 'descending' | undefined {
|
|
127
|
+
const s: FdySortState | null = effectiveSort.value;
|
|
128
|
+
if (s === null || s.key !== col.key) return undefined;
|
|
129
|
+
return s.dir === 'asc' ? 'ascending' : 'descending';
|
|
130
|
+
}
|
|
131
|
+
function onSort(col: FdyTableColumn<Row>): void {
|
|
132
|
+
if (col.sortable !== true) return;
|
|
133
|
+
const cur: FdySortState | null = effectiveSort.value;
|
|
134
|
+
const next: FdySortState =
|
|
135
|
+
cur !== null && cur.key === col.key
|
|
136
|
+
? { key: col.key, dir: cur.dir === 'asc' ? 'desc' : 'asc' }
|
|
137
|
+
: { key: col.key, dir: 'asc' };
|
|
138
|
+
if (sortControlled.value) emit('update:sort', next);
|
|
139
|
+
else {
|
|
140
|
+
internalSort.value = next;
|
|
141
|
+
internalPageIndex.value = 0;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function onFilterChange(col: FdyTableColumn<Row>, filter: FdyColumnFilter | null): void {
|
|
145
|
+
const nextMap: FdyFilterMap = { ...effectiveFilters.value };
|
|
146
|
+
if (filter === null) delete nextMap[col.key];
|
|
147
|
+
else nextMap[col.key] = filter;
|
|
148
|
+
if (filtersControlled.value) emit('update:filters', nextMap);
|
|
149
|
+
else {
|
|
150
|
+
internalFilters.value = nextMap;
|
|
151
|
+
internalPageIndex.value = 0;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function goTo(page1: number): void {
|
|
155
|
+
const clamped: number = Math.min(Math.max(1, page1), totalPages.value);
|
|
156
|
+
const index0: number = clamped - 1;
|
|
157
|
+
if (serverPaged.value) {
|
|
158
|
+
const p: FdyPageState = props.page as FdyPageState;
|
|
159
|
+
emit('update:page', { index: index0, size: p.size, total: p.total });
|
|
160
|
+
} else {
|
|
161
|
+
internalPageIndex.value = index0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cellClass(col: FdyTableColumn<Row>): string | undefined {
|
|
166
|
+
return col.mono === true ? 'fdy-mono' : undefined;
|
|
167
|
+
}
|
|
168
|
+
function alignStyle(col: FdyTableColumn<Row>): Record<string, string> | undefined {
|
|
169
|
+
return col.align !== undefined ? { textAlign: col.align } : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function rowClasses(row: Row): Array<string | undefined> {
|
|
173
|
+
return [props.rowClass?.(row), props.rowActivatable === true ? 'fdy-table__row--activatable' : undefined];
|
|
174
|
+
}
|
|
175
|
+
function onRowClick(row: Row): void {
|
|
176
|
+
if (props.rowActivatable === true) emit('row-activate', row);
|
|
177
|
+
}
|
|
178
|
+
// Enter/Space activate only when the row itself is focused — a control inside a cell keeps its own
|
|
179
|
+
// event (the `event.target !== event.currentTarget` guard). Click relies on inner controls calling
|
|
180
|
+
// stopPropagation, matching the pattern consumers hand-roll today.
|
|
181
|
+
function onRowKeydown(e: KeyboardEvent, row: Row): void {
|
|
182
|
+
if (props.rowActivatable !== true || e.target !== e.currentTarget) return;
|
|
183
|
+
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
emit('row-activate', row);
|
|
186
|
+
}
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<template>
|
|
190
|
+
<div class="fdy-datatable">
|
|
191
|
+
<div v-if="$slots.toolbar" class="fdy-table-toolbar">
|
|
192
|
+
<slot name="toolbar" />
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="fdy-table-scroll">
|
|
196
|
+
<table class="fdy-table" :aria-label="ariaLabel">
|
|
197
|
+
<thead>
|
|
198
|
+
<tr>
|
|
199
|
+
<th v-for="col in columns" :key="col.key" scope="col" :style="alignStyle(col)" :aria-sort="ariaSortOf(col)">
|
|
200
|
+
<button
|
|
201
|
+
v-if="col.sortable"
|
|
202
|
+
type="button"
|
|
203
|
+
class="fdy-table__sortbtn"
|
|
204
|
+
@click="onSort(col)"
|
|
205
|
+
>{{ col.label }}</button>
|
|
206
|
+
<template v-else>{{ col.label }}</template>
|
|
207
|
+
<FdyTableFilter
|
|
208
|
+
v-if="col.filter"
|
|
209
|
+
:label="col.label"
|
|
210
|
+
:type="col.filter"
|
|
211
|
+
:filter="effectiveFilters[col.key]"
|
|
212
|
+
:options="enumOptionsMap[col.key] ?? []"
|
|
213
|
+
@change="onFilterChange(col, $event)"
|
|
214
|
+
/>
|
|
215
|
+
</th>
|
|
216
|
+
</tr>
|
|
217
|
+
</thead>
|
|
218
|
+
<tbody>
|
|
219
|
+
<tr v-if="loading">
|
|
220
|
+
<td :colspan="columns.length" class="fdy-table__state" role="status">Loading…</td>
|
|
221
|
+
</tr>
|
|
222
|
+
<tr v-else-if="displayRows.length === 0">
|
|
223
|
+
<td :colspan="columns.length" class="fdy-table__state">
|
|
224
|
+
<slot name="empty">{{ emptyText ?? 'No data' }}</slot>
|
|
225
|
+
</td>
|
|
226
|
+
</tr>
|
|
227
|
+
<tr
|
|
228
|
+
v-for="row in displayRows"
|
|
229
|
+
v-else
|
|
230
|
+
:key="rowKey(row)"
|
|
231
|
+
:class="rowClasses(row)"
|
|
232
|
+
:tabindex="rowActivatable ? 0 : undefined"
|
|
233
|
+
@click="onRowClick(row)"
|
|
234
|
+
@keydown="onRowKeydown($event, row)"
|
|
235
|
+
>
|
|
236
|
+
<td v-for="col in columns" :key="col.key" :class="cellClass(col)" :style="alignStyle(col)">
|
|
237
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="cellValue(row, col)">{{ cellText(row, col) }}</slot>
|
|
238
|
+
</td>
|
|
239
|
+
</tr>
|
|
240
|
+
</tbody>
|
|
241
|
+
</table>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div v-if="hasPager" class="fdy-table-footer">
|
|
245
|
+
<span class="fdy-table-footer__info">Showing {{ rangeFrom }}–{{ rangeTo }} of {{ totalCount }}</span>
|
|
246
|
+
<nav aria-label="Pagination">
|
|
247
|
+
<ul class="fdy-pagination__list">
|
|
248
|
+
<li>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="fdy-pagination__link"
|
|
252
|
+
aria-label="Previous page"
|
|
253
|
+
:disabled="currentPage1 === 1"
|
|
254
|
+
@click="goTo(currentPage1 - 1)"
|
|
255
|
+
>‹</button>
|
|
256
|
+
</li>
|
|
257
|
+
<li v-for="(p, i) in pages" :key="typeof p === 'number' ? p : `gap-${i}`">
|
|
258
|
+
<span v-if="p === 'ellipsis'" class="fdy-pagination__ellipsis">…</span>
|
|
259
|
+
<span v-else-if="p === currentPage1" class="fdy-pagination__link" aria-current="page">{{ p }}</span>
|
|
260
|
+
<button v-else type="button" class="fdy-pagination__link" :aria-label="`Go to page ${p}`" @click="goTo(p)">{{ p }}</button>
|
|
261
|
+
</li>
|
|
262
|
+
<li>
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
class="fdy-pagination__link"
|
|
266
|
+
aria-label="Next page"
|
|
267
|
+
:disabled="currentPage1 === totalPages"
|
|
268
|
+
@click="goTo(currentPage1 + 1)"
|
|
269
|
+
>›</button>
|
|
270
|
+
</li>
|
|
271
|
+
</ul>
|
|
272
|
+
</nav>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</template>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch, type ComputedRef, type Ref } from 'vue';
|
|
3
|
+
import { usePopover } from '../usePopover';
|
|
4
|
+
import { isFilterActive } from '../../core/table-model.js';
|
|
5
|
+
import type { FdyColumnFilter, FdyColumnFilterType } from '../../core/table-model';
|
|
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
|
+
// Reuses usePopover so the panel escapes the table's `overflow:hidden` via the top layer. Purely
|
|
10
|
+
// controlled — it renders the current `filter` and emits the next one (or null to clear); the
|
|
11
|
+
// parent owns where that goes (client state or an `update:filters` emit). Not exported.
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
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
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
/** The next filter for this column, or null to clear it. */
|
|
23
|
+
change: [filter: FdyColumnFilter | null];
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const rootEl: Ref<HTMLSpanElement | null> = ref(null);
|
|
27
|
+
const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
|
|
28
|
+
const panelEl: Ref<HTMLDivElement | null> = ref(null);
|
|
29
|
+
const open: Ref<boolean> = ref(false);
|
|
30
|
+
|
|
31
|
+
usePopover(panelEl, triggerEl, open);
|
|
32
|
+
|
|
33
|
+
const active: ComputedRef<boolean> = computed((): boolean => isFilterActive(props.filter));
|
|
34
|
+
|
|
35
|
+
// Typed reads of the current filter for the inputs (fall back to the empty shape per type).
|
|
36
|
+
const textValue: ComputedRef<string> = computed((): string =>
|
|
37
|
+
props.filter?.type === 'text' ? props.filter.text : '',
|
|
38
|
+
);
|
|
39
|
+
const enumValues: ComputedRef<ReadonlyArray<string>> = computed((): ReadonlyArray<string> =>
|
|
40
|
+
props.filter?.type === 'enum' ? props.filter.values : [],
|
|
41
|
+
);
|
|
42
|
+
const numMin: ComputedRef<string> = computed((): string =>
|
|
43
|
+
props.filter?.type === 'number' && props.filter.min !== null ? String(props.filter.min) : '',
|
|
44
|
+
);
|
|
45
|
+
const numMax: ComputedRef<string> = computed((): string =>
|
|
46
|
+
props.filter?.type === 'number' && props.filter.max !== null ? String(props.filter.max) : '',
|
|
47
|
+
);
|
|
48
|
+
const dateFrom: ComputedRef<string> = computed((): string =>
|
|
49
|
+
props.filter?.type === 'date' && props.filter.from !== null ? props.filter.from : '',
|
|
50
|
+
);
|
|
51
|
+
const dateTo: ComputedRef<string> = computed((): string =>
|
|
52
|
+
props.filter?.type === 'date' && props.filter.to !== null ? props.filter.to : '',
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
function apply(next: FdyColumnFilter): void {
|
|
56
|
+
emit('change', isFilterActive(next) ? next : null);
|
|
57
|
+
}
|
|
58
|
+
function parseNum(v: string): number | null {
|
|
59
|
+
const t: string = v.trim();
|
|
60
|
+
if (t === '') return null;
|
|
61
|
+
const n: number = Number(t);
|
|
62
|
+
return Number.isNaN(n) ? null : n;
|
|
63
|
+
}
|
|
64
|
+
function onText(e: Event): void {
|
|
65
|
+
apply({ type: 'text', text: (e.target as HTMLInputElement).value });
|
|
66
|
+
}
|
|
67
|
+
function onEnumToggle(value: string, checked: boolean): void {
|
|
68
|
+
const set: string[] = enumValues.value.filter((v: string): boolean => v !== value);
|
|
69
|
+
if (checked) set.push(value);
|
|
70
|
+
apply({ type: 'enum', values: set });
|
|
71
|
+
}
|
|
72
|
+
function onNumber(which: 'min' | 'max', e: Event): void {
|
|
73
|
+
const v: number | null = parseNum((e.target as HTMLInputElement).value);
|
|
74
|
+
apply({
|
|
75
|
+
type: 'number',
|
|
76
|
+
min: which === 'min' ? v : parseNum(numMin.value),
|
|
77
|
+
max: which === 'max' ? v : parseNum(numMax.value),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function onDate(which: 'from' | 'to', e: Event): void {
|
|
81
|
+
const v: string = (e.target as HTMLInputElement).value;
|
|
82
|
+
const val: string | null = v === '' ? null : v;
|
|
83
|
+
apply({
|
|
84
|
+
type: 'date',
|
|
85
|
+
from: which === 'from' ? val : (dateFrom.value || null),
|
|
86
|
+
to: which === 'to' ? val : (dateTo.value || null),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toggle(): void {
|
|
91
|
+
open.value = !open.value;
|
|
92
|
+
}
|
|
93
|
+
function close(returnFocus: boolean): void {
|
|
94
|
+
open.value = false;
|
|
95
|
+
if (returnFocus) triggerEl.value?.focus();
|
|
96
|
+
}
|
|
97
|
+
function reset(): void {
|
|
98
|
+
emit('change', null);
|
|
99
|
+
close(true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Dismiss on outside pointer or Escape while open.
|
|
103
|
+
function onDocPointerDown(e: MouseEvent): void {
|
|
104
|
+
const t: EventTarget | null = e.target;
|
|
105
|
+
if (rootEl.value !== null && t instanceof Node && !rootEl.value.contains(t) && !panelEl.value?.contains(t)) {
|
|
106
|
+
close(false);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function onDocKeydown(e: KeyboardEvent): void {
|
|
110
|
+
if (e.key === 'Escape' && open.value) close(true);
|
|
111
|
+
}
|
|
112
|
+
watch(open, (isOpen: boolean): void => {
|
|
113
|
+
if (isOpen) {
|
|
114
|
+
document.addEventListener('mousedown', onDocPointerDown);
|
|
115
|
+
document.addEventListener('keydown', onDocKeydown);
|
|
116
|
+
} else {
|
|
117
|
+
document.removeEventListener('mousedown', onDocPointerDown);
|
|
118
|
+
document.removeEventListener('keydown', onDocKeydown);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
onBeforeUnmount((): void => {
|
|
122
|
+
document.removeEventListener('mousedown', onDocPointerDown);
|
|
123
|
+
document.removeEventListener('keydown', onDocKeydown);
|
|
124
|
+
});
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<span ref="rootEl" class="fdy-table__filterwrap">
|
|
129
|
+
<button
|
|
130
|
+
ref="triggerEl"
|
|
131
|
+
type="button"
|
|
132
|
+
:class="active ? 'fdy-table__filterbtn is-active' : 'fdy-table__filterbtn'"
|
|
133
|
+
aria-haspopup="dialog"
|
|
134
|
+
:aria-pressed="active"
|
|
135
|
+
:aria-expanded="open"
|
|
136
|
+
:aria-label="`Filter ${label}`"
|
|
137
|
+
@click.stop="toggle"
|
|
138
|
+
>
|
|
139
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
140
|
+
<path d="M3 5h18l-7 8v5l-4 2v-7z" />
|
|
141
|
+
</svg>
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
<div ref="panelEl" class="fdy-filter" popover="manual" :hidden="!open" role="dialog" :aria-label="`Filter ${label}`">
|
|
145
|
+
<template v-if="type === 'text'">
|
|
146
|
+
<div class="fdy-filter__title">Contains text</div>
|
|
147
|
+
<input class="fdy-input" type="search" placeholder="Contains…" :value="textValue" @input="onText" />
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<template v-else-if="type === 'enum'">
|
|
151
|
+
<div class="fdy-filter__title">Show values</div>
|
|
152
|
+
<div class="fdy-filter__list">
|
|
153
|
+
<label v-for="val in options" :key="val" class="fdy-filter__check">
|
|
154
|
+
<input
|
|
155
|
+
type="checkbox"
|
|
156
|
+
class="fdy-checkbox"
|
|
157
|
+
:checked="enumValues.includes(val)"
|
|
158
|
+
@change="onEnumToggle(val, ($event.target as HTMLInputElement).checked)"
|
|
159
|
+
/>
|
|
160
|
+
{{ val }}
|
|
161
|
+
</label>
|
|
162
|
+
</div>
|
|
163
|
+
</template>
|
|
164
|
+
|
|
165
|
+
<template v-else-if="type === 'number'">
|
|
166
|
+
<div class="fdy-filter__title">Value range</div>
|
|
167
|
+
<div class="fdy-filter__range">
|
|
168
|
+
<input class="fdy-input" type="text" inputmode="numeric" placeholder="Min" :value="numMin" @input="onNumber('min', $event)" />
|
|
169
|
+
<span aria-hidden="true">–</span>
|
|
170
|
+
<input class="fdy-input" type="text" inputmode="numeric" placeholder="Max" :value="numMax" @input="onNumber('max', $event)" />
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
|
|
174
|
+
<template v-else-if="type === 'date'">
|
|
175
|
+
<div class="fdy-filter__title">Date range</div>
|
|
176
|
+
<div class="fdy-filter__range">
|
|
177
|
+
<input class="fdy-input" type="date" aria-label="From" :value="dateFrom" @input="onDate('from', $event)" />
|
|
178
|
+
<span aria-hidden="true">–</span>
|
|
179
|
+
<input class="fdy-input" type="date" aria-label="To" :value="dateTo" @input="onDate('to', $event)" />
|
|
180
|
+
</div>
|
|
181
|
+
</template>
|
|
182
|
+
|
|
183
|
+
<div class="fdy-filter__foot">
|
|
184
|
+
<button type="button" class="fdy-btn fdy-btn--ghost fdy-btn--sm" @click="reset">Reset</button>
|
|
185
|
+
<button type="button" class="fdy-btn fdy-btn--sm" @click="close(true)">Close</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</span>
|
|
189
|
+
</template>
|
package/adapters/vue/index.d.ts
CHANGED
|
@@ -21,6 +21,22 @@ export { default as FdyCascade } from './components/FdyCascade.vue';
|
|
|
21
21
|
export type { CascadeNode } from './components/FdyCascade.vue';
|
|
22
22
|
export { default as FdyCfl } from './components/FdyCfl.vue';
|
|
23
23
|
export { default as FdyChart } from './components/FdyChart.vue';
|
|
24
|
+
export { default as FdyTable } from './components/FdyTable.vue';
|
|
25
|
+
export { default as FdyModal } from './components/FdyModal.vue';
|
|
26
|
+
export { default as FdyDrawer } from './components/FdyDrawer.vue';
|
|
27
|
+
|
|
28
|
+
/** Controlled data-table types (shared, framework-agnostic core). */
|
|
29
|
+
export type {
|
|
30
|
+
FdyTableColumn,
|
|
31
|
+
FdySortState,
|
|
32
|
+
FdySortDir,
|
|
33
|
+
FdyColumnType,
|
|
34
|
+
FdyColumnAlign,
|
|
35
|
+
FdyColumnFilterType,
|
|
36
|
+
FdyColumnFilter,
|
|
37
|
+
FdyFilterMap,
|
|
38
|
+
FdyPageState,
|
|
39
|
+
} from '../core/table-model';
|
|
24
40
|
|
|
25
41
|
/** One data series for the cartesian chart types (line / area / multi-series & stacked bar). */
|
|
26
42
|
export interface FdyChartSeries {
|
package/adapters/vue/index.js
CHANGED
|
@@ -6,3 +6,6 @@ export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
|
|
|
6
6
|
export { default as FdyCascade } from './components/FdyCascade.vue';
|
|
7
7
|
export { default as FdyCfl } from './components/FdyCfl.vue';
|
|
8
8
|
export { default as FdyChart } from './components/FdyChart.vue';
|
|
9
|
+
export { default as FdyTable } from './components/FdyTable.vue';
|
|
10
|
+
export { default as FdyModal } from './components/FdyModal.vue';
|
|
11
|
+
export { default as FdyDrawer } from './components/FdyDrawer.vue';
|
package/dist/freeday.bundle.css
CHANGED
|
@@ -915,6 +915,7 @@ a { color: var(--color-primary); }
|
|
|
915
915
|
.fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
|
|
916
916
|
.fdy-drawer__close:hover{background:var(--color-surface-2);color:var(--color-text);}
|
|
917
917
|
.fdy-drawer__body{flex:1;min-height:0;overflow:auto;padding:var(--space-4);}
|
|
918
|
+
.fdy-drawer__footer{flex:none;display:flex;gap:var(--space-2);justify-content:flex-end;padding:var(--space-4) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:var(--color-surface-2);}
|
|
918
919
|
/* Symmetric enter AND exit on the native <dialog>: @starting-style animates the entry;
|
|
919
920
|
* the overlay/display allow-discrete transitions keep the panel painted through the exit so
|
|
920
921
|
* it slides back out on close (not just in). prefers-reduced-motion is honored by the global
|
|
@@ -1210,7 +1211,18 @@ a { color: var(--color-primary); }
|
|
|
1210
1211
|
.fdy-table tbody tr:last-child td{border-bottom:0;}
|
|
1211
1212
|
.fdy-table tbody tr{transition:background-color var(--dur-fast) var(--ease-standard);}
|
|
1212
1213
|
.fdy-table tbody tr:hover{background:var(--color-surface-2);}
|
|
1214
|
+
/* Monospace data — ids, codes, IPs, timestamps. Alignment-neutral so it works in any cell
|
|
1215
|
+
or inline (`<span class="fdy-mono">`), not just numeric columns. */
|
|
1216
|
+
.fdy-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
|
|
1217
|
+
/* Numeric cells are mono + right-aligned. Kept self-contained (not composed from .fdy-mono)
|
|
1218
|
+
so existing single-class markup keeps working. */
|
|
1213
1219
|
.fdy-table__num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);}
|
|
1220
|
+
/* Loading / empty state cell (FdyTable spans it across all columns). */
|
|
1221
|
+
.fdy-table__state{text-align:center;color:var(--color-text-muted);padding:var(--space-6) var(--space-4);}
|
|
1222
|
+
/* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint already
|
|
1223
|
+
comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
|
|
1224
|
+
.fdy-table__row--activatable{cursor:pointer;}
|
|
1225
|
+
.fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
|
|
1214
1226
|
.fdy-table th[aria-sort]{cursor:pointer;}
|
|
1215
1227
|
.fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
|
|
1216
1228
|
.fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
|
|
@@ -1234,7 +1246,8 @@ a { color: var(--color-primary); }
|
|
|
1234
1246
|
.fdy-table-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-top:var(--bw) solid var(--color-border);}
|
|
1235
1247
|
.fdy-table-footer__info{font-size:var(--text-sm);color:var(--color-text-muted);}
|
|
1236
1248
|
|
|
1237
|
-
/* Column filter — funnel button injected into a filterable <th> */
|
|
1249
|
+
/* Column filter — funnel button injected (enhancer) or rendered (FdyTable) into a filterable <th> */
|
|
1250
|
+
.fdy-table__filterwrap{display:inline-flex;align-items:center;vertical-align:middle;}
|
|
1238
1251
|
.fdy-table__filterbtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin-left:var(--space-1);padding:0;vertical-align:middle;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-subtle);cursor:pointer;}
|
|
1239
1252
|
.fdy-table__filterbtn:hover{background:var(--color-surface-3);color:var(--color-text);}
|
|
1240
1253
|
.fdy-table__filterbtn:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|
package/dist/freeday.css
CHANGED
|
@@ -570,6 +570,7 @@ a { color: var(--color-primary); }
|
|
|
570
570
|
.fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
|
|
571
571
|
.fdy-drawer__close:hover{background:var(--color-surface-2);color:var(--color-text);}
|
|
572
572
|
.fdy-drawer__body{flex:1;min-height:0;overflow:auto;padding:var(--space-4);}
|
|
573
|
+
.fdy-drawer__footer{flex:none;display:flex;gap:var(--space-2);justify-content:flex-end;padding:var(--space-4) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:var(--color-surface-2);}
|
|
573
574
|
/* Symmetric enter AND exit on the native <dialog>: @starting-style animates the entry;
|
|
574
575
|
* the overlay/display allow-discrete transitions keep the panel painted through the exit so
|
|
575
576
|
* it slides back out on close (not just in). prefers-reduced-motion is honored by the global
|
|
@@ -865,7 +866,18 @@ a { color: var(--color-primary); }
|
|
|
865
866
|
.fdy-table tbody tr:last-child td{border-bottom:0;}
|
|
866
867
|
.fdy-table tbody tr{transition:background-color var(--dur-fast) var(--ease-standard);}
|
|
867
868
|
.fdy-table tbody tr:hover{background:var(--color-surface-2);}
|
|
869
|
+
/* Monospace data — ids, codes, IPs, timestamps. Alignment-neutral so it works in any cell
|
|
870
|
+
or inline (`<span class="fdy-mono">`), not just numeric columns. */
|
|
871
|
+
.fdy-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
|
|
872
|
+
/* Numeric cells are mono + right-aligned. Kept self-contained (not composed from .fdy-mono)
|
|
873
|
+
so existing single-class markup keeps working. */
|
|
868
874
|
.fdy-table__num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);}
|
|
875
|
+
/* Loading / empty state cell (FdyTable spans it across all columns). */
|
|
876
|
+
.fdy-table__state{text-align:center;color:var(--color-text-muted);padding:var(--space-6) var(--space-4);}
|
|
877
|
+
/* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint already
|
|
878
|
+
comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
|
|
879
|
+
.fdy-table__row--activatable{cursor:pointer;}
|
|
880
|
+
.fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
|
|
869
881
|
.fdy-table th[aria-sort]{cursor:pointer;}
|
|
870
882
|
.fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
|
|
871
883
|
.fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
|
|
@@ -889,7 +901,8 @@ a { color: var(--color-primary); }
|
|
|
889
901
|
.fdy-table-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-top:var(--bw) solid var(--color-border);}
|
|
890
902
|
.fdy-table-footer__info{font-size:var(--text-sm);color:var(--color-text-muted);}
|
|
891
903
|
|
|
892
|
-
/* Column filter — funnel button injected into a filterable <th> */
|
|
904
|
+
/* Column filter — funnel button injected (enhancer) or rendered (FdyTable) into a filterable <th> */
|
|
905
|
+
.fdy-table__filterwrap{display:inline-flex;align-items:center;vertical-align:middle;}
|
|
893
906
|
.fdy-table__filterbtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin-left:var(--space-1);padding:0;vertical-align:middle;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-subtle);cursor:pointer;}
|
|
894
907
|
.fdy-table__filterbtn:hover{background:var(--color-surface-3);color:var(--color-text);}
|
|
895
908
|
.fdy-table__filterbtn:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
.fdy-drawer__close{border:0;background:transparent;color:var(--color-text-muted);font-size:var(--text-xl);line-height:1;cursor:pointer;padding:var(--space-1);border-radius:var(--radius-sm);}
|
|
14
14
|
.fdy-drawer__close:hover{background:var(--color-surface-2);color:var(--color-text);}
|
|
15
15
|
.fdy-drawer__body{flex:1;min-height:0;overflow:auto;padding:var(--space-4);}
|
|
16
|
+
.fdy-drawer__footer{flex:none;display:flex;gap:var(--space-2);justify-content:flex-end;padding:var(--space-4) var(--space-5);border-top:var(--bw) solid var(--color-border-muted);background:var(--color-surface-2);}
|
|
16
17
|
/* Symmetric enter AND exit on the native <dialog>: @starting-style animates the entry;
|
|
17
18
|
* the overlay/display allow-discrete transitions keep the panel painted through the exit so
|
|
18
19
|
* it slides back out on close (not just in). prefers-reduced-motion is honored by the global
|
package/src/components/table.css
CHANGED
|
@@ -7,7 +7,18 @@
|
|
|
7
7
|
.fdy-table tbody tr:last-child td{border-bottom:0;}
|
|
8
8
|
.fdy-table tbody tr{transition:background-color var(--dur-fast) var(--ease-standard);}
|
|
9
9
|
.fdy-table tbody tr:hover{background:var(--color-surface-2);}
|
|
10
|
+
/* Monospace data — ids, codes, IPs, timestamps. Alignment-neutral so it works in any cell
|
|
11
|
+
or inline (`<span class="fdy-mono">`), not just numeric columns. */
|
|
12
|
+
.fdy-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
|
|
13
|
+
/* Numeric cells are mono + right-aligned. Kept self-contained (not composed from .fdy-mono)
|
|
14
|
+
so existing single-class markup keeps working. */
|
|
10
15
|
.fdy-table__num{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--font-mono);}
|
|
16
|
+
/* Loading / empty state cell (FdyTable spans it across all columns). */
|
|
17
|
+
.fdy-table__state{text-align:center;color:var(--color-text-muted);padding:var(--space-6) var(--space-4);}
|
|
18
|
+
/* Activatable row (FdyTable rowActivatable): pointer + keyboard focus ring. The hover tint already
|
|
19
|
+
comes from the base `.fdy-table tbody tr:hover` rule, so this only adds the affordance + focus. */
|
|
20
|
+
.fdy-table__row--activatable{cursor:pointer;}
|
|
21
|
+
.fdy-table__row--activatable:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px;}
|
|
11
22
|
.fdy-table th[aria-sort]{cursor:pointer;}
|
|
12
23
|
.fdy-table th[aria-sort="ascending"]::after{content:" ↑";color:var(--color-primary);}
|
|
13
24
|
.fdy-table th[aria-sort="descending"]::after{content:" ↓";color:var(--color-primary);}
|
|
@@ -31,7 +42,8 @@
|
|
|
31
42
|
.fdy-table-footer{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-3) var(--space-4);border-top:var(--bw) solid var(--color-border);}
|
|
32
43
|
.fdy-table-footer__info{font-size:var(--text-sm);color:var(--color-text-muted);}
|
|
33
44
|
|
|
34
|
-
/* Column filter — funnel button injected into a filterable <th> */
|
|
45
|
+
/* Column filter — funnel button injected (enhancer) or rendered (FdyTable) into a filterable <th> */
|
|
46
|
+
.fdy-table__filterwrap{display:inline-flex;align-items:center;vertical-align:middle;}
|
|
35
47
|
.fdy-table__filterbtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;margin-left:var(--space-1);padding:0;vertical-align:middle;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text-subtle);cursor:pointer;}
|
|
36
48
|
.fdy-table__filterbtn:hover{background:var(--color-surface-3);color:var(--color-text);}
|
|
37
49
|
.fdy-table__filterbtn:focus-visible{outline:none;box-shadow:0 0 0 3px color-mix(in srgb,var(--color-primary) 26%,transparent);}
|