@leavepulse/ui 0.36.1 → 0.37.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.
@@ -34,9 +34,10 @@ import {
34
34
  DialogRoot,
35
35
  DialogTitle,
36
36
  } from "reka-ui"
37
- import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue"
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
- * Two races to lose here, and both are lost silently: a stale response
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 searching = ref(false)
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
- if (timer) clearTimeout(timer)
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
- searching.value = true
266
- const mine = ++token
267
- timer = setTimeout(async () => {
268
- controller = new AbortController()
269
- try {
270
- const hits = await run(q, controller.signal)
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: T[]
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 props.rows) {
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
- ownSort.value = next
237
- emit("update:sort", next)
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
- ownSort.value = next
260
- emit("update:sort", next)
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(() => props.rows.map((r, i) => keyFor(r, i)))
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(() => ro?.disconnect())
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 :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
535
- <div>{{ emptyLabel }}</div>
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>
@@ -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"