@leavepulse/ui 0.36.1 → 0.37.1
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/assets/{LpCommandPalette-BERCHiev.css → LpCommandPalette-B-g1fa8r.css} +4 -4
- package/dist/assets/{LpTable-DfSwPCJ-.css → LpTable-BbOFmZfc.css} +9 -9
- package/dist/components/LpCommandPalette.vue.js +14 -30
- package/dist/components/LpTable.vue.d.ts +41 -4
- package/dist/components/LpTable.vue.js +235 -139
- package/dist/components/modalDepth.js +1 -1
- package/dist/composables/useAsyncTask.d.ts +18 -0
- package/dist/composables/useAsyncTask.js +36 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/components/LpCommandPalette.vue +14 -35
- package/src/components/LpTable.vue +181 -17
- package/src/components/modalDepth.ts +8 -1
- package/src/composables/useAsyncTask.ts +50 -0
- package/src/index.ts +2 -1
|
@@ -34,9 +34,10 @@ import {
|
|
|
34
34
|
DialogRoot,
|
|
35
35
|
DialogTitle,
|
|
36
36
|
} from "reka-ui"
|
|
37
|
-
import { computed, nextTick,
|
|
37
|
+
import { computed, nextTick, ref, shallowRef, watch } from "vue"
|
|
38
38
|
import LpIcon from "./LpIcon.vue"
|
|
39
39
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
40
|
+
import { useAsyncTask } from "../composables/useAsyncTask"
|
|
40
41
|
import { useHotkeys } from "../composables/useHotkeys"
|
|
41
42
|
|
|
42
43
|
export interface Command {
|
|
@@ -218,19 +219,13 @@ function back(): boolean {
|
|
|
218
219
|
// ── async results ────────────────────────────────────────────
|
|
219
220
|
/*
|
|
220
221
|
* One in-flight request, shared by `search` at the root and by a fetching
|
|
221
|
-
* `children` inside a mode — they differ only in which function to call.
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* overwriting a fresher one, and a response arriving after the palette moved
|
|
225
|
-
* on (closed, or into another mode). The token guards both — the abort signal
|
|
226
|
-
* only asks the consumer to stop, it cannot promise they did.
|
|
222
|
+
* `children` inside a mode — they differ only in which function to call. The
|
|
223
|
+
* debounce, the abort and the stale-response guard live in `useAsyncTask`,
|
|
224
|
+
* which the table's `load` uses too.
|
|
227
225
|
*/
|
|
228
226
|
const found = shallowRef<Command[]>([])
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
232
|
-
let controller: AbortController | undefined
|
|
233
|
-
let token = 0
|
|
227
|
+
const task = useAsyncTask<Command[]>()
|
|
228
|
+
const searching = task.pending
|
|
234
229
|
|
|
235
230
|
function fetcher(): ((q: string, signal: AbortSignal) => Command[] | Promise<Command[]>) | null {
|
|
236
231
|
const current = mode.value
|
|
@@ -243,12 +238,7 @@ function fetcher(): ((q: string, signal: AbortSignal) => Command[] | Promise<Com
|
|
|
243
238
|
}
|
|
244
239
|
|
|
245
240
|
function cancel() {
|
|
246
|
-
|
|
247
|
-
timer = undefined
|
|
248
|
-
controller?.abort()
|
|
249
|
-
controller = undefined
|
|
250
|
-
token += 1
|
|
251
|
-
searching.value = false
|
|
241
|
+
task.cancel()
|
|
252
242
|
}
|
|
253
243
|
|
|
254
244
|
function refresh() {
|
|
@@ -262,25 +252,14 @@ function refresh() {
|
|
|
262
252
|
// not — ⌘K would fire a request at every open.
|
|
263
253
|
const inMode = mode.value !== null
|
|
264
254
|
if (!inMode && q.length < Math.max(1, props.minChars)) return
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (mine !== token) return
|
|
272
|
-
found.value = hits
|
|
273
|
-
} catch {
|
|
274
|
-
// Abort or a failing provider: the static commands still stand.
|
|
275
|
-
if (mine === token) found.value = []
|
|
276
|
-
} finally {
|
|
277
|
-
if (mine === token) searching.value = false
|
|
278
|
-
}
|
|
279
|
-
}, props.debounce)
|
|
255
|
+
// Abort or a failing provider: the static commands still stand.
|
|
256
|
+
task.run((signal) => run(q, signal), {
|
|
257
|
+
delay: props.debounce,
|
|
258
|
+
apply: (hits) => (found.value = hits),
|
|
259
|
+
fail: () => (found.value = []),
|
|
260
|
+
})
|
|
280
261
|
}
|
|
281
262
|
|
|
282
|
-
onBeforeUnmount(cancel)
|
|
283
|
-
|
|
284
263
|
// ── filtering + scoring ──────────────────────────────────────
|
|
285
264
|
/*
|
|
286
265
|
* What the query filters: the open mode's own children, or the root commands.
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Attributes go to the scroll area, not the wrapper: a height or max-height set
|
|
3
|
+
// by the page has always landed there, and the sticky header depends on it.
|
|
4
|
+
export default { inheritAttrs: false }
|
|
5
|
+
</script>
|
|
6
|
+
|
|
1
7
|
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
|
2
|
-
import { computed, onBeforeUnmount, ref, watch } from "vue"
|
|
8
|
+
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue"
|
|
3
9
|
import LpCheckbox from "./LpCheckbox.vue"
|
|
4
10
|
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
5
11
|
import LpIcon from "./LpIcon.vue"
|
|
12
|
+
import LpPagination from "./LpPagination.vue"
|
|
6
13
|
import LpScrollArea from "./LpScrollArea.vue"
|
|
14
|
+
import { useAsyncTask } from "../composables/useAsyncTask"
|
|
7
15
|
import { useRovingFocus } from "../composables/useRovingFocus"
|
|
8
16
|
|
|
9
17
|
export interface TableColumn<Row> {
|
|
@@ -35,12 +43,49 @@ export interface SortState {
|
|
|
35
43
|
dir: "asc" | "desc"
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* What a server-backed table asks for. `hidden` mirrors the header filter: the
|
|
48
|
+
* values unticked per column, so a value the server has never sent before shows
|
|
49
|
+
* up instead of being silently excluded.
|
|
50
|
+
*/
|
|
51
|
+
export interface TableQuery {
|
|
52
|
+
page: number
|
|
53
|
+
pageSize: number
|
|
54
|
+
sort: SortState | null
|
|
55
|
+
hidden: Record<string, unknown[]>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One page of a server-backed table. `facets` are the distinct values of the
|
|
60
|
+
* filterable columns across the whole result — one page cannot know them, and a
|
|
61
|
+
* filter built from twenty-five rows would offer whatever happened to be on it.
|
|
62
|
+
*/
|
|
63
|
+
export interface TablePage<Row> {
|
|
64
|
+
rows: Row[]
|
|
65
|
+
total: number
|
|
66
|
+
facets?: Record<string, unknown[]>
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
type RowKey = string | number
|
|
39
70
|
|
|
40
71
|
const props = withDefaults(
|
|
41
72
|
defineProps<{
|
|
42
73
|
columns: TableColumn<T>[]
|
|
43
|
-
rows
|
|
74
|
+
/** The rows, for a table that holds its data. Ignored when `load` is set. */
|
|
75
|
+
rows?: T[]
|
|
76
|
+
/**
|
|
77
|
+
* Fetch the rows instead of being handed them: paging, sorting and the
|
|
78
|
+
* header filters then happen on the server. Debounced and abortable, the
|
|
79
|
+
* same way as the command palette's `search` — a stale page never lands
|
|
80
|
+
* over a fresher one.
|
|
81
|
+
*/
|
|
82
|
+
load?: (query: TableQuery, signal: AbortSignal) => TablePage<T> | Promise<TablePage<T>>
|
|
83
|
+
/** Rows per page with `load`. */
|
|
84
|
+
pageSize?: number
|
|
85
|
+
/** Wait after a sort or filter change before `load` runs. */
|
|
86
|
+
debounce?: number
|
|
87
|
+
loadingLabel?: string
|
|
88
|
+
errorLabel?: string
|
|
44
89
|
/** Stable id per row — required for selection; falls back to index otherwise. */
|
|
45
90
|
rowKey?: keyof T | ((row: T) => RowKey)
|
|
46
91
|
emptyLabel?: string
|
|
@@ -68,6 +113,11 @@ const props = withDefaults(
|
|
|
68
113
|
columnsLabel?: string
|
|
69
114
|
}>(),
|
|
70
115
|
{
|
|
116
|
+
rows: () => [],
|
|
117
|
+
pageSize: 25,
|
|
118
|
+
debounce: 150,
|
|
119
|
+
loadingLabel: "Loading…",
|
|
120
|
+
errorLabel: "Could not load",
|
|
71
121
|
emptyLabel: "Nothing here yet",
|
|
72
122
|
emptyIcon: "lucide:inbox",
|
|
73
123
|
selectable: false,
|
|
@@ -144,6 +194,27 @@ const contextMenu = computed<ContextMenuItemDef[]>(() => {
|
|
|
144
194
|
const ownSort = ref<SortState | null>(null)
|
|
145
195
|
const activeSort = computed(() => props.sort ?? ownSort.value)
|
|
146
196
|
|
|
197
|
+
// ── server-backed rows ───────────────────────────────────────
|
|
198
|
+
const loaded = shallowRef<T[]>([])
|
|
199
|
+
const total = ref(0)
|
|
200
|
+
const facets = ref<Record<string, unknown[]>>({})
|
|
201
|
+
const failed = ref(false)
|
|
202
|
+
const page = ref(1)
|
|
203
|
+
const task = useAsyncTask<TablePage<T>>()
|
|
204
|
+
const loading = task.pending
|
|
205
|
+
|
|
206
|
+
const sourceRows = computed<T[]>(() => (props.load ? loaded.value : props.rows))
|
|
207
|
+
|
|
208
|
+
// The tallest page shown so far, held as a floor: the last page is usually
|
|
209
|
+
// short, and without it the pager below jumps up the moment you reach it.
|
|
210
|
+
const tableEl = ref<HTMLElement | null>(null)
|
|
211
|
+
const floor = ref(0)
|
|
212
|
+
async function holdHeight() {
|
|
213
|
+
await nextTick()
|
|
214
|
+
const height = tableEl.value?.offsetHeight ?? 0
|
|
215
|
+
if (height > floor.value) floor.value = height
|
|
216
|
+
}
|
|
217
|
+
|
|
147
218
|
/*
|
|
148
219
|
* ── hiding values, and hiding columns ────────────────────────────────────────
|
|
149
220
|
*
|
|
@@ -158,6 +229,23 @@ const activeSort = computed(() => props.sort ?? ownSort.value)
|
|
|
158
229
|
* never saw.
|
|
159
230
|
*/
|
|
160
231
|
const hiddenValues = ref<Record<string, Set<unknown>>>({})
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* Rows animate only when the table itself reshuffles them — a header filter or
|
|
235
|
+
* a sort, a change the eye should follow. A parent replacing `rows` wholesale
|
|
236
|
+
* (the next page, a reload) swaps instantly: animated, the old page would fade
|
|
237
|
+
* out beside the new one, and the table would show both lists for a moment.
|
|
238
|
+
*/
|
|
239
|
+
const animating = ref(false)
|
|
240
|
+
let motionTimer: ReturnType<typeof setTimeout> | undefined
|
|
241
|
+
function withMotion(change: () => void) {
|
|
242
|
+
if (!props.load) {
|
|
243
|
+
animating.value = true
|
|
244
|
+
if (motionTimer) clearTimeout(motionTimer)
|
|
245
|
+
motionTimer = setTimeout(() => (animating.value = false), 600)
|
|
246
|
+
}
|
|
247
|
+
change()
|
|
248
|
+
}
|
|
161
249
|
const hiddenColumns = ref<Set<string>>(new Set())
|
|
162
250
|
|
|
163
251
|
const visibleColumns = computed(() =>
|
|
@@ -166,8 +254,9 @@ const visibleColumns = computed(() =>
|
|
|
166
254
|
|
|
167
255
|
/** The distinct values a filterable column holds, in first-seen order. */
|
|
168
256
|
function valuesOf(col: TableColumn<T>): unknown[] {
|
|
257
|
+
if (props.load && facets.value[col.key]) return facets.value[col.key]
|
|
169
258
|
const seen: unknown[] = []
|
|
170
|
-
for (const row of
|
|
259
|
+
for (const row of sourceRows.value) {
|
|
171
260
|
const v = row[col.key]
|
|
172
261
|
if (!seen.includes(v)) seen.push(v)
|
|
173
262
|
}
|
|
@@ -179,6 +268,10 @@ function isValueHidden(key: string, value: unknown): boolean {
|
|
|
179
268
|
}
|
|
180
269
|
|
|
181
270
|
function toggleValue(key: string, value: unknown) {
|
|
271
|
+
withMotion(() => applyToggle(key, value))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function applyToggle(key: string, value: unknown) {
|
|
182
275
|
const next = new Set(hiddenValues.value[key] ?? [])
|
|
183
276
|
if (next.has(value)) next.delete(value)
|
|
184
277
|
else next.add(value)
|
|
@@ -186,7 +279,7 @@ function toggleValue(key: string, value: unknown) {
|
|
|
186
279
|
}
|
|
187
280
|
|
|
188
281
|
function showAllValues(key: string) {
|
|
189
|
-
hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }
|
|
282
|
+
withMotion(() => (hiddenValues.value = { ...hiddenValues.value, [key]: new Set() }))
|
|
190
283
|
}
|
|
191
284
|
|
|
192
285
|
function toggleColumn(key: string) {
|
|
@@ -198,6 +291,7 @@ function toggleColumn(key: string) {
|
|
|
198
291
|
|
|
199
292
|
/** Rows left once the per-column value filters have been applied. */
|
|
200
293
|
const filteredRows = computed<T[]>(() => {
|
|
294
|
+
if (props.load) return sourceRows.value
|
|
201
295
|
const active = Object.entries(hiddenValues.value).filter(([, set]) => set.size)
|
|
202
296
|
if (!active.length) return props.rows
|
|
203
297
|
return props.rows.filter((row) =>
|
|
@@ -208,7 +302,7 @@ const filteredRows = computed<T[]>(() => {
|
|
|
208
302
|
// We compare by raw cell value with a stable-ish coerce.
|
|
209
303
|
const displayRows = computed<T[]>(() => {
|
|
210
304
|
const s = activeSort.value
|
|
211
|
-
if (!s) return filteredRows.value
|
|
305
|
+
if (!s || props.load) return filteredRows.value
|
|
212
306
|
const col = props.columns.find((c) => c.key === s.key)
|
|
213
307
|
if (!col?.sortable) return filteredRows.value
|
|
214
308
|
const factor = s.dir === "asc" ? 1 : -1
|
|
@@ -233,8 +327,10 @@ function toggleSort(col: TableColumn<T>) {
|
|
|
233
327
|
else next = null
|
|
234
328
|
// Kept locally as well as emitted: a parent that binds v-model overwrites
|
|
235
329
|
// this on the way back, and one that does not still gets a table that sorts.
|
|
236
|
-
|
|
237
|
-
|
|
330
|
+
withMotion(() => {
|
|
331
|
+
ownSort.value = next
|
|
332
|
+
emit("update:sort", next)
|
|
333
|
+
})
|
|
238
334
|
}
|
|
239
335
|
|
|
240
336
|
function sortIcon(col: TableColumn<T>): string {
|
|
@@ -255,10 +351,11 @@ function sortIcon(col: TableColumn<T>): string {
|
|
|
255
351
|
*/
|
|
256
352
|
function headerMenu(col: TableColumn<T>): ContextMenuItemDef[] {
|
|
257
353
|
const s = activeSort.value
|
|
258
|
-
const set = (next: SortState | null) => () =>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
354
|
+
const set = (next: SortState | null) => () =>
|
|
355
|
+
withMotion(() => {
|
|
356
|
+
ownSort.value = next
|
|
357
|
+
emit("update:sort", next)
|
|
358
|
+
})
|
|
262
359
|
|
|
263
360
|
const items: ContextMenuItemDef[] = []
|
|
264
361
|
|
|
@@ -338,9 +435,45 @@ function tableMenu(): ContextMenuItemDef[] {
|
|
|
338
435
|
]
|
|
339
436
|
}
|
|
340
437
|
|
|
438
|
+
function fetchPage(delay = 0) {
|
|
439
|
+
const load = props.load
|
|
440
|
+
if (!load) return
|
|
441
|
+
const hidden = Object.fromEntries(
|
|
442
|
+
Object.entries(hiddenValues.value)
|
|
443
|
+
.filter(([, set]) => set.size)
|
|
444
|
+
.map(([key, set]) => [key, [...set]]),
|
|
445
|
+
)
|
|
446
|
+
const query: TableQuery = { page: page.value, pageSize: props.pageSize, sort: activeSort.value, hidden }
|
|
447
|
+
task.run((signal) => load(query, signal), {
|
|
448
|
+
delay,
|
|
449
|
+
apply: (result) => {
|
|
450
|
+
loaded.value = result.rows
|
|
451
|
+
total.value = result.total
|
|
452
|
+
facets.value = result.facets ?? {}
|
|
453
|
+
failed.value = false
|
|
454
|
+
holdHeight()
|
|
455
|
+
},
|
|
456
|
+
fail: () => {
|
|
457
|
+
failed.value = true
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// A new sort or filter starts from the first page: page 4 of a different
|
|
463
|
+
// question is a page of nothing in particular.
|
|
464
|
+
watch([activeSort, hiddenValues], () => {
|
|
465
|
+
if (!props.load) return
|
|
466
|
+
if (page.value !== 1) page.value = 1
|
|
467
|
+
else fetchPage(props.debounce)
|
|
468
|
+
})
|
|
469
|
+
watch(page, () => fetchPage())
|
|
470
|
+
watch(() => props.load, () => fetchPage(), { immediate: true })
|
|
471
|
+
|
|
472
|
+
defineExpose({ reload: () => fetchPage() })
|
|
473
|
+
|
|
341
474
|
// ── selection ────────────────────────────────────────────────
|
|
342
475
|
const selectedSet = computed(() => new Set(props.selected))
|
|
343
|
-
const allKeys = computed(() =>
|
|
476
|
+
const allKeys = computed(() => sourceRows.value.map((r, i) => keyFor(r, i)))
|
|
344
477
|
const allChecked = computed(
|
|
345
478
|
() => allKeys.value.length > 0 && allKeys.value.every((k) => selectedSet.value.has(k)),
|
|
346
479
|
)
|
|
@@ -431,7 +564,10 @@ watch(
|
|
|
431
564
|
},
|
|
432
565
|
{ immediate: true, flush: "post" },
|
|
433
566
|
)
|
|
434
|
-
onBeforeUnmount(() =>
|
|
567
|
+
onBeforeUnmount(() => {
|
|
568
|
+
ro?.disconnect()
|
|
569
|
+
if (motionTimer) clearTimeout(motionTimer)
|
|
570
|
+
})
|
|
435
571
|
|
|
436
572
|
const barInsetTop = computed(() =>
|
|
437
573
|
props.stickyHeader && headHeight.value ? `${headHeight.value}px` : undefined,
|
|
@@ -441,16 +577,19 @@ const barInsetTop = computed(() =>
|
|
|
441
577
|
<template>
|
|
442
578
|
<!-- No top fade: a sticky header sits in the masked band and would be dimmed
|
|
443
579
|
by it. The bottom edge is left crisp with it for symmetry. -->
|
|
580
|
+
<div class="flex flex-col gap-3">
|
|
444
581
|
<LpScrollArea
|
|
582
|
+
v-bind="$attrs"
|
|
445
583
|
:fade="false"
|
|
446
584
|
class="rounded-card border border-line"
|
|
585
|
+
:style="load && floor ? { minHeight: `${floor + 2}px` } : undefined"
|
|
447
586
|
:bar-inset-top="barInsetTop"
|
|
448
587
|
>
|
|
449
588
|
<!-- The table-wide menu, which is what a right-click on empty header space
|
|
450
589
|
reaches: rows and headers carry their own and take the event first, so
|
|
451
590
|
this is the fallback rather than a competitor. -->
|
|
452
591
|
<LpContextMenu :items="contextMenu" always>
|
|
453
|
-
<table class="w-full border-collapse text-sm">
|
|
592
|
+
<table ref="tableEl" class="w-full border-collapse text-sm">
|
|
454
593
|
<!-- The background belongs on `thead` itself, not only on its row:
|
|
455
594
|
`border-collapse` collapses the borders, and with a sticky header the
|
|
456
595
|
rows show through that seam — a band of body text rides across the
|
|
@@ -488,7 +627,7 @@ const barInsetTop = computed(() =>
|
|
|
488
627
|
Right-clicking names the directions instead of making you cycle
|
|
489
628
|
through them: getting back to unsorted from ascending is two
|
|
490
629
|
more clicks otherwise. -->
|
|
491
|
-
<LpContextMenu v-if="col.sortable" :items="headerMenu(col)">
|
|
630
|
+
<LpContextMenu v-if="col.sortable || col.filterable" :items="headerMenu(col)">
|
|
492
631
|
<button
|
|
493
632
|
type="button"
|
|
494
633
|
class="group/sort -mx-1 inline-flex items-center gap-1 rounded px-1 outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
@@ -500,6 +639,14 @@ const barInsetTop = computed(() =>
|
|
|
500
639
|
>
|
|
501
640
|
{{ col.label }}
|
|
502
641
|
<LpIcon
|
|
642
|
+
v-if="!col.sortable"
|
|
643
|
+
name="lucide:filter"
|
|
644
|
+
:size="12"
|
|
645
|
+
class="shrink-0"
|
|
646
|
+
:class="hiddenValues[col.key]?.size ? 'text-brand opacity-100' : 'opacity-40 group-hover/sort:opacity-70'"
|
|
647
|
+
/>
|
|
648
|
+
<LpIcon
|
|
649
|
+
v-else
|
|
503
650
|
:name="sortIcon(col)"
|
|
504
651
|
:size="13"
|
|
505
652
|
class="shrink-0 transition-[transform,opacity] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)]"
|
|
@@ -524,15 +671,24 @@ const barInsetTop = computed(() =>
|
|
|
524
671
|
frames read as the table being redrawn, and the one that stayed is
|
|
525
672
|
impossible to keep track of. `move` is what carries the survivors to
|
|
526
673
|
their new position instead of teleporting them. -->
|
|
674
|
+
<!-- Animated only while the table reshuffles its own rows — see
|
|
675
|
+
`withMotion`. -->
|
|
527
676
|
<TransitionGroup
|
|
528
677
|
tag="tbody"
|
|
529
678
|
name="lp-row"
|
|
679
|
+
:css="animating"
|
|
680
|
+
:class="loading && displayRows.length ? 'opacity-60 transition-opacity' : 'transition-opacity'"
|
|
530
681
|
:ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
|
|
531
682
|
>
|
|
532
683
|
<tr v-if="displayRows.length === 0" key="lp-table-empty" class="lp-row-empty">
|
|
533
684
|
<td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
|
|
534
|
-
<LpIcon
|
|
535
|
-
|
|
685
|
+
<LpIcon
|
|
686
|
+
:name="loading ? 'lucide:loader-circle' : failed ? 'lucide:circle-alert' : emptyIcon"
|
|
687
|
+
:size="22"
|
|
688
|
+
class="mx-auto mb-2 opacity-60"
|
|
689
|
+
:class="loading ? 'animate-spin' : ''"
|
|
690
|
+
/>
|
|
691
|
+
<div>{{ loading ? loadingLabel : failed ? errorLabel : emptyLabel }}</div>
|
|
536
692
|
</td>
|
|
537
693
|
</tr>
|
|
538
694
|
<!-- A plain <tr>, because TransitionGroup animates element children and
|
|
@@ -569,6 +725,14 @@ const barInsetTop = computed(() =>
|
|
|
569
725
|
</table>
|
|
570
726
|
</LpContextMenu>
|
|
571
727
|
</LpScrollArea>
|
|
728
|
+
<LpPagination
|
|
729
|
+
v-if="load && total > pageSize"
|
|
730
|
+
v-model:page="page"
|
|
731
|
+
:total="total"
|
|
732
|
+
:page-size="pageSize"
|
|
733
|
+
:disabled="loading"
|
|
734
|
+
/>
|
|
735
|
+
</div>
|
|
572
736
|
</template>
|
|
573
737
|
|
|
574
738
|
<style scoped>
|
|
@@ -26,8 +26,15 @@ import { computed, ref, type ComputedRef } from "vue"
|
|
|
26
26
|
* is 100 and --z-modal 110, so a gap of 2 left the upper scrim under the lower
|
|
27
27
|
* PANEL — it darkened that panel and could not blur it, a backdrop-filter
|
|
28
28
|
* having nothing of it underneath to work on.
|
|
29
|
+
*
|
|
30
|
+
* Clearing it means strictly more than the 10 between the two rungs. A step of
|
|
31
|
+
* exactly 10 put the lower panel at 100, level with the upper scrim, and a tie
|
|
32
|
+
* in z-index is settled by DOM order. Siblings in one component happened to
|
|
33
|
+
* come out right, but a dialog mounted once at the app root — opened from a
|
|
34
|
+
* page dialog that teleported in later — lost the tie, and its scrim darkened
|
|
35
|
+
* the page behind the first dialog instead of the first dialog itself.
|
|
29
36
|
*/
|
|
30
|
-
const STEP =
|
|
37
|
+
const STEP = 20
|
|
31
38
|
|
|
32
39
|
/** Levels currently claimed, oldest first. */
|
|
33
40
|
const stack = ref<number[]>([])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getCurrentScope, onScopeDispose, ref } from "vue"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One debounced, abortable request at a time — the shape every "fetch on input"
|
|
5
|
+
* control in the kit needs: the command palette's `search`, the table's `load`.
|
|
6
|
+
*
|
|
7
|
+
* Two races are lost silently without it: a stale response overwriting a
|
|
8
|
+
* fresher one, and a response landing after the control moved on. The token
|
|
9
|
+
* guards both — the abort signal only asks the consumer to stop, it cannot
|
|
10
|
+
* promise they did.
|
|
11
|
+
*/
|
|
12
|
+
export function useAsyncTask<T>() {
|
|
13
|
+
const pending = ref(false)
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
15
|
+
let controller: AbortController | undefined
|
|
16
|
+
let token = 0
|
|
17
|
+
|
|
18
|
+
function cancel() {
|
|
19
|
+
if (timer) clearTimeout(timer)
|
|
20
|
+
timer = undefined
|
|
21
|
+
controller?.abort()
|
|
22
|
+
controller = undefined
|
|
23
|
+
token += 1
|
|
24
|
+
pending.value = false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(
|
|
28
|
+
task: (signal: AbortSignal) => T | Promise<T>,
|
|
29
|
+
options: { delay?: number; apply: (value: T) => void; fail?: (error: unknown) => void },
|
|
30
|
+
) {
|
|
31
|
+
cancel()
|
|
32
|
+
pending.value = true
|
|
33
|
+
const mine = ++token
|
|
34
|
+
timer = setTimeout(async () => {
|
|
35
|
+
controller = new AbortController()
|
|
36
|
+
try {
|
|
37
|
+
const value = await task(controller.signal)
|
|
38
|
+
if (mine === token) options.apply(value)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (mine === token) options.fail?.(error)
|
|
41
|
+
} finally {
|
|
42
|
+
if (mine === token) pending.value = false
|
|
43
|
+
}
|
|
44
|
+
}, options.delay ?? 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (getCurrentScope()) onScopeDispose(cancel)
|
|
48
|
+
|
|
49
|
+
return { pending, run, cancel }
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -151,7 +151,7 @@ export { default as LpStepper } from "./components/LpStepper.vue"
|
|
|
151
151
|
export type { Step } from "./components/LpStepper.vue"
|
|
152
152
|
export { default as LpSwitch } from "./components/LpSwitch.vue"
|
|
153
153
|
export { default as LpTable } from "./components/LpTable.vue"
|
|
154
|
-
export type { SortState, TableColumn } from "./components/LpTable.vue"
|
|
154
|
+
export type { SortState, TableColumn, TablePage, TableQuery } from "./components/LpTable.vue"
|
|
155
155
|
export { default as LpTableOfContents } from "./components/LpTableOfContents.vue"
|
|
156
156
|
export type { TocLink } from "./components/LpTableOfContents.vue"
|
|
157
157
|
export { default as LpTabs } from "./components/LpTabs.vue"
|
|
@@ -235,6 +235,7 @@ export type { UseInputFilterOptions } from "./composables/useInputFilter"
|
|
|
235
235
|
export { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing"
|
|
236
236
|
export { useReveal } from "./composables/useReveal"
|
|
237
237
|
export type { UseReveal, UseRevealOptions } from "./composables/useReveal"
|
|
238
|
+
export { useAsyncTask } from "./composables/useAsyncTask"
|
|
238
239
|
export { useRovingFocus } from "./composables/useRovingFocus"
|
|
239
240
|
export type { RovingItem, UseRovingFocusOptions } from "./composables/useRovingFocus"
|
|
240
241
|
export { useFormDraft } from "./composables/useFormDraft"
|