@leavepulse/ui 0.94.1 → 0.95.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.
Files changed (92) hide show
  1. package/dist/assets/{LpTable-DxnthVz4.css → LpTable-BJjNEvgJ.css} +57 -30
  2. package/dist/chunks/{LpAddressInput-BCqR0zmv.js → LpAddressInput-C5VIgRp0.js} +1 -1
  3. package/dist/chunks/{LpAppShell-DlpQierx.js → LpAppShell-C4b3ySa_.js} +3 -3
  4. package/dist/chunks/{LpCodeBlock-BgWcg2mk.js → LpCodeBlock-DTU40x8P.js} +1 -1
  5. package/dist/chunks/{LpCodeDisplay-DBi1YPLp.js → LpCodeDisplay-F2hjLb0X.js} +2 -2
  6. package/dist/chunks/{LpConfirmDialog-y6k0ryWR.js → LpConfirmDialog-DrHcIxRn.js} +1 -1
  7. package/dist/chunks/{LpConfirmHost-D-X0tDym.js → LpConfirmHost-D0Cd8FA_.js} +1 -1
  8. package/dist/chunks/{LpCopyButton-BGzw95Xc.js → LpCopyButton-CiqUS4SX.js} +1 -1
  9. package/dist/chunks/{LpDataList-B56Gu6Yt.js → LpDataList-Cit9fOSp.js} +2 -2
  10. package/dist/chunks/{LpDrawer-BkWjFdV0.js → LpDrawer-BHlqi62K.js} +1 -1
  11. package/dist/chunks/{LpFileTree-BN_zybBN.js → LpFileTree-gVjF_b6r.js} +1 -1
  12. package/dist/chunks/{LpInspector-oTfkOdYd.js → LpInspector-DSL1lINx.js} +2 -2
  13. package/dist/chunks/{LpLightbox-DPgz7IsT.js → LpLightbox--EwvQGe-.js} +1 -1
  14. package/dist/chunks/{LpLogViewer-BuIzr_6h.js → LpLogViewer-DSq9e8mT.js} +1 -1
  15. package/dist/chunks/{LpMediaGallery-CTejKF2-.js → LpMediaGallery-DlYJf3DS.js} +1 -1
  16. package/dist/chunks/{LpModal-D1E7rQzw.js → LpModal-COMHrBKL.js} +1 -1
  17. package/dist/chunks/{LpModalNav-BFjiHWbr.js → LpModalNav-Cj02cfhL.js} +2 -2
  18. package/dist/chunks/{LpNotificationBell-KRgdRZs-.js → LpNotificationBell-DNGijtmW.js} +1 -1
  19. package/dist/chunks/{LpNotificationFeed-CMGYU8c-.js → LpNotificationFeed-BoovbtfL.js} +3 -3
  20. package/dist/chunks/{LpPageScrollbar-C9sTeU-2.js → LpPageScrollbar-BniFeL2U.js} +5 -1
  21. package/dist/chunks/{LpPaletteTheme-CLwO-SDf.js → LpPaletteTheme-_FwkJO2o.js} +1 -1
  22. package/dist/chunks/{LpPasswordInput-LPVI7D3v.js → LpPasswordInput-BBDz2zyU.js} +1 -1
  23. package/dist/chunks/{LpPie-CsiBXe-e.js → LpPie-CtC4XdhC.js} +1 -1
  24. package/dist/chunks/{LpPipeline-DVcoSs--.js → LpPipeline-aCALaUY8.js} +1 -1
  25. package/dist/chunks/{LpRelativeTime-CN5bWD73.js → LpRelativeTime-9MHlbq6F.js} +1 -1
  26. package/dist/chunks/{LpScrollArea-CqvwfZs2.js → LpScrollArea-B_1KZOSS.js} +11 -1
  27. package/dist/chunks/{LpSecretField-BwrZ4TfT.js → LpSecretField-C9vwMFQ6.js} +4 -4
  28. package/dist/chunks/{LpSidebar-BKhVDsMt.js → LpSidebar-DPri9uyz.js} +3 -3
  29. package/dist/chunks/{LpSidebarNav-DbCap8z1.js → LpSidebarNav-CV1rU6La.js} +1 -1
  30. package/dist/chunks/{LpThemeStudio-BPPctXgd.js → LpThemeStudio-yiEdFJQL.js} +1 -1
  31. package/dist/chunks/{LpThemeSwitcher-B3c44xFH.js → LpThemeSwitcher-DKkCFaFk.js} +1 -1
  32. package/dist/chunks/{LpToaster-Cdx1tnHB.js → LpToaster-C7hHkGOA.js} +1 -1
  33. package/dist/chunks/LpTooltip-Bt1ZFViJ.js +333 -0
  34. package/dist/chunks/{LpTruncate-C_C8eiIE.js → LpTruncate-DEHWL1OA.js} +2 -2
  35. package/dist/chunks/{LpUptimeBar-Dt6SY_-m.js → LpUptimeBar-CXgzU5ym.js} +1 -1
  36. package/dist/chunks/{LpWorkspaceSwitcher-DCIiOQt_.js → LpWorkspaceSwitcher-CrUVSI7x.js} +1 -1
  37. package/dist/chunks/{RevealToggle-BsYxY6Oz.js → RevealToggle-D9Vs88kM.js} +1 -1
  38. package/dist/components/LpAddressInput.vue.js +1 -1
  39. package/dist/components/LpAppShell.vue.js +1 -1
  40. package/dist/components/LpCodeBlock.vue.js +1 -1
  41. package/dist/components/LpCodeDisplay.vue.js +1 -1
  42. package/dist/components/LpCommandPalette.vue.js +1 -1
  43. package/dist/components/LpConfirmDialog.vue.js +1 -1
  44. package/dist/components/LpConfirmHost.vue.js +1 -1
  45. package/dist/components/LpCopyButton.vue.js +1 -1
  46. package/dist/components/LpDataList.vue.js +1 -1
  47. package/dist/components/LpDock.vue.js +1 -1
  48. package/dist/components/LpDrawer.vue.js +1 -1
  49. package/dist/components/LpFileTree.vue.js +1 -1
  50. package/dist/components/LpInspector.vue.js +1 -1
  51. package/dist/components/LpLightbox.vue.js +1 -1
  52. package/dist/components/LpLogViewer.vue.js +1 -1
  53. package/dist/components/LpMediaGallery.vue.js +1 -1
  54. package/dist/components/LpModal.vue.js +1 -1
  55. package/dist/components/LpModalNav.vue.js +1 -1
  56. package/dist/components/LpNotificationBell.vue.js +1 -1
  57. package/dist/components/LpNotificationFeed.vue.js +1 -1
  58. package/dist/components/LpPageScrollbar.vue.js +1 -1
  59. package/dist/components/LpPaletteTheme.vue.js +1 -1
  60. package/dist/components/LpPasswordInput.vue.js +1 -1
  61. package/dist/components/LpPie.vue.js +1 -1
  62. package/dist/components/LpPipeline.vue.js +1 -1
  63. package/dist/components/LpRelativeTime.vue.js +1 -1
  64. package/dist/components/LpScrollArea.vue.js +1 -1
  65. package/dist/components/LpSecretField.vue.js +1 -1
  66. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  67. package/dist/components/LpSidebar.vue.js +1 -1
  68. package/dist/components/LpSidebarNav.vue.js +1 -1
  69. package/dist/components/LpTable.vue.js +38 -12
  70. package/dist/components/LpThemeStudio.vue.js +1 -1
  71. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  72. package/dist/components/LpToaster.vue.js +1 -1
  73. package/dist/components/LpTooltip.vue.d.ts +3 -3
  74. package/dist/components/LpTooltip.vue.js +1 -1
  75. package/dist/components/LpTruncate.vue.js +1 -1
  76. package/dist/components/LpUptimeBar.vue.js +1 -1
  77. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  78. package/dist/components/RevealToggle.vue.js +1 -1
  79. package/dist/components/tableFeatures.d.ts +1 -0
  80. package/dist/components/tableFeatures.js +25 -0
  81. package/dist/composables/useShift.js +28 -11
  82. package/dist/index.d.ts +2 -0
  83. package/dist/index.js +36 -34
  84. package/package.json +1 -1
  85. package/src/components/LpPageScrollbar.vue +14 -1
  86. package/src/components/LpScrollArea.vue +18 -1
  87. package/src/components/LpTable.vue +76 -4
  88. package/src/components/LpTooltip.vue +153 -14
  89. package/src/components/tableFeatures.ts +36 -0
  90. package/src/composables/useShift.ts +45 -15
  91. package/src/index.ts +2 -0
  92. package/dist/chunks/LpTooltip-BKde8Xjv.js +0 -230
@@ -39,6 +39,7 @@ import {
39
39
  orderPinned,
40
40
  pinOffsets,
41
41
  presetHidden,
42
+ rowChanges,
42
43
  rowWindow,
43
44
  sortByKeys,
44
45
  type ColumnPin,
@@ -48,6 +49,7 @@ import {
48
49
  import { devWarn } from "../composables/devWarn"
49
50
  import { startPointerDrag } from "../composables/pointerDrag"
50
51
  import { isHTMLElement, nodeWindow } from "../composables/useView"
52
+ import { prefersReducedMotion } from "../composables/easing"
51
53
  import LpButton from "./LpButton.vue"
52
54
  import { downloadFile, fileStem, toCsv, type CsvOptions } from "./exportData"
53
55
  import {
@@ -1708,6 +1710,47 @@ const roving = useRovingFocus(rovingItems, {
1708
1710
  },
1709
1711
  })
1710
1712
 
1713
+ /*
1714
+ * A change that brings in, takes out or reorders many rows at once — a filter
1715
+ * lifted from ten rows back to sixty, a page of new rows, a sort of a long
1716
+ * list — is not animated row by row. Each row eased on its own reads as noise
1717
+ * at that size, and following them is costly: TransitionGroup measures every
1718
+ * row before and after (a forced layout of all the new rows inside the click),
1719
+ * then sets an enter or move transition on each. The rows land at once and the
1720
+ * body eases in as one instead, a single opacity animation on the compositor.
1721
+ * A few rows leaving, arriving or moving (a filter that hides some, a row
1722
+ * deleted) keep their own motion, which is what lets the eye follow them.
1723
+ *
1724
+ * Counted by rowChanges: the rows that arrive, leave or change their order;
1725
+ * the ones that only slide aside to close a gap do not count.
1726
+ */
1727
+ const ROW_MOTION_LIMIT = 12
1728
+ const rowsEl = shallowRef<HTMLElement | null>(null)
1729
+ function setRowsEl(el: unknown) {
1730
+ const node = (el as { $el?: HTMLElement } | null)?.$el ?? null
1731
+ rowsEl.value = node
1732
+ roving.setContainer(node)
1733
+ }
1734
+ const motionIds = computed(() => (headerless.value ? allEntries.value : viewEntries.value).map((entry) => entry.id))
1735
+ /* The rows on screen, written after each render: what the next change is measured against. */
1736
+ let shownIds: readonly string[] = []
1737
+ const bigSwap = computed(() => rowChanges(shownIds, motionIds.value) > ROW_MOTION_LIMIT)
1738
+ let settleFlip = false
1739
+ watch(
1740
+ motionIds,
1741
+ (ids) => {
1742
+ const eased = bigSwap.value && shownIds.length > 0 && ids.length > 0
1743
+ shownIds = ids
1744
+ const body = rowsEl.value
1745
+ if (!eased || !body || props.virtualRowHeight || prefersReducedMotion(nodeWindow(body))) return
1746
+ // Two names for one animation: switching between them restarts it without
1747
+ // the reflow that taking a class off and putting it back would need.
1748
+ settleFlip = !settleFlip
1749
+ body.dataset.lpSettle = settleFlip ? "a" : "b"
1750
+ },
1751
+ { flush: "post", immediate: true },
1752
+ )
1753
+
1711
1754
  function onRowKeydown(event: KeyboardEvent) {
1712
1755
  // Space ticks rather than activates when the table is selectable — the
1713
1756
  // standard split for a multi-select list.
@@ -2088,10 +2131,11 @@ usePaletteCommands(() => {
2088
2131
  <TransitionGroup
2089
2132
  tag="tbody"
2090
2133
  name="lp-row"
2091
- :css="animating && !virtualRowHeight"
2134
+ :css="animating && !virtualRowHeight && !bigSwap"
2135
+ :move-class="bigSwap ? 'lp-row-still' : undefined"
2092
2136
  :class="busy && displayRows.length ? 'opacity-60 transition-opacity' : 'transition-opacity'"
2093
2137
  :aria-busy="busy || undefined"
2094
- :ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
2138
+ :ref="setRowsEl"
2095
2139
  >
2096
2140
  <!-- The first page on its way: rows in the shape of the columns rather
2097
2141
  than a spinner in an empty box, so the table already has its height
@@ -2252,7 +2296,8 @@ usePaletteCommands(() => {
2252
2296
  :key="activeLayout"
2253
2297
  tag="div"
2254
2298
  name="lp-row"
2255
- :css="animating"
2299
+ :css="animating && !bigSwap"
2300
+ :move-class="bigSwap ? 'lp-row-still' : undefined"
2256
2301
  v-bind="scrollAttrs"
2257
2302
  class="min-h-0 transition-opacity"
2258
2303
  :class="[
@@ -2263,7 +2308,7 @@ usePaletteCommands(() => {
2263
2308
  ]"
2264
2309
  :style="activeLayout === 'cards' ? { gridTemplateColumns: `repeat(auto-fill, minmax(min(${cardWidth}, 100%), 1fr))` } : undefined"
2265
2310
  :aria-busy="busy || undefined"
2266
- :ref="(el: unknown) => roving.setContainer((el as { $el?: HTMLElement })?.$el ?? null)"
2311
+ :ref="setRowsEl"
2267
2312
  >
2268
2313
  <template v-if="displayRows.length === 0 && busy">
2269
2314
  <div
@@ -2501,6 +2546,30 @@ usePaletteCommands(() => {
2501
2546
  transition: transform var(--duration-medium) var(--ease-emphasized);
2502
2547
  }
2503
2548
 
2549
+ /*
2550
+ * A change too big to animate row by row (see ROW_MOTION_LIMIT): the rows
2551
+ * land at once and the body comes up from half-faded as one. `lp-row-still`
2552
+ * is the move class then, and has no transition on purpose: TransitionGroup
2553
+ * checks the move class for one before it measures the rows, and skips the
2554
+ * measuring when there is none.
2555
+ */
2556
+ [data-lp-settle="a"] {
2557
+ animation: lp-rows-settle-a var(--duration-fast) var(--ease-emphasized);
2558
+ }
2559
+ [data-lp-settle="b"] {
2560
+ animation: lp-rows-settle-b var(--duration-fast) var(--ease-emphasized);
2561
+ }
2562
+ @keyframes lp-rows-settle-a {
2563
+ from {
2564
+ opacity: 0.4;
2565
+ }
2566
+ }
2567
+ @keyframes lp-rows-settle-b {
2568
+ from {
2569
+ opacity: 0.4;
2570
+ }
2571
+ }
2572
+
2504
2573
  .lp-row-enter-from,
2505
2574
  .lp-row-leave-to {
2506
2575
  opacity: 0;
@@ -2624,5 +2693,8 @@ tr:hover .lp-tick-cue,
2624
2693
  .lp-tick {
2625
2694
  transition: none;
2626
2695
  }
2696
+ [data-lp-settle] {
2697
+ animation: none;
2698
+ }
2627
2699
  }
2628
2700
  </style>
@@ -23,6 +23,15 @@
23
23
  * alone could not guarantee. reka opens the tip on keyboard focus as well as
24
24
  * hover and closes it on Escape, satisfying WCAG 1.4.13.
25
25
  *
26
+ * Until it is first wanted only the trigger is rendered: the reka tooltip (its
27
+ * provider, root, popper and portal) is stood up on the first pointer over the
28
+ * trigger, the first focus on it or a touch hold, and stays from then on. A
29
+ * table row carries two or three tips, nearly all of them never hovered, and
30
+ * fifty rows of full tooltips were most of the cost of showing the rows. The
31
+ * trigger's element is the same one before and after, so a ref to it, its
32
+ * focus and its hover survive the switch; the label is on it from the start,
33
+ * and the description arrives with the tip, as it always did.
34
+ *
26
35
  * Do NOT wrap the `#trigger` of LpDropdownMenu, LpPopover or LpContextMenu:
27
36
  * those bind their handlers to the slot's direct child, so a wrapper swallows
28
37
  * them and the control stops opening. Put `aria-label` on the trigger instead.
@@ -30,13 +39,28 @@
30
39
  import { clamp } from "../composables/clamp"
31
40
  import {
32
41
  injectTooltipProviderContext,
42
+ injectTooltipRootContext,
43
+ Slot,
33
44
  TooltipContent,
34
45
  TooltipPortal,
35
46
  TooltipProvider,
36
47
  TooltipRoot,
37
48
  TooltipTrigger,
38
49
  } from "reka-ui"
39
- import { computed, mergeProps, onBeforeUnmount, ref, useAttrs, watch, type FunctionalComponent } from "vue"
50
+ import {
51
+ computed,
52
+ defineComponent,
53
+ mergeProps,
54
+ onBeforeUnmount,
55
+ onMounted,
56
+ onUpdated,
57
+ ref,
58
+ shallowRef,
59
+ useAttrs,
60
+ watch,
61
+ type ComponentPublicInstance,
62
+ type FunctionalComponent,
63
+ } from "vue"
40
64
  import {
41
65
  springSettled,
42
66
  springStep,
@@ -263,6 +287,7 @@ const hold = useTouchHold({
263
287
  enabled: () => props.touch === "hold" && !props.followCursor && !props.disabled,
264
288
  delay: () => HOLD_MS,
265
289
  onHold: () => {
290
+ armed.value = true
266
291
  heldOpen.value = true
267
292
  wanted.value = true
268
293
  open.value = true
@@ -354,11 +379,132 @@ const guardOutside = useOutsideGuard()
354
379
  defineOptions({ inheritAttrs: false })
355
380
  // The caller's own listeners on the trigger run beside the hold's, not instead of them.
356
381
  const attrs = useAttrs()
357
- const triggerAttrs = computed(() => mergeProps(attrs, hold.handlers))
382
+
383
+ /*
384
+ * The tip is mounted on demand (see the header). The trigger is drawn by this
385
+ * component itself rather than by reka's TooltipTrigger, so it is the same
386
+ * element before and after: TooltipTrigger needs the root around it, and
387
+ * putting the root in later would have rebuilt the trigger under the pointer
388
+ * or the focus. What TooltipTrigger does with its events is done here instead,
389
+ * through the root's own context once it exists; reka's TooltipTrigger is
390
+ * still mounted with the tip, as the anchor it is placed against.
391
+ */
392
+ const armed = ref(false)
393
+ type RootContext = ReturnType<typeof injectTooltipRootContext>
394
+ type ProviderContext = ReturnType<typeof injectTooltipProviderContext>
395
+ const root = shallowRef<RootContext | null>(null)
396
+ let provider: ProviderContext | null = null
397
+
398
+ const triggerRef = ref<ComponentPublicInstance | null>(null)
399
+ const triggerEl = shallowRef<HTMLElement | undefined>()
400
+ function readTrigger() {
401
+ const el = triggerRef.value?.$el as Node | null | undefined
402
+ const next = el && el.nodeType === 1 ? (el as HTMLElement) : undefined
403
+ if (next === triggerEl.value) return
404
+ triggerEl.value = next
405
+ root.value?.onTriggerChange(next)
406
+ }
407
+ onMounted(readTrigger)
408
+ onUpdated(readTrigger)
409
+
410
+ /* What happened before the tip existed, replayed once it does. */
411
+ let hovering = false
412
+ let focusedBy: HTMLElement | null = null
413
+ let pointerDown = false
414
+ let pointerMoveOpened = false
415
+
416
+ const live = () => (root.value && !root.value.disabled.value ? root.value : null)
417
+
418
+ function enter() {
419
+ const tip = live()
420
+ if (!tip || pointerMoveOpened || provider?.isPointerInTransitRef.value) return
421
+ tip.onTriggerEnter()
422
+ pointerMoveOpened = true
423
+ }
424
+
425
+ function focusOpen(target: HTMLElement) {
426
+ const tip = live()
427
+ if (!tip || pointerDown) return
428
+ if (tip.ignoreNonKeyboardFocus.value && !target.matches?.(":focus-visible")) return
429
+ tip.onOpen()
430
+ }
431
+
432
+ const triggerEvents = {
433
+ onPointerenter(event: PointerEvent) {
434
+ if (event.pointerType === "touch") return
435
+ hovering = true
436
+ armed.value = true
437
+ },
438
+ onPointermove(event: PointerEvent) {
439
+ onPointerMove(event)
440
+ if (event.pointerType === "touch") return
441
+ hovering = true
442
+ armed.value = true
443
+ enter()
444
+ },
445
+ onPointerleave() {
446
+ hovering = false
447
+ const tip = live()
448
+ if (!tip) return
449
+ tip.onTriggerLeave()
450
+ pointerMoveOpened = false
451
+ },
452
+ onPointerdown(event: PointerEvent) {
453
+ const tip = live()
454
+ if (tip?.open.value && !tip.disableClosingTrigger.value) tip.onClose()
455
+ pointerDown = true
456
+ const doc = (event.currentTarget as Node | null)?.ownerDocument ?? document
457
+ doc.addEventListener("pointerup", () => setTimeout(() => (pointerDown = false), 1), { once: true })
458
+ },
459
+ onFocus(event: FocusEvent) {
460
+ focusedBy = event.currentTarget as HTMLElement
461
+ armed.value = true
462
+ focusOpen(focusedBy)
463
+ },
464
+ onBlur() {
465
+ focusedBy = null
466
+ live()?.onClose()
467
+ },
468
+ onClick() {
469
+ const tip = live()
470
+ if (tip && !tip.disableClosingTrigger.value) tip.onClose()
471
+ },
472
+ }
473
+
474
+ /* Hands the root's context out and replays what the tip was armed by. */
475
+ const RootBridge = defineComponent({
476
+ setup() {
477
+ root.value = injectTooltipRootContext()
478
+ provider = injectTooltipProviderContext()
479
+ onMounted(() => {
480
+ // After reka's TooltipTrigger, which reports the element it renders (none).
481
+ root.value?.onTriggerChange(triggerEl.value)
482
+ if (hovering) enter()
483
+ if (focusedBy && focusedBy.ownerDocument.activeElement === focusedBy) focusOpen(focusedBy)
484
+ })
485
+ onBeforeUnmount(() => {
486
+ root.value = null
487
+ provider = null
488
+ })
489
+ return () => null
490
+ },
491
+ })
492
+
493
+ const triggerProps = computed(() =>
494
+ mergeProps(attrs, hold.handlers, triggerEvents, {
495
+ "aria-label": props.label ? props.content : undefined,
496
+ "aria-describedby": root.value?.open.value ? root.value.contentId : undefined,
497
+ "data-state": root.value?.stateAttribute.value ?? "closed",
498
+ "data-grace-area-trigger": "",
499
+ class: props.touch === "hold" && !props.followCursor ? "[-webkit-touch-callout:none]" : undefined,
500
+ }),
501
+ )
358
502
  </script>
359
503
 
360
504
  <template>
361
- <component :is="shared ? PassThrough : TooltipProvider" v-bind="shared ? {} : { delayDuration: delay ?? 200 }">
505
+ <!-- The caller's own element, with the trigger's props merged onto it. -->
506
+ <Slot ref="triggerRef" v-bind="triggerProps"><slot /></Slot>
507
+ <component v-if="armed" :is="shared ? PassThrough : TooltipProvider" v-bind="shared ? {} : { delayDuration: delay ?? 200 }">
362
508
  <!--
363
509
  `disable-closing-trigger` in follow mode: reka dismisses a tip when its
364
510
  trigger is clicked, which is right for a button (the tip explained the
@@ -373,17 +519,10 @@ const triggerAttrs = computed(() => mergeProps(attrs, hold.handlers))
373
519
  :disable-closing-trigger="followCursor"
374
520
  @update:open="onOpenChange"
375
521
  >
376
- <!-- `as-child` merges these onto the caller's own element, so the label
377
- lands on the real button rather than on a wrapper. -->
378
- <TooltipTrigger
379
- as-child
380
- v-bind="triggerAttrs"
381
- :aria-label="label ? content : undefined"
382
- :class="touch === 'hold' && !followCursor ? '[-webkit-touch-callout:none]' : undefined"
383
- @pointermove="onPointerMove"
384
- >
385
- <slot />
386
- </TooltipTrigger>
522
+ <!-- Renders nothing: the anchor the tip is placed against, and the id
523
+ the trigger's aria-describedby points at. -->
524
+ <TooltipTrigger as-child :reference="triggerEl" />
525
+ <RootBridge />
387
526
  <TooltipPortal :to="portalTo">
388
527
  <TooltipContent
389
528
  @interact-outside="guardOutside"
@@ -157,3 +157,39 @@ export function presetHidden(
157
157
  * - `list`: one compact line per row with no header, for narrow places.
158
158
  */
159
159
  export type TableLayout = "table" | "cards" | "list"
160
+
161
+ /*
162
+ * How many rows a change of the shown list moves on screen: the ones that
163
+ * arrive, the ones that leave, and the ones that change their order among the
164
+ * rows that stay. Rows that only slide up or down to close a gap or make room
165
+ * are not counted; they move with the others and say nothing new.
166
+ *
167
+ * The order changes are the survivors outside the longest run that kept its
168
+ * order (a longest increasing subsequence of their old positions), which is the
169
+ * fewest rows that had to be picked up and put elsewhere.
170
+ */
171
+ export function rowChanges(before: readonly string[], after: readonly string[]): number {
172
+ const old = new Map<string, number>()
173
+ before.forEach((id, i) => old.set(id, i))
174
+ let entered = 0
175
+ const kept: number[] = []
176
+ for (const id of after) {
177
+ const at = old.get(id)
178
+ if (at === undefined) entered++
179
+ else kept.push(at)
180
+ }
181
+ const left = before.length - kept.length
182
+ // Patience sorting: tails[k] is the smallest end of an increasing run of k + 1.
183
+ const tails: number[] = []
184
+ for (const at of kept) {
185
+ let lo = 0
186
+ let hi = tails.length
187
+ while (lo < hi) {
188
+ const mid = (lo + hi) >> 1
189
+ if (tails[mid]! < at) lo = mid + 1
190
+ else hi = mid
191
+ }
192
+ tails[lo] = at
193
+ }
194
+ return entered + left + (kept.length - tails.length)
195
+ }
@@ -63,6 +63,33 @@ export interface UseShift {
63
63
  retune: () => void
64
64
  }
65
65
 
66
+ interface FirstRead {
67
+ read: () => void
68
+ write: () => void
69
+ }
70
+ const firstReads = new Map<Window, Set<FirstRead>>()
71
+
72
+ /*
73
+ * Runs in the next frame, every read of every box mounted meanwhile before any
74
+ * write: one layout for all of them, however many there are.
75
+ */
76
+ function firstRead(win: Window, pass: FirstRead): () => void {
77
+ let queue = firstReads.get(win)
78
+ if (!queue) {
79
+ const batch = new Set<FirstRead>()
80
+ queue = batch
81
+ firstReads.set(win, batch)
82
+ win.requestAnimationFrame(() => {
83
+ firstReads.delete(win)
84
+ for (const p of batch) p.read()
85
+ for (const p of batch) p.write()
86
+ })
87
+ }
88
+ queue.add(pass)
89
+ const batch = queue
90
+ return () => batch.delete(pass)
91
+ }
92
+
66
93
  /** Kept in step with the `duration-fast` token the transition utilities use. */
67
94
  const TWEEN_MS = 160
68
95
 
@@ -83,7 +110,7 @@ export function useShift(
83
110
 
84
111
  let ro: ResizeObserver | undefined
85
112
  let mo: MutationObserver | undefined
86
- let raf = 0
113
+ let cancelFirstRead: (() => void) | undefined
87
114
  let settleTimer: ReturnType<typeof setTimeout> | undefined
88
115
  /** Where the box last settled, so a retune has a size to ease FROM. */
89
116
  let lastHeight = 0
@@ -259,7 +286,8 @@ export function useShift(
259
286
  mo?.disconnect()
260
287
  ro = undefined
261
288
  mo = undefined
262
- cancelAnimationFrame(raf)
289
+ cancelFirstRead?.()
290
+ cancelFirstRead = undefined
263
291
  clearTimeout(settleTimer)
264
292
  tweening.value = false
265
293
  resizing.value = false
@@ -277,21 +305,23 @@ export function useShift(
277
305
  teardown()
278
306
  if (!enabled || !node) return
279
307
 
280
- lastHeight = node.offsetHeight
281
- lastWidth = node.offsetWidth
282
- laidOut = node.getClientRects().length > 0
283
308
  scrollHost = node
284
309
  node.addEventListener("scroll", rememberScroll, { capture: true, passive: true })
285
- if (wantsHeight()) node.style.height = `${lastHeight}px`
286
- if (wantsWidth()) node.style.width = `${lastWidth}px`
287
- // A box must not animate in from its pre-content size, so the transition
288
- // only arms once that first pin has painted. The pin is dropped in the
289
- // same frame: it exists to give the first `retune` something to start
290
- // from, and holding it would cap the box at its opening size.
291
- raf = requestAnimationFrame(() => {
292
- tweening.value = true
293
- if (wantsHeight()) node.style.height = ""
294
- if (wantsWidth()) node.style.width = ""
310
+ // The size a first `retune` eases from, read in the next frame together
311
+ // with every other box mounted in this render, and the transition armed
312
+ // then: a box must not animate in from its pre-content size. Read here,
313
+ // at mount, each box forced a layout of its own: fifty badges arriving
314
+ // in a table's rows laid the page out fifty times inside one click.
315
+ // Until the read, a retune finds no size to ease from and lets the box
316
+ // take its own, which is what a box that has just appeared should do.
317
+ cancelFirstRead = firstRead(nodeWindow(node), {
318
+ read: () => {
319
+ if (inFlight) return
320
+ lastHeight = node.offsetHeight
321
+ lastWidth = node.offsetWidth
322
+ laidOut = node.getClientRects().length > 0
323
+ },
324
+ write: () => (tweening.value = true),
295
325
  })
296
326
 
297
327
  // Two triggers, since either can move the size alone: the resize observer
package/src/index.ts CHANGED
@@ -272,6 +272,8 @@ export type { SortState, TableColumn, TableListMode, TablePage, TableQuery } fro
272
272
  export type { TableLabels } from "./components/LpTable.vue"
273
273
  export type { TableCellEdit, TableExportOptions, TableSelectionToken } from "./components/LpTable.vue"
274
274
  export type { ColumnPin, ColumnPreset, TableLayout } from "./components/tableFeatures"
275
+ export { rowWindow } from "./components/tableFeatures"
276
+ export type { RowWindow } from "./components/tableFeatures"
275
277
  export { compareValues } from "./components/tableSort"
276
278
  export { filterRows, showOnly, shownValues } from "./components/tableFilter"
277
279
  export type { TableFilters } from "./components/tableFilter"