@aggc/ui 0.8.0 → 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.
@@ -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>
@@ -4,41 +4,58 @@ export const uiModalOverlayClass = css({
4
4
  position: "fixed",
5
5
  inset: "0",
6
6
  bg: "rgba(0, 0, 0, 0.3)",
7
- backdropFilter: "blur(12px)",
7
+ backdropFilter: "blur(8px)",
8
8
  _dark: {
9
9
  bg: "rgba(0, 0, 0, 0.5)",
10
10
  },
11
- display: "flex",
12
- alignItems: "center",
13
- justifyContent: "center",
14
11
  zIndex: "50",
15
- px: "4",
16
12
  overscrollBehavior: "contain",
17
13
  animation: "fadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1)",
18
14
  });
19
15
 
16
+ export const uiModalWrapperClass = css({
17
+ position: "fixed",
18
+ inset: "0",
19
+ display: "flex",
20
+ alignItems: { base: "flex-end", sm: "center" },
21
+ justifyContent: "center",
22
+ zIndex: "51",
23
+ pointerEvents: "none",
24
+ });
25
+
20
26
  export const uiModalContentClass = cva({
21
27
  base: {
22
28
  bg: "bg.menu",
23
- borderRadius: "xl",
24
29
  borderWidth: "1px",
25
30
  borderColor: "border.subtle",
26
31
  p: "0",
27
32
  display: "flex",
28
33
  flexDirection: "column",
29
- maxH: "calc(100dvh - 2rem)",
30
- boxShadow:
31
- "0 16px 48px -8px rgba(0, 0, 0, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.06)",
32
- animation: "modalIn 240ms cubic-bezier(0.16, 1, 0.3, 1)",
33
- position: "fixed",
34
- top: "50%",
35
- left: "50%",
36
- transform: "translate(-50%, -50%)",
37
- zIndex: "51",
34
+ // Mobile: bottom sheet — desktop: centered via wrapper
35
+ position: { base: "fixed", sm: "relative" },
36
+ bottom: { base: "0", sm: "auto" },
37
+ left: { base: "0", sm: "auto" },
38
+ right: { base: "0", sm: "auto" },
39
+ pointerEvents: "auto",
40
+ borderTopLeftRadius: "xl",
41
+ borderTopRightRadius: "xl",
42
+ borderBottomLeftRadius: { base: "0", sm: "xl" },
43
+ borderBottomRightRadius: { base: "0", sm: "xl" },
44
+ maxH: { base: "90dvh", sm: "calc(100dvh - 2rem)" },
45
+ animation: {
46
+ base: "modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1) backwards",
47
+ sm: "modalIn 240ms cubic-bezier(0.16, 1, 0.3, 1) backwards",
48
+ },
49
+ boxShadow: {
50
+ base: "0 -8px 32px -4px rgba(0, 0, 0, 0.12), 0 -2px 8px -2px rgba(0, 0, 0, 0.06)",
51
+ sm: "0 16px 48px -8px rgba(0, 0, 0, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.06)",
52
+ },
38
53
  _dark: {
39
- boxShadow:
40
- "0 16px 48px -8px rgba(0, 0, 0, 0.56), 0 4px 16px -4px rgba(0, 0, 0, 0.32)",
41
54
  borderColor: "border.default",
55
+ boxShadow: {
56
+ base: "0 -8px 32px -4px rgba(0, 0, 0, 0.48), 0 -2px 8px -2px rgba(0, 0, 0, 0.24)",
57
+ sm: "0 16px 48px -8px rgba(0, 0, 0, 0.56), 0 4px 16px -4px rgba(0, 0, 0, 0.32)",
58
+ },
42
59
  },
43
60
  _focusVisible: {
44
61
  outline: "none",
@@ -46,9 +63,9 @@ export const uiModalContentClass = cva({
46
63
  },
47
64
  variants: {
48
65
  size: {
49
- sm: { w: "380px" },
50
- md: { w: "480px" },
51
- lg: { w: "640px" },
66
+ sm: { w: { base: "full", sm: "380px" } },
67
+ md: { w: { base: "full", sm: "480px" } },
68
+ lg: { w: { base: "full", sm: "640px" } },
52
69
  },
53
70
  },
54
71
  defaultVariants: {
@@ -60,8 +77,8 @@ export const uiModalHeaderClass = css({
60
77
  display: "flex",
61
78
  justifyContent: "space-between",
62
79
  alignItems: "center",
63
- p: "6",
64
- pb: "4",
80
+ p: { base: "4", sm: "6" },
81
+ pb: { base: "3", sm: "4" },
65
82
  });
66
83
 
67
84
  export const uiModalHeaderContentClass = css({
@@ -109,8 +126,8 @@ export const uiModalCloseClass = css({
109
126
  });
110
127
 
111
128
  export const uiModalBodyClass = css({
112
- px: "6",
113
- pb: "6",
129
+ px: { base: "4", sm: "6" },
130
+ pb: { base: "4", sm: "6" },
114
131
  overflowY: "auto",
115
132
  overscrollBehavior: "contain",
116
133
  });
@@ -119,7 +136,7 @@ export const uiModalActionsClass = css({
119
136
  display: "flex",
120
137
  justifyContent: "flex-end",
121
138
  gap: "3",
122
- p: "6",
139
+ p: { base: "4", sm: "6" },
123
140
  pt: "3",
124
141
  borderTopWidth: "1px",
125
142
  borderTopColor: "border.soft",