@aggc/ui 0.7.1 → 0.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/dist/chunks/DataTable-CIiU5Jx3.js +8688 -0
- package/dist/components/DataTable.styles.d.ts +48 -0
- package/dist/components/DataTable.types.d.ts +38 -0
- package/dist/components/DataTable.vue.d.ts +72 -0
- package/dist/components/UiAvatar.styles.d.ts +53 -0
- package/dist/components/UiAvatar.vue.d.ts +13 -0
- package/dist/components/UiModal.styles.d.ts +31 -0
- package/dist/components/UiModal.vue.d.ts +30 -0
- package/dist/components/UiToast.styles.d.ts +41 -0
- package/dist/components/UiToast.vue.d.ts +13 -0
- package/dist/components/UiToastProvider.vue.d.ts +13 -0
- package/dist/components/UiTooltip.styles.d.ts +1 -0
- package/dist/components/UiTooltip.vue.d.ts +25 -0
- package/dist/components/index.d.ts +11 -0
- package/dist/components.js +30 -12
- package/dist/composables/useToast.d.ts +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +82 -63
- package/dist/ui.css +995 -294
- package/package.json +3 -2
- package/src/components/DataTable.styles.ts +493 -0
- package/src/components/DataTable.test.ts +249 -0
- package/src/components/DataTable.types.ts +42 -0
- package/src/components/DataTable.vue +567 -0
- package/src/components/UiAvatar.styles.ts +81 -0
- package/src/components/UiAvatar.test.ts +43 -0
- package/src/components/UiAvatar.vue +41 -0
- package/src/components/UiModal.styles.ts +143 -0
- package/src/components/UiModal.test.ts +64 -0
- package/src/components/UiModal.vue +82 -0
- package/src/components/UiToast.styles.ts +143 -0
- package/src/components/UiToast.test.ts +47 -0
- package/src/components/UiToast.vue +65 -0
- package/src/components/UiToastProvider.vue +22 -0
- package/src/components/UiTooltip.styles.ts +25 -0
- package/src/components/UiTooltip.test.ts +37 -0
- package/src/components/UiTooltip.vue +37 -0
- package/src/components/index.ts +17 -0
- package/src/composables/useToast.ts +43 -0
- package/src/css/base.css +61 -1
- package/src/index.ts +1 -0
- package/src/stories/feedback/UiToast.stories.ts +72 -0
- package/src/stories/layout/DataTable.stories.ts +141 -0
- package/src/stories/layout/UiModal.stories.ts +89 -0
- package/src/stories/primitives/UiAvatar.stories.ts +83 -0
- package/src/stories/primitives/UiTooltip.stories.ts +46 -0
- package/src/stories/support/sources.ts +81 -0
- package/dist/chunks/UiSkeleton.vue_vue_type_script_setup_true_lang-DUse1KRc.js +0 -1201
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
import { ref, computed, watch, onMounted, onUnmounted, useSlots } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
Search, X, SearchX, MoreHorizontal, Check, Minus,
|
|
5
|
+
ArrowUp, ArrowDown, ChevronsUpDown, ChevronLeft, ChevronRight,
|
|
6
|
+
} from "lucide-vue-next";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenuRoot,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
} from "reka-ui";
|
|
12
|
+
import UiSkeleton from "./UiSkeleton.vue";
|
|
13
|
+
import UiTooltip from "./UiTooltip.vue";
|
|
14
|
+
import type {
|
|
15
|
+
DataTableColumn,
|
|
16
|
+
DataTableLabels,
|
|
17
|
+
DataTableSortState,
|
|
18
|
+
} from "./DataTable.types";
|
|
19
|
+
import {
|
|
20
|
+
toolbar, searchWrap, searchIcon, searchInput, searchClear, searchClearIcon,
|
|
21
|
+
bulkBar, bulkCount,
|
|
22
|
+
table, tableHead, tableBody, tableRow,
|
|
23
|
+
headerCell, headerLabel, sortBtn, sortIcon, sortIconActive,
|
|
24
|
+
dataCell, actionsCell, hideMobileCell,
|
|
25
|
+
checkCell, nativeCheckbox, checkIcon,
|
|
26
|
+
menuBtn, menuBtnIcon, actionMenu,
|
|
27
|
+
mobileList, mobileCard, mobileCardSelected, mobileToolbar, mobileActionsRow, mobileSearchRow, mobileSelectAll, mobileBulkActions,
|
|
28
|
+
deselectBtn, deselectIcon,
|
|
29
|
+
filteredEmpty, filteredEmptyIcon, filteredEmptyTitle, filteredEmptyDesc,
|
|
30
|
+
clearSearchBtn, listFooter, footerCount, pageBtn, pageInfo,
|
|
31
|
+
} from "./DataTable.styles";
|
|
32
|
+
|
|
33
|
+
const BREAKPOINT_MD = 768;
|
|
34
|
+
|
|
35
|
+
const props = withDefaults(defineProps<{
|
|
36
|
+
items: T[];
|
|
37
|
+
columns: DataTableColumn[];
|
|
38
|
+
isLoading?: boolean;
|
|
39
|
+
searchable?: boolean;
|
|
40
|
+
searchPlaceholder?: string;
|
|
41
|
+
searchFields?: string[];
|
|
42
|
+
itemIdKey?: string;
|
|
43
|
+
/** Show the selection column + bulk actions. Set false for display-only tables. */
|
|
44
|
+
selectable?: boolean;
|
|
45
|
+
/** Override the scrollable body max-height (any CSS length, or "none"). */
|
|
46
|
+
bodyMaxHeight?: string;
|
|
47
|
+
/** Override user-visible strings for i18n. */
|
|
48
|
+
labels?: Partial<DataTableLabels>;
|
|
49
|
+
/** Where sorting runs. "client" sorts in-place; "server" only emits. */
|
|
50
|
+
sortMode?: "client" | "server";
|
|
51
|
+
/** Controlled sort state (use with v-model:sort). */
|
|
52
|
+
sort?: DataTableSortState | null;
|
|
53
|
+
/** Page size for client-side pagination. Omit to disable. */
|
|
54
|
+
pageSize?: number;
|
|
55
|
+
/** Controlled current page (use with v-model:page). */
|
|
56
|
+
page?: number;
|
|
57
|
+
}>(), {
|
|
58
|
+
isLoading: false,
|
|
59
|
+
searchable: false,
|
|
60
|
+
searchPlaceholder: "Search...",
|
|
61
|
+
searchFields: () => [],
|
|
62
|
+
itemIdKey: "id",
|
|
63
|
+
selectable: true,
|
|
64
|
+
sortMode: "client",
|
|
65
|
+
sort: undefined,
|
|
66
|
+
pageSize: undefined,
|
|
67
|
+
page: undefined,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const emit = defineEmits<{
|
|
71
|
+
"update:selected": [string[]];
|
|
72
|
+
"update:sort": [DataTableSortState | null];
|
|
73
|
+
"update:page": [number];
|
|
74
|
+
}>();
|
|
75
|
+
|
|
76
|
+
// ── Labels (i18n) ──
|
|
77
|
+
const DEFAULT_LABELS: DataTableLabels = {
|
|
78
|
+
search: "Search",
|
|
79
|
+
searchPlaceholder: "Search...",
|
|
80
|
+
clearSearch: "Clear search",
|
|
81
|
+
selectAll: "Select all",
|
|
82
|
+
deselectAll: "Deselect all",
|
|
83
|
+
select: "Select",
|
|
84
|
+
deselect: "Deselect",
|
|
85
|
+
selectRow: "Select row",
|
|
86
|
+
actions: "Actions",
|
|
87
|
+
selected: "{count} selected",
|
|
88
|
+
noResultsTitle: 'No results for "{term}"',
|
|
89
|
+
noResultsDesc: "Try a different search term.",
|
|
90
|
+
clearSearchBtn: "Clear search",
|
|
91
|
+
footerCount: "{shown} of {total} items",
|
|
92
|
+
previousPage: "Previous page",
|
|
93
|
+
nextPage: "Next page",
|
|
94
|
+
pageOf: "Page {page} of {pages}",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const L = computed<DataTableLabels>(() => ({ ...DEFAULT_LABELS, ...props.labels }));
|
|
98
|
+
|
|
99
|
+
function fmt(str: string, vars: Record<string, string | number>): string {
|
|
100
|
+
return str.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ""));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Selection (Set-based, immutable updates) ──
|
|
104
|
+
const selectedSet = ref(new Set<string>());
|
|
105
|
+
const selectedSize = computed(() => selectedSet.value.size);
|
|
106
|
+
const isMobile = ref(false);
|
|
107
|
+
const selectMode = computed(() => isMobile.value && selectedSize.value > 0);
|
|
108
|
+
const selectedItems = computed(() => props.items.filter(i => selectedSet.value.has(getItemId(i))));
|
|
109
|
+
|
|
110
|
+
function toggleItem(id: string, idx: number) {
|
|
111
|
+
const next = new Set(selectedSet.value);
|
|
112
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
113
|
+
selectedSet.value = next;
|
|
114
|
+
lastClickedIdx.value = idx;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Shift+Click range select
|
|
118
|
+
const lastClickedIdx = ref(-1);
|
|
119
|
+
|
|
120
|
+
function onCheckboxClick(e: MouseEvent, itemId: string, idx: number) {
|
|
121
|
+
// Don't double-toggle if mousedown already toggled this checkbox
|
|
122
|
+
if (suppressCheckboxClick) { suppressCheckboxClick = false; return; }
|
|
123
|
+
if (e.shiftKey && lastClickedIdx.value !== -1 && lastClickedIdx.value !== idx) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const start = Math.min(lastClickedIdx.value, idx);
|
|
126
|
+
const end = Math.max(lastClickedIdx.value, idx);
|
|
127
|
+
const targetState = !selectedSet.value.has(itemId);
|
|
128
|
+
const next = new Set(selectedSet.value);
|
|
129
|
+
for (let i = start; i <= end; i++) {
|
|
130
|
+
const id = getItemId(viewItems.value[i]);
|
|
131
|
+
if (targetState) next.add(id);
|
|
132
|
+
else next.delete(id);
|
|
133
|
+
}
|
|
134
|
+
selectedSet.value = next;
|
|
135
|
+
} else {
|
|
136
|
+
toggleItem(itemId, idx);
|
|
137
|
+
}
|
|
138
|
+
// Keep drag-to-select direction in sync with checkbox action
|
|
139
|
+
dragTargetState.value = selectedSet.value.has(itemId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function toggleAll() {
|
|
143
|
+
if (isAllSelected() || isIndeterminate()) selectedSet.value = new Set();
|
|
144
|
+
else selectedSet.value = new Set(viewItems.value.map(i => getItemId(i)));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isAllSelected(): boolean {
|
|
148
|
+
const ids = viewItems.value.map(i => getItemId(i));
|
|
149
|
+
return ids.length > 0 && ids.every(id => selectedSet.value.has(id));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isIndeterminate(): boolean {
|
|
153
|
+
const ids = viewItems.value.map(i => getItemId(i));
|
|
154
|
+
return ids.some(id => selectedSet.value.has(id)) && !isAllSelected();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ariaCheckedValue(): "true" | "false" | "mixed" {
|
|
158
|
+
if (isIndeterminate()) return "mixed";
|
|
159
|
+
return isAllSelected() ? "true" : "false";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
watch(() => selectedSet.value.size, () => emit("update:selected", [...selectedSet.value]));
|
|
163
|
+
|
|
164
|
+
// ── Safe ID (fail fast in dev on missing id) ──
|
|
165
|
+
function getItemId(item: T): string {
|
|
166
|
+
const raw = (item as Record<string, unknown>)[props.itemIdKey];
|
|
167
|
+
if (raw != null && String(raw).length > 0) return String(raw);
|
|
168
|
+
if (import.meta.env.DEV) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`[DataTable] item is missing an id for itemIdKey "${props.itemIdKey}". ` +
|
|
171
|
+
`Set a valid itemIdKey prop or ensure every item has that field.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Search (debounced) ──
|
|
178
|
+
const searchRaw = ref("");
|
|
179
|
+
const search = ref("");
|
|
180
|
+
let searchTimer: ReturnType<typeof setTimeout>;
|
|
181
|
+
watch(searchRaw, (v) => {
|
|
182
|
+
clearTimeout(searchTimer);
|
|
183
|
+
searchTimer = setTimeout(() => { search.value = v; }, 150);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const filteredItems = computed(() => {
|
|
187
|
+
const q = search.value.toLowerCase().trim();
|
|
188
|
+
if (!q) return props.items;
|
|
189
|
+
const fields = props.searchFields?.length ? props.searchFields : props.columns.map(c => c.key);
|
|
190
|
+
return props.items.filter(item =>
|
|
191
|
+
fields.some(f => {
|
|
192
|
+
const val = (item as Record<string, unknown>)[f];
|
|
193
|
+
return val != null && String(val).toLowerCase().includes(q);
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── Sorting ──
|
|
199
|
+
const sortControlled = computed(() => props.sort !== undefined);
|
|
200
|
+
const internalSort = ref<DataTableSortState | null>(null);
|
|
201
|
+
const currentSort = computed<DataTableSortState | null>(() =>
|
|
202
|
+
sortControlled.value ? (props.sort ?? null) : internalSort.value,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
function setSort(next: DataTableSortState | null) {
|
|
206
|
+
if (!sortControlled.value) internalSort.value = next;
|
|
207
|
+
emit("update:sort", next);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function onHeaderClick(col: DataTableColumn) {
|
|
211
|
+
if (!col.sortable) return;
|
|
212
|
+
const cur = currentSort.value;
|
|
213
|
+
let next: DataTableSortState | null;
|
|
214
|
+
if (cur?.key !== col.key) next = { key: col.key, dir: "asc" };
|
|
215
|
+
else if (cur.dir === "asc") next = { key: col.key, dir: "desc" };
|
|
216
|
+
else next = null; // desc → clear
|
|
217
|
+
setSort(next);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const sortedItems = computed(() => {
|
|
221
|
+
const cur = currentSort.value;
|
|
222
|
+
if (props.sortMode !== "client" || !cur) return filteredItems.value;
|
|
223
|
+
const { key, dir } = cur;
|
|
224
|
+
const mul = dir === "asc" ? 1 : -1;
|
|
225
|
+
return [...filteredItems.value].sort((a, b) => {
|
|
226
|
+
const av = (a as Record<string, unknown>)[key];
|
|
227
|
+
const bv = (b as Record<string, unknown>)[key];
|
|
228
|
+
if (av == null && bv == null) return 0;
|
|
229
|
+
if (av == null) return 1;
|
|
230
|
+
if (bv == null) return -1;
|
|
231
|
+
if (typeof av === "number" && typeof bv === "number") return (av - bv) * mul;
|
|
232
|
+
return String(av).localeCompare(String(bv)) * mul;
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── Pagination (client-side) ──
|
|
237
|
+
const paginated = computed(() => typeof props.pageSize === "number" && props.pageSize > 0);
|
|
238
|
+
const totalPages = computed(() =>
|
|
239
|
+
paginated.value && props.pageSize
|
|
240
|
+
? Math.max(1, Math.ceil(sortedItems.value.length / props.pageSize))
|
|
241
|
+
: 1,
|
|
242
|
+
);
|
|
243
|
+
const pageControlled = computed(() => props.page !== undefined);
|
|
244
|
+
const internalPage = ref(1);
|
|
245
|
+
const currentPage = computed(() => {
|
|
246
|
+
const p = pageControlled.value ? (props.page ?? 1) : internalPage.value;
|
|
247
|
+
return Math.min(Math.max(1, p), totalPages.value);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
function setPage(p: number) {
|
|
251
|
+
const clamped = Math.min(Math.max(1, p), totalPages.value);
|
|
252
|
+
if (!pageControlled.value) internalPage.value = clamped;
|
|
253
|
+
emit("update:page", clamped);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Reset internal page when the dataset changes shape.
|
|
257
|
+
watch([filteredItems, currentSort], () => {
|
|
258
|
+
if (!pageControlled.value && internalPage.value > totalPages.value) {
|
|
259
|
+
internalPage.value = totalPages.value;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const viewItems = computed(() => {
|
|
264
|
+
if (!paginated.value || !props.pageSize) return sortedItems.value;
|
|
265
|
+
const start = (currentPage.value - 1) * props.pageSize;
|
|
266
|
+
return sortedItems.value.slice(start, start + props.pageSize);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Pre-resolve id + index once per visible row.
|
|
270
|
+
const rowsWithIds = computed(() =>
|
|
271
|
+
viewItems.value.map((item, idx) => ({ item, id: getItemId(item), idx })),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const slots = useSlots();
|
|
275
|
+
const hasActions = computed(() => !!slots.actions);
|
|
276
|
+
const gridTemplate = computed(() => {
|
|
277
|
+
const prefix = props.selectable ? "40px " : "";
|
|
278
|
+
const suffix = hasActions.value ? " 48px" : "";
|
|
279
|
+
return `${prefix}${props.columns.map(c => c.width).join(" ")}${suffix}`;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const bodyMaxHeightStyle = computed(() =>
|
|
283
|
+
props.bodyMaxHeight !== undefined ? { maxHeight: props.bodyMaxHeight } : {},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// ── Drag-to-select (paint selection) ──
|
|
287
|
+
const dragSelecting = ref(false);
|
|
288
|
+
const dragTargetState = ref(false);
|
|
289
|
+
const dragStartIdx = ref(-1);
|
|
290
|
+
let suppressCheckboxClick = false;
|
|
291
|
+
|
|
292
|
+
function startDragSelect(idx: number, itemId: string, isCheckbox: boolean) {
|
|
293
|
+
dragSelecting.value = true;
|
|
294
|
+
dragStartIdx.value = idx;
|
|
295
|
+
dragTargetState.value = !selectedSet.value.has(itemId);
|
|
296
|
+
toggleItem(itemId, idx);
|
|
297
|
+
if (isCheckbox) suppressCheckboxClick = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function applyDragRange(idx: number) {
|
|
301
|
+
if (!dragSelecting.value || dragStartIdx.value === -1) return;
|
|
302
|
+
const start = Math.min(dragStartIdx.value, idx);
|
|
303
|
+
const end = Math.max(dragStartIdx.value, idx);
|
|
304
|
+
const next = new Set(selectedSet.value);
|
|
305
|
+
for (let i = start; i <= end; i++) {
|
|
306
|
+
const id = getItemId(viewItems.value[i]);
|
|
307
|
+
if (dragTargetState.value) next.add(id);
|
|
308
|
+
else next.delete(id);
|
|
309
|
+
}
|
|
310
|
+
selectedSet.value = next;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function stopDragSelect() {
|
|
314
|
+
dragSelecting.value = false;
|
|
315
|
+
dragStartIdx.value = -1;
|
|
316
|
+
touchDragActive = false;
|
|
317
|
+
setTimeout(() => { suppressCheckboxClick = false; }, 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Desktop: mouse
|
|
321
|
+
function onRowMouseDown(e: MouseEvent, itemId: string, idx: number) {
|
|
322
|
+
if (!props.selectable || e.button !== 0) return;
|
|
323
|
+
const target = e.target as HTMLElement;
|
|
324
|
+
const onCheckbox = !!target.closest("button[role='checkbox'], [role='checkbox']");
|
|
325
|
+
startDragSelect(idx, itemId, onCheckbox);
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function onRowMouseEnter(idx: number) {
|
|
330
|
+
if (!props.selectable) return;
|
|
331
|
+
applyDragRange(idx);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Mobile: touch — only activate if started on a checkbox, else allow native scroll
|
|
335
|
+
let touchDragActive = false;
|
|
336
|
+
|
|
337
|
+
function onRowTouchStart(e: TouchEvent, itemId: string, idx: number) {
|
|
338
|
+
if (!props.selectable) return;
|
|
339
|
+
const target = e.target as HTMLElement;
|
|
340
|
+
const onCheckbox = !!target.closest("button[role='checkbox'], [role='checkbox']");
|
|
341
|
+
if (!onCheckbox && !selectMode.value) return;
|
|
342
|
+
touchDragActive = true;
|
|
343
|
+
startDragSelect(idx, itemId, onCheckbox);
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function onRowTouchMove(e: TouchEvent) {
|
|
348
|
+
if (!touchDragActive) return;
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
const touch = e.touches[0];
|
|
351
|
+
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
|
352
|
+
if (!el) return;
|
|
353
|
+
const row = el.closest("[data-row-idx]") as HTMLElement | null;
|
|
354
|
+
if (!row) return;
|
|
355
|
+
const idx = parseInt(row.dataset.rowIdx || "-1");
|
|
356
|
+
if (idx >= 0) applyDragRange(idx);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Responsive ──
|
|
360
|
+
function onResize() { isMobile.value = window.innerWidth < BREAKPOINT_MD; }
|
|
361
|
+
|
|
362
|
+
// ── Keyboard shortcuts ──
|
|
363
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
364
|
+
if (!props.selectable) return;
|
|
365
|
+
if ((e.key === "a" || e.key === "A") && (e.metaKey || e.ctrlKey)) {
|
|
366
|
+
// Ctrl/Cmd+A: select all visible
|
|
367
|
+
if (document.activeElement?.closest("input, textarea, [contenteditable]")) return;
|
|
368
|
+
e.preventDefault();
|
|
369
|
+
selectedSet.value = new Set(viewItems.value.map(i => getItemId(i)));
|
|
370
|
+
} else if (e.key === "Escape") {
|
|
371
|
+
selectedSet.value = new Set();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
onMounted(() => {
|
|
376
|
+
onResize();
|
|
377
|
+
window.addEventListener("resize", onResize);
|
|
378
|
+
window.addEventListener("mouseup", stopDragSelect);
|
|
379
|
+
window.addEventListener("touchend", stopDragSelect);
|
|
380
|
+
window.addEventListener("keydown", onKeyDown);
|
|
381
|
+
});
|
|
382
|
+
onUnmounted(() => {
|
|
383
|
+
clearTimeout(searchTimer);
|
|
384
|
+
window.removeEventListener("resize", onResize);
|
|
385
|
+
window.removeEventListener("mouseup", stopDragSelect);
|
|
386
|
+
window.removeEventListener("touchend", stopDragSelect);
|
|
387
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
388
|
+
});
|
|
389
|
+
</script>
|
|
390
|
+
|
|
391
|
+
<template>
|
|
392
|
+
<div :aria-busy="isLoading || undefined">
|
|
393
|
+
<!-- Desktop toolbar -->
|
|
394
|
+
<div v-if="searchable && items.length > 0 && !isLoading && !isMobile" :class="toolbar">
|
|
395
|
+
<div :class="searchWrap">
|
|
396
|
+
<Search :class="searchIcon" aria-hidden="true" />
|
|
397
|
+
<input v-model="searchRaw" :class="searchInput" type="text" :placeholder="L.searchPlaceholder" :aria-label="L.search" />
|
|
398
|
+
<button v-if="searchRaw" :class="searchClear" @click="searchRaw = ''" :aria-label="L.clearSearch"><X :class="searchClearIcon" /></button>
|
|
399
|
+
</div>
|
|
400
|
+
<div v-if="selectable" v-show="selectedSize" :class="bulkBar">
|
|
401
|
+
<span :class="bulkCount">{{ fmt(L.selected, { count: selectedSize }) }}</span>
|
|
402
|
+
<UiTooltip :content="L.deselectAll">
|
|
403
|
+
<button type="button" :class="deselectBtn" @click="selectedSet = new Set()" :aria-label="L.deselectAll"><X :class="deselectIcon" /></button>
|
|
404
|
+
</UiTooltip>
|
|
405
|
+
<slot name="bulk-actions" :selected="[...selectedSet]" :selected-items="selectedItems" />
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<!-- Mobile: sticky toolbar (search + select-all + bulk) -->
|
|
410
|
+
<div v-if="searchable && items.length > 0 && !isLoading && isMobile" :class="mobileToolbar">
|
|
411
|
+
<div :class="mobileSearchRow">
|
|
412
|
+
<div :class="searchWrap">
|
|
413
|
+
<Search :class="searchIcon" aria-hidden="true" />
|
|
414
|
+
<input v-model="searchRaw" :class="searchInput" type="text" :placeholder="L.searchPlaceholder" :aria-label="L.search" />
|
|
415
|
+
<button v-if="searchRaw" :class="searchClear" @click="searchRaw = ''" :aria-label="L.clearSearch"><X :class="searchClearIcon" /></button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
<div :class="mobileActionsRow">
|
|
419
|
+
<UiTooltip v-if="selectable" :content="isAllSelected() && !isIndeterminate() ? L.deselectAll : L.selectAll">
|
|
420
|
+
<button type="button" :class="mobileSelectAll" @click="toggleAll">
|
|
421
|
+
<span :class="[nativeCheckbox, (isAllSelected() || isIndeterminate()) && 'checked']" aria-hidden="true">
|
|
422
|
+
<Check v-if="isAllSelected() && !isIndeterminate()" :class="checkIcon" />
|
|
423
|
+
<Minus v-else-if="isIndeterminate()" :class="checkIcon" />
|
|
424
|
+
</span>
|
|
425
|
+
{{ isAllSelected() && !isIndeterminate() ? L.deselectAll : L.selectAll }}
|
|
426
|
+
</button>
|
|
427
|
+
</UiTooltip>
|
|
428
|
+
<div v-if="selectable && selectedSize" :class="mobileBulkActions">
|
|
429
|
+
<slot name="bulk-actions" :selected="[...selectedSet]" :selected-items="selectedItems" />
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<!-- Filtered empty -->
|
|
435
|
+
<div v-if="search && filteredItems.length === 0 && !isLoading" :class="filteredEmpty">
|
|
436
|
+
<SearchX :class="filteredEmptyIcon" />
|
|
437
|
+
<p :class="filteredEmptyTitle">{{ fmt(L.noResultsTitle, { term: search }) }}</p>
|
|
438
|
+
<p :class="filteredEmptyDesc">{{ L.noResultsDesc }}</p>
|
|
439
|
+
<button type="button" :class="clearSearchBtn" @click="searchRaw = ''">{{ L.clearSearchBtn }}</button>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<template v-else>
|
|
443
|
+
<!-- Desktop table -->
|
|
444
|
+
<div v-if="!isMobile" :class="table">
|
|
445
|
+
<div :class="tableHead" :style="{ gridTemplateColumns: gridTemplate }">
|
|
446
|
+
<div v-if="selectable" :class="checkCell">
|
|
447
|
+
<UiTooltip :content="isAllSelected() ? L.deselectAll : L.selectAll">
|
|
448
|
+
<button type="button" :class="[nativeCheckbox, (isAllSelected() || isIndeterminate()) && 'checked']"
|
|
449
|
+
role="checkbox" :aria-checked="ariaCheckedValue()"
|
|
450
|
+
:aria-label="isAllSelected() ? L.deselectAll : L.selectAll"
|
|
451
|
+
@click="toggleAll">
|
|
452
|
+
<Check v-if="isAllSelected() && !isIndeterminate()" :class="checkIcon" />
|
|
453
|
+
<Minus v-else-if="isIndeterminate()" :class="checkIcon" />
|
|
454
|
+
</button>
|
|
455
|
+
</UiTooltip>
|
|
456
|
+
</div>
|
|
457
|
+
<div v-for="col in columns" :key="col.key" :class="[headerCell, col.hideMobile && hideMobileCell]">
|
|
458
|
+
<slot :name="`header-${col.key}`">
|
|
459
|
+
<button v-if="col.sortable" type="button" :class="sortBtn"
|
|
460
|
+
:aria-label="`${col.label}, ${currentSort?.key === col.key ? currentSort.dir : 'none'}`"
|
|
461
|
+
@click="onHeaderClick(col)">
|
|
462
|
+
<span>{{ col.label }}</span>
|
|
463
|
+
<ArrowUp v-if="currentSort?.key === col.key && currentSort.dir === 'asc'" :class="[sortIcon, sortIconActive]" />
|
|
464
|
+
<ArrowDown v-else-if="currentSort?.key === col.key && currentSort.dir === 'desc'" :class="[sortIcon, sortIconActive]" />
|
|
465
|
+
<ChevronsUpDown v-else :class="sortIcon" />
|
|
466
|
+
</button>
|
|
467
|
+
<span v-else :class="headerLabel">{{ col.label }}</span>
|
|
468
|
+
</slot>
|
|
469
|
+
</div>
|
|
470
|
+
<div v-if="hasActions" :class="headerCell" role="columnheader" :aria-label="L.actions" />
|
|
471
|
+
</div>
|
|
472
|
+
<div :class="tableBody" role="rowgroup" :style="bodyMaxHeightStyle">
|
|
473
|
+
<!-- Skeleton rows while loading -->
|
|
474
|
+
<template v-if="isLoading">
|
|
475
|
+
<div
|
|
476
|
+
v-for="i in 5"
|
|
477
|
+
:key="`sk-${i}`"
|
|
478
|
+
:class="tableRow"
|
|
479
|
+
:style="{ gridTemplateColumns: gridTemplate }"
|
|
480
|
+
aria-hidden="true"
|
|
481
|
+
>
|
|
482
|
+
<div v-if="selectable" :class="checkCell"><UiSkeleton width="16px" height="16px" /></div>
|
|
483
|
+
<div v-for="col in columns" :key="col.key" :class="[dataCell, col.hideMobile && hideMobileCell]">
|
|
484
|
+
<UiSkeleton variant="text" :width="i % 2 === 0 ? '55%' : '70%'" />
|
|
485
|
+
</div>
|
|
486
|
+
<div v-if="hasActions" :class="dataCell" />
|
|
487
|
+
</div>
|
|
488
|
+
</template>
|
|
489
|
+
<!-- Data rows -->
|
|
490
|
+
<template v-else>
|
|
491
|
+
<div
|
|
492
|
+
v-for="row in rowsWithIds"
|
|
493
|
+
:key="row.id"
|
|
494
|
+
:class="tableRow"
|
|
495
|
+
:style="{ gridTemplateColumns: gridTemplate }"
|
|
496
|
+
:data-row-idx="row.idx"
|
|
497
|
+
@mousedown="onRowMouseDown($event, row.id, row.idx)"
|
|
498
|
+
@mouseenter="onRowMouseEnter(row.idx)"
|
|
499
|
+
@touchstart="onRowTouchStart($event, row.id, row.idx)"
|
|
500
|
+
@touchmove="onRowTouchMove"
|
|
501
|
+
@touchend="stopDragSelect"
|
|
502
|
+
>
|
|
503
|
+
<div v-if="selectable" :class="checkCell">
|
|
504
|
+
<UiTooltip :content="selectedSet.has(row.id) ? L.deselect : L.select">
|
|
505
|
+
<button type="button" :class="[nativeCheckbox, selectedSet.has(row.id) && 'checked']"
|
|
506
|
+
role="checkbox" :aria-checked="selectedSet.has(row.id)"
|
|
507
|
+
:aria-label="L.selectRow" @click="onCheckboxClick($event, row.id, row.idx)">
|
|
508
|
+
<Check v-if="selectedSet.has(row.id)" :class="checkIcon" />
|
|
509
|
+
</button>
|
|
510
|
+
</UiTooltip>
|
|
511
|
+
</div>
|
|
512
|
+
<div v-for="col in columns" :key="col.key" :class="[dataCell, col.hideMobile && hideMobileCell]">
|
|
513
|
+
<slot :name="`cell-${col.key}`" :item="row.item" />
|
|
514
|
+
</div>
|
|
515
|
+
<div v-if="hasActions" :class="actionsCell">
|
|
516
|
+
<DropdownMenuRoot>
|
|
517
|
+
<DropdownMenuTrigger as="button" :class="menuBtn" :aria-label="L.actions"><MoreHorizontal :class="menuBtnIcon" /></DropdownMenuTrigger>
|
|
518
|
+
<DropdownMenuContent :class="actionMenu" align="end" :side-offset="4">
|
|
519
|
+
<slot name="actions" :item="row.item" />
|
|
520
|
+
</DropdownMenuContent>
|
|
521
|
+
</DropdownMenuRoot>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
</template>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
<!-- Mobile cards -->
|
|
529
|
+
<div v-if="isMobile" :class="mobileList">
|
|
530
|
+
<template v-if="isLoading">
|
|
531
|
+
<div v-for="i in 5" :key="`sk-m-${i}`" :class="mobileCard" aria-hidden="true">
|
|
532
|
+
<UiSkeleton variant="title" height="18px" :width="i % 2 === 0 ? '45%' : '65%'" />
|
|
533
|
+
<UiSkeleton variant="text" height="12px" :width="i % 3 === 0 ? '70%' : '55%'" />
|
|
534
|
+
</div>
|
|
535
|
+
</template>
|
|
536
|
+
<template v-else>
|
|
537
|
+
<div
|
|
538
|
+
v-for="row in rowsWithIds"
|
|
539
|
+
:key="row.id"
|
|
540
|
+
:class="[mobileCard, selectable && selectedSet.has(row.id) && mobileCardSelected]"
|
|
541
|
+
:data-row-idx="row.idx"
|
|
542
|
+
@touchstart="onRowTouchStart($event, row.id, row.idx)"
|
|
543
|
+
@touchmove="onRowTouchMove"
|
|
544
|
+
@touchend="stopDragSelect"
|
|
545
|
+
>
|
|
546
|
+
<slot name="mobile-card" :item="row.item" :selected="selectedSet.has(row.id)" :toggle-select="() => toggleItem(row.id, row.idx)" :select-mode="selectMode" />
|
|
547
|
+
</div>
|
|
548
|
+
</template>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<!-- Footer -->
|
|
552
|
+
<div v-if="items.length > 0" :class="listFooter">
|
|
553
|
+
<span :class="footerCount">{{ fmt(L.footerCount, { shown: filteredItems.length, total: items.length }) }}</span>
|
|
554
|
+
<template v-if="paginated && totalPages > 1">
|
|
555
|
+
<button type="button" :class="pageBtn" :disabled="currentPage <= 1" :aria-label="L.previousPage" @click="setPage(currentPage - 1)"><ChevronLeft :class="sortIcon" /></button>
|
|
556
|
+
<span :class="pageInfo">{{ fmt(L.pageOf, { page: currentPage, pages: totalPages }) }}</span>
|
|
557
|
+
<button type="button" :class="pageBtn" :disabled="currentPage >= totalPages" :aria-label="L.nextPage" @click="setPage(currentPage + 1)"><ChevronRight :class="sortIcon" /></button>
|
|
558
|
+
</template>
|
|
559
|
+
</div>
|
|
560
|
+
</template>
|
|
561
|
+
</div>
|
|
562
|
+
</template>
|
|
563
|
+
|
|
564
|
+
<style scoped>
|
|
565
|
+
.bulk-bar-enter-active, .bulk-bar-leave-active { transition: opacity 150ms ease-out; }
|
|
566
|
+
.bulk-bar-enter-from, .bulk-bar-leave-to { opacity: 0; }
|
|
567
|
+
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { css, cva } from "@styled/css";
|
|
2
|
+
|
|
3
|
+
export const uiAvatarRootClass = cva({
|
|
4
|
+
base: {
|
|
5
|
+
display: "inline-flex",
|
|
6
|
+
alignItems: "center",
|
|
7
|
+
justifyContent: "center",
|
|
8
|
+
overflow: "hidden",
|
|
9
|
+
userSelect: "none",
|
|
10
|
+
flexShrink: "0",
|
|
11
|
+
bg: "bg.accentSoft",
|
|
12
|
+
transition: "background-color 160ms cubic-bezier(0.25, 0.1, 0.25, 1)",
|
|
13
|
+
},
|
|
14
|
+
variants: {
|
|
15
|
+
size: {
|
|
16
|
+
sm: { w: "8", h: "8" },
|
|
17
|
+
md: { w: "10", h: "10" },
|
|
18
|
+
lg: { w: "12", h: "12" },
|
|
19
|
+
xl: { w: "14", h: "14" },
|
|
20
|
+
},
|
|
21
|
+
shape: {
|
|
22
|
+
circle: { borderRadius: "full" },
|
|
23
|
+
square: { borderRadius: "lg" },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
defaultVariants: {
|
|
27
|
+
size: "md",
|
|
28
|
+
shape: "circle",
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const uiAvatarImageClass = css({
|
|
33
|
+
w: "full",
|
|
34
|
+
h: "full",
|
|
35
|
+
objectFit: "cover",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const uiAvatarFallbackClass = cva({
|
|
39
|
+
base: {
|
|
40
|
+
fontFamily: "heading",
|
|
41
|
+
fontWeight: "600",
|
|
42
|
+
color: "text.accent",
|
|
43
|
+
lineHeight: "1",
|
|
44
|
+
display: "flex",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
justifyContent: "center",
|
|
47
|
+
w: "full",
|
|
48
|
+
h: "full",
|
|
49
|
+
},
|
|
50
|
+
variants: {
|
|
51
|
+
size: {
|
|
52
|
+
sm: { fontSize: "xs" },
|
|
53
|
+
md: { fontSize: "sm" },
|
|
54
|
+
lg: { fontSize: "md" },
|
|
55
|
+
xl: { fontSize: "lg" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: {
|
|
59
|
+
size: "md",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const uiAvatarIconFallbackClass = cva({
|
|
64
|
+
base: {
|
|
65
|
+
color: "text.muted",
|
|
66
|
+
display: "flex",
|
|
67
|
+
alignItems: "center",
|
|
68
|
+
justifyContent: "center",
|
|
69
|
+
},
|
|
70
|
+
variants: {
|
|
71
|
+
size: {
|
|
72
|
+
sm: {},
|
|
73
|
+
md: {},
|
|
74
|
+
lg: {},
|
|
75
|
+
xl: {},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
defaultVariants: {
|
|
79
|
+
size: "md",
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import UiAvatar from "./UiAvatar.vue";
|
|
4
|
+
|
|
5
|
+
describe("UiAvatar", () => {
|
|
6
|
+
it("has accessible label with full name", () => {
|
|
7
|
+
const wrapper = mount(UiAvatar, {
|
|
8
|
+
props: { name: "Alice Johnson" },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(wrapper.attributes("aria-label")).toBe("Alice Johnson");
|
|
12
|
+
expect(wrapper.attributes("role")).toBe("img");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders image when src provided", () => {
|
|
16
|
+
const wrapper = mount(UiAvatar, {
|
|
17
|
+
props: {
|
|
18
|
+
name: "Alice",
|
|
19
|
+
src: "https://example.com/avatar.jpg",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const img = wrapper.find("img");
|
|
24
|
+
expect(img.exists()).toBe(true);
|
|
25
|
+
expect(img.attributes("src")).toBe("https://example.com/avatar.jpg");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not render image when src is not provided", () => {
|
|
29
|
+
const wrapper = mount(UiAvatar, {
|
|
30
|
+
props: { name: "Alice" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(wrapper.find("img").exists()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders avatar root element", () => {
|
|
37
|
+
const wrapper = mount(UiAvatar, {
|
|
38
|
+
props: { name: "Alice Johnson" },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(wrapper.find("[role='img']").exists()).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|