@leavepulse/ui 0.55.0 → 0.57.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.
Files changed (73) hide show
  1. package/dist/chunks/{LpActionBar-CnfFVIwC.js → LpActionBar-C3hkVsFz.js} +1 -1
  2. package/dist/chunks/{LpAddressInput-iOJhmRzy.js → LpAddressInput-NUHmplCd.js} +1 -1
  3. package/dist/chunks/{LpAppShell-DcSXik4a.js → LpAppShell-Bl1Ch68N.js} +2 -2
  4. package/dist/chunks/{LpButton-CKSwC6Mu.js → LpButton-BdfZFNqi.js} +31 -7
  5. package/dist/chunks/{LpConfirmDialog-Ca-CTlxT.js → LpConfirmDialog-CrbUZgJE.js} +1 -1
  6. package/dist/chunks/{LpConfirmHost-BAaSaT2b.js → LpConfirmHost-BgMIV8r9.js} +1 -1
  7. package/dist/chunks/{LpDateRangePicker-Drd7EbIz.js → LpDateRangePicker-CxV5jW7p.js} +4 -13
  8. package/dist/chunks/{LpDrawer-CBFVD624.js → LpDrawer-D9lLOC0C.js} +4 -6
  9. package/dist/chunks/LpHintLayer-CI1oZ9Pd.js +154 -0
  10. package/dist/chunks/{LpModalNav-aWwc_5CD.js → LpModalNav-DXyAyhQl.js} +2 -2
  11. package/dist/chunks/{LpNotificationBell-wZ3meeim.js → LpNotificationBell-DEL-RoHS.js} +1 -1
  12. package/dist/chunks/{LpPie-B7ti5qCH.js → LpPie-CXka8Jvx.js} +1 -1
  13. package/dist/chunks/{LpSidebar-C2oolgwy.js → LpSidebar-hZ8D1cVr.js} +1 -1
  14. package/dist/chunks/{LpTableOfContents-BUcAfGPQ.js → LpTableOfContents-DYuqkByy.js} +2 -8
  15. package/dist/chunks/{LpTooltip-CXVKcOXT.js → LpTooltip-suFCM34F.js} +2 -2
  16. package/dist/chunks/LpTour-C1TC2GyY.js +315 -0
  17. package/dist/chunks/{LpUptimeBar-ugcv25xj.js → LpUptimeBar-B4ltCfno.js} +1 -1
  18. package/dist/chunks/TourCard-BQS3ztqj.js +100 -0
  19. package/dist/component-names.d.ts +1 -1
  20. package/dist/components/LpActionBar.vue.js +1 -1
  21. package/dist/components/LpAddressInput.vue.js +1 -1
  22. package/dist/components/LpAppShell.vue.js +1 -1
  23. package/dist/components/LpButton.vue.d.ts +10 -0
  24. package/dist/components/LpButton.vue.js +1 -1
  25. package/dist/components/LpConfirmDialog.vue.js +1 -1
  26. package/dist/components/LpConfirmHost.vue.js +1 -1
  27. package/dist/components/LpDateRangePicker.vue.js +1 -1
  28. package/dist/components/LpDrawer.vue.js +1 -1
  29. package/dist/components/LpHintLayer.vue.d.ts +33 -0
  30. package/dist/components/LpHintLayer.vue.js +4 -0
  31. package/dist/components/LpImageEditor.vue.js +1 -1
  32. package/dist/components/LpModalNav.vue.js +1 -1
  33. package/dist/components/LpNotificationBell.vue.js +1 -1
  34. package/dist/components/LpPie.vue.js +1 -1
  35. package/dist/components/LpSidebar.vue.js +1 -1
  36. package/dist/components/LpTable.vue.js +1 -1
  37. package/dist/components/LpTableOfContents.vue.js +1 -1
  38. package/dist/components/LpTooltip.vue.js +1 -1
  39. package/dist/components/LpTour.vue.d.ts +98 -0
  40. package/dist/components/LpTour.vue.js +4 -0
  41. package/dist/components/LpUptimeBar.vue.js +1 -1
  42. package/dist/components/TourCard.vue.d.ts +28 -0
  43. package/dist/components/TourCard.vue.js +4 -0
  44. package/dist/components/tour.d.ts +21 -0
  45. package/dist/components/tour.js +38 -0
  46. package/dist/composables/scroll.d.ts +17 -0
  47. package/dist/composables/scroll.js +29 -0
  48. package/dist/composables/useGamepadNav.js +2 -7
  49. package/dist/composables/useHints.d.ts +18 -0
  50. package/dist/composables/useHints.js +120 -0
  51. package/dist/composables/useMediaQuery.d.ts +2 -0
  52. package/dist/composables/useMediaQuery.js +18 -0
  53. package/dist/composables/usePaintSelect.js +1 -7
  54. package/dist/index.d.ts +10 -0
  55. package/dist/index.js +38 -22
  56. package/package.json +1 -1
  57. package/src/component-names.ts +2 -0
  58. package/src/components/LpButton.vue +28 -1
  59. package/src/components/LpDateRangePicker.vue +3 -12
  60. package/src/components/LpDrawer.vue +4 -7
  61. package/src/components/LpHintLayer.vue +148 -0
  62. package/src/components/LpTableOfContents.vue +2 -14
  63. package/src/components/LpTooltip.vue +2 -4
  64. package/src/components/LpTour.vue +338 -0
  65. package/src/components/TourCard.vue +41 -0
  66. package/src/components/tour.ts +57 -0
  67. package/src/composables/scroll.ts +54 -0
  68. package/src/composables/useGamepadNav.ts +2 -7
  69. package/src/composables/useHints.ts +151 -0
  70. package/src/composables/useMediaQuery.ts +20 -0
  71. package/src/composables/usePaintSelect.ts +1 -8
  72. package/src/index.ts +10 -0
  73. package/src/tokens/tokens.css +1 -0
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-BkaGXTjx.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-iOJhmRzy.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-NUHmplCd.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-Cc84LaM3.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
- import { _ as _4 } from "./chunks/LpActionBar-CnfFVIwC.js";
5
+ import { _ as _4 } from "./chunks/LpActionBar-C3hkVsFz.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-B6ilrM7T.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-DcSXik4a.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-Bl1Ch68N.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-C8IgSMsi.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-LTvy0OkE.js";
10
10
  import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
11
11
  import { _ as _10 } from "./chunks/LpBreadcrumbs-D4TsoFju.js";
12
- import { _ as _11 } from "./chunks/LpButton-CKSwC6Mu.js";
12
+ import { _ as _11 } from "./chunks/LpButton-BdfZFNqi.js";
13
13
  import { _ as _12 } from "./chunks/LpCalendar-DrA2McTU.js";
14
14
  import { _ as _13 } from "./chunks/LpCard-DbjO5yaF.js";
15
15
  import { default as default2 } from "./components/LpChart.vue.js";
@@ -30,17 +30,17 @@ import { default as default6 } from "./components/LpPaletteStats.vue.js";
30
30
  import { default as default7 } from "./components/LpPaletteSwatches.vue.js";
31
31
  import { default as default8 } from "./components/LpKeys.vue.js";
32
32
  import { comboKeys, comboSteps } from "./components/keys.js";
33
- import { _ as _18 } from "./chunks/LpConfirmDialog-Ca-CTlxT.js";
34
- import { _ as _19 } from "./chunks/LpConfirmHost-BAaSaT2b.js";
33
+ import { _ as _18 } from "./chunks/LpConfirmDialog-CrbUZgJE.js";
34
+ import { _ as _19 } from "./chunks/LpConfirmHost-BgMIV8r9.js";
35
35
  import { confirm, useConfirm } from "./components/confirm.js";
36
36
  import { _ as _20 } from "./chunks/LpContextMenu-EF_90u3g.js";
37
37
  import { _ as _21 } from "./chunks/LpDatePicker-BrdFtSTy.js";
38
- import { _ as _22 } from "./chunks/LpDateRangePicker-Drd7EbIz.js";
38
+ import { _ as _22 } from "./chunks/LpDateRangePicker-CxV5jW7p.js";
39
39
  import { _ as _23 } from "./chunks/LpRangeCalendar-DZuaRV0W.js";
40
40
  import { clampRange, defaultRangePresets, formatRange, normalizeRange, sameRange } from "./components/dateRange.js";
41
41
  import { _ as _24 } from "./chunks/LpDisclosure-Cc23-JOT.js";
42
42
  import { _ as _25 } from "./chunks/LpDivider-CUX46KR0.js";
43
- import { _ as _26 } from "./chunks/LpDrawer-CBFVD624.js";
43
+ import { _ as _26 } from "./chunks/LpDrawer-D9lLOC0C.js";
44
44
  import { _ as _27 } from "./chunks/LpDropdownMenu-DrpJJBp0.js";
45
45
  import { default as default9 } from "./components/LpDropzone.vue.js";
46
46
  import { _ as _28 } from "./chunks/LpEmptyState-BJbZXC8D.js";
@@ -60,15 +60,15 @@ import { _ as _37 } from "./chunks/LpLogViewer-BfgtXVKm.js";
60
60
  import { countLevels, matchPositions, matchesTerm, ringWindow, stepMatch } from "./components/logSearch.js";
61
61
  import { _ as _38 } from "./chunks/LpMediaGallery-BnlkN_1f.js";
62
62
  import { _ as _39 } from "./chunks/LpModal-JNa3B_Lr.js";
63
- import { _ as _40 } from "./chunks/LpModalNav-aWwc_5CD.js";
64
- import { _ as _41 } from "./chunks/LpNotificationBell-wZ3meeim.js";
63
+ import { _ as _40 } from "./chunks/LpModalNav-DXyAyhQl.js";
64
+ import { _ as _41 } from "./chunks/LpNotificationBell-DEL-RoHS.js";
65
65
  import { _ as _42 } from "./chunks/LpNumberField-B77O-z28.js";
66
66
  import { _ as _43 } from "./chunks/LpTimeField-Cqe7fXnh.js";
67
67
  import { _ as _44 } from "./chunks/LpNumberFlow-z4WPZcZu.js";
68
68
  import { _ as _45 } from "./chunks/LpOtpInput-PbeNJ5In.js";
69
69
  import { _ as _46 } from "./chunks/LpPagination-BcDlo3r_.js";
70
70
  import { _ as _47 } from "./chunks/LpPasswordInput-eKzQhbQP.js";
71
- import { _ as _48 } from "./chunks/LpPie-B7ti5qCH.js";
71
+ import { _ as _48 } from "./chunks/LpPie-CXka8Jvx.js";
72
72
  import { _ as _49 } from "./chunks/LpPhoneInput-BCbrrhgE.js";
73
73
  import { filterCountries, flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
74
74
  import { _ as _50 } from "./chunks/LpPopover-DCKeqiVn.js";
@@ -83,7 +83,7 @@ import { _ as _58 } from "./chunks/LpSelect-DNIL6MB9.js";
83
83
  import { _ as _59 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
84
84
  import { _ as _60 } from "./chunks/LpShift-DXNsauQZ.js";
85
85
  import { default as default11 } from "./components/LpShortcutsDialog.vue.js";
86
- import { _ as _61 } from "./chunks/LpSidebar-C2oolgwy.js";
86
+ import { _ as _61 } from "./chunks/LpSidebar-hZ8D1cVr.js";
87
87
  import { _ as _62 } from "./chunks/LpSkeleton-BB6XIV-w.js";
88
88
  import { _ as _63 } from "./chunks/LpSlider-Cuzmrd7p.js";
89
89
  import { _ as _64 } from "./chunks/LpRangeSlider-C1yF9-nP.js";
@@ -95,7 +95,7 @@ import { _ as _68 } from "./chunks/LpStepper-C7XiVKZj.js";
95
95
  import { _ as _69 } from "./chunks/LpSwitch-DG2AhXIL.js";
96
96
  import { default as default12 } from "./components/LpTable.vue.js";
97
97
  import { compareValues } from "./components/tableSort.js";
98
- import { _ as _70 } from "./chunks/LpTableOfContents-BUcAfGPQ.js";
98
+ import { _ as _70 } from "./chunks/LpTableOfContents-DYuqkByy.js";
99
99
  import { _ as _71 } from "./chunks/LpTabs-BMSBB78h.js";
100
100
  import { _ as _72 } from "./chunks/LpTagsInput-D1SuqbhB.js";
101
101
  import { addTags, splitTags } from "./components/tags.js";
@@ -103,10 +103,13 @@ import { _ as _73 } from "./chunks/LpTextarea-uuScWo6-.js";
103
103
  import { _ as _74 } from "./chunks/LpThemeSwitcher-Fz85BCRS.js";
104
104
  import { _ as _75 } from "./chunks/LpTilt-B7KHX1zH.js";
105
105
  import { _ as _76 } from "./chunks/LpToaster-DTtWuKvq.js";
106
- import { _ as _77 } from "./chunks/LpTooltip-CXVKcOXT.js";
107
- import { _ as _78 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
108
- import { _ as _79 } from "./chunks/LpTopologyCanvas-6ozNrQKh.js";
109
- import { _ as _80 } from "./chunks/LpUptimeBar-ugcv25xj.js";
106
+ import { _ as _77 } from "./chunks/LpTooltip-suFCM34F.js";
107
+ import { _ as _78 } from "./chunks/LpTour-C1TC2GyY.js";
108
+ import { holeOf, openContextMenu, panelsAround } from "./components/tour.js";
109
+ import { _ as _79 } from "./chunks/LpHintLayer-CI1oZ9Pd.js";
110
+ import { _ as _80 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
111
+ import { _ as _81 } from "./chunks/LpTopologyCanvas-6ozNrQKh.js";
112
+ import { _ as _82 } from "./chunks/LpUptimeBar-B4ltCfno.js";
110
113
  import { deserializeLayout, serializeLayout } from "./layout/tree.js";
111
114
  import { addLeaf, addTab, countBlocks, countLeaves, makeLayout, moveLeaf, removeBlock, removeLeaf, reorderTab, resizeAt, setActiveTab } from "./layout/tree.js";
112
115
  import { blockTitle, defineBlocks } from "./layout/registry.js";
@@ -127,6 +130,9 @@ import { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } fro
127
130
  import { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice.js";
128
131
  import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
129
132
  import { useReveal } from "./composables/useReveal.js";
133
+ import { hints, vHint } from "./composables/useHints.js";
134
+ import { scrollParent, scrollToElement } from "./composables/scroll.js";
135
+ import { useMediaQuery } from "./composables/useMediaQuery.js";
130
136
  import { useAsyncTask } from "./composables/useAsyncTask.js";
131
137
  import { useRovingFocus } from "./composables/useRovingFocus.js";
132
138
  import { useFormDraft } from "./composables/useFormDraft.js";
@@ -137,7 +143,7 @@ import { useShift } from "./composables/useShift.js";
137
143
  import { useTilt } from "./composables/useTilt.js";
138
144
  import { useZoomPan } from "./composables/useZoomPan.js";
139
145
  import { useToast } from "./composables/useToast.js";
140
- import { _ as _81 } from "./chunks/LpThemeProvider-CQ2jC9TA.js";
146
+ import { _ as _83 } from "./chunks/LpThemeProvider-CQ2jC9TA.js";
141
147
  import { isInternalHref, resolveLink } from "./components/link.js";
142
148
  function defineTheme(base, overrides = {}) {
143
149
  const src = typeof base === "string" ? presets[base] : base;
@@ -209,6 +215,7 @@ export {
209
215
  _30 as LpFieldNotice,
210
216
  _29 as LpFileTree,
211
217
  _31 as LpFormField,
218
+ _79 as LpHintLayer,
212
219
  _32 as LpIcon,
213
220
  default10 as LpImageEditor,
214
221
  _33 as LpInfraNode,
@@ -260,15 +267,16 @@ export {
260
267
  _71 as LpTabs,
261
268
  _72 as LpTagsInput,
262
269
  _73 as LpTextarea,
263
- _81 as LpThemeProvider,
270
+ _83 as LpThemeProvider,
264
271
  _74 as LpThemeSwitcher,
265
272
  _75 as LpTilt,
266
273
  _43 as LpTimeField,
267
274
  _76 as LpToaster,
268
275
  _77 as LpTooltip,
269
- _78 as LpTooltipProvider,
270
- _79 as LpTopologyCanvas,
271
- _80 as LpUptimeBar,
276
+ _80 as LpTooltipProvider,
277
+ _81 as LpTopologyCanvas,
278
+ _78 as LpTour,
279
+ _82 as LpUptimeBar,
272
280
  _65 as LpVideoBackdrop,
273
281
  N as NO_BACKDROP_ATTR,
274
282
  OSM_TILES,
@@ -336,6 +344,8 @@ export {
336
344
  formatSize,
337
345
  formatTick,
338
346
  highlightParts,
347
+ hints,
348
+ holeOf,
339
349
  hotkeyConflicts,
340
350
  hotkeySteps,
341
351
  hsvToHsl,
@@ -370,6 +380,8 @@ export {
370
380
  normalizeRange,
371
381
  k as normalizeTheme,
372
382
  opaqueCss,
383
+ openContextMenu,
384
+ panelsAround,
373
385
  parseColor,
374
386
  parseCommandQuery,
375
387
  parseConfig,
@@ -405,6 +417,8 @@ export {
405
417
  ringWindow,
406
418
  o as rose,
407
419
  sameRange,
420
+ scrollParent,
421
+ scrollToElement,
408
422
  seamProgress,
409
423
  seamReached,
410
424
  sequenceProgress,
@@ -448,6 +462,7 @@ export {
448
462
  useLayout,
449
463
  useLinkComponent,
450
464
  useLpConfig,
465
+ useMediaQuery,
451
466
  useMergedAttrs,
452
467
  useMergedClass,
453
468
  useModalLayer,
@@ -460,5 +475,6 @@ export {
460
475
  useTilt,
461
476
  useToast,
462
477
  useZoomPan,
478
+ vHint,
463
479
  y as violet
464
480
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -33,6 +33,7 @@ export const COMPONENT_NAMES = [
33
33
  "LpFieldNotice",
34
34
  "LpFileTree",
35
35
  "LpFormField",
36
+ "LpHintLayer",
36
37
  "LpIcon",
37
38
  "LpImageEditor",
38
39
  "LpInfraNode",
@@ -92,6 +93,7 @@ export const COMPONENT_NAMES = [
92
93
  "LpTooltip",
93
94
  "LpTooltipProvider",
94
95
  "LpTopologyCanvas",
96
+ "LpTour",
95
97
  "LpUptimeBar",
96
98
  "LpVideoBackdrop"
97
99
  ] as const
@@ -9,6 +9,7 @@ import { tv, type VariantProps } from "tailwind-variants"
9
9
  import { computed, type Component } from "vue"
10
10
  import { useLinkComponent } from "../composables/useLpConfig"
11
11
  import { useMergedAttrs } from "../composables/useMergedClass"
12
+ import LpIcon from "./LpIcon.vue"
12
13
  import { resolveLink, type LinkTarget } from "./link"
13
14
 
14
15
  const button = tv({
@@ -47,6 +48,8 @@ const button = tv({
47
48
  square: { true: "aspect-square p-0" },
48
49
  // soft elevation glow keyed to the variant's colour
49
50
  glow: { true: "" },
51
+ // busy: the label fades out in place (no width jump) and a spinner sits on top
52
+ loading: { true: "relative cursor-progress !text-transparent hover:!text-transparent" },
50
53
  },
51
54
  compoundVariants: [
52
55
  { square: true, size: "xs", class: "size-8" },
@@ -96,6 +99,11 @@ const props = withDefaults(
96
99
  href?: string
97
100
  type?: "button" | "submit" | "reset"
98
101
  disabled?: boolean
102
+ /**
103
+ * Busy state: blocks clicks like `disabled` without dimming, fades the label out
104
+ * in place and shows a spinner over it, so the width never jumps. Sets `aria-busy`.
105
+ */
106
+ loading?: boolean
99
107
  }>(),
100
108
  { type: "button" },
101
109
  )
@@ -119,11 +127,20 @@ const isNativeButton = computed(() => resolved.value.is === "button")
119
127
  * own handler, which skips navigation once the event is default-prevented.
120
128
  */
121
129
  function blockDisabled(event: MouseEvent) {
122
- if (!props.disabled || isNativeButton.value) return
130
+ if (!props.loading && (!props.disabled || isNativeButton.value)) return
123
131
  event.preventDefault()
124
132
  event.stopImmediatePropagation()
125
133
  }
126
134
 
135
+ const SPINNER_TONE: Record<string, string> = {
136
+ solid: "text-ink-inverse",
137
+ action: "text-ink-inverse",
138
+ danger: "text-ink-inverse",
139
+ light: "text-[#0b1210]",
140
+ muted: "text-muted",
141
+ }
142
+ const spinnerTone = computed(() => SPINNER_TONE[props.variant ?? "solid"] ?? "text-ink")
143
+
127
144
  const linkProps = computed(() => {
128
145
  if (isNativeButton.value) return {}
129
146
  if (!props.disabled) return resolved.value.props
@@ -137,6 +154,7 @@ const { class: classes, attrs: rest } = useMergedAttrs(() =>
137
154
  block: props.block,
138
155
  square: props.square,
139
156
  glow: props.glow,
157
+ loading: props.loading,
140
158
  }),
141
159
  )
142
160
  </script>
@@ -149,9 +167,18 @@ const { class: classes, attrs: rest } = useMergedAttrs(() =>
149
167
  :disabled="isNativeButton ? disabled : undefined"
150
168
  :aria-disabled="!isNativeButton && disabled ? 'true' : undefined"
151
169
  :tabindex="!isNativeButton && disabled ? -1 : undefined"
170
+ :aria-busy="loading ? 'true' : undefined"
152
171
  :class="classes"
153
172
  @click.capture="blockDisabled"
154
173
  >
155
174
  <slot />
175
+ <span
176
+ v-if="loading"
177
+ class="pointer-events-none absolute inset-0 flex items-center justify-center animate-[fade-in_var(--duration-fast)_var(--ease-emphasized)] motion-reduce:animate-none"
178
+ :class="spinnerTone"
179
+ aria-hidden="true"
180
+ >
181
+ <LpIcon name="lucide:loader-circle" class="size-[1.15em] animate-spin motion-reduce:animate-none" />
182
+ </span>
156
183
  </component>
157
184
  </template>
@@ -10,11 +10,12 @@ export default { inheritAttrs: false }
10
10
  * Two months sit side by side where there is room, one on a phone.
11
11
  */
12
12
  import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui"
13
- import { computed, onBeforeUnmount, onMounted, ref, useAttrs } from "vue"
13
+ import { computed, ref, useAttrs } from "vue"
14
14
  import { useFieldControl } from "../composables/useField"
15
15
  import { useLocale } from "../composables/useLocale"
16
16
  import { useLabels } from "../composables/useLpConfig"
17
17
  import { mergeClass } from "../composables/useMergedClass"
18
+ import { useMediaQuery } from "../composables/useMediaQuery"
18
19
  import {
19
20
  clampRange,
20
21
  type DateRange,
@@ -113,17 +114,7 @@ function presetFits(preset: DateRangePreset): boolean {
113
114
  return !!clampRange(preset.range(), props.min, props.max)
114
115
  }
115
116
 
116
- const wide = ref(false)
117
- let query: MediaQueryList | undefined
118
- const onQuery = () => {
119
- wide.value = !!query?.matches
120
- }
121
- onMounted(() => {
122
- query = window.matchMedia("(min-width: 640px)")
123
- onQuery()
124
- query.addEventListener("change", onQuery)
125
- })
126
- onBeforeUnmount(() => query?.removeEventListener("change", onQuery))
117
+ const wide = useMediaQuery("(min-width: 640px)")
127
118
 
128
119
  const shellSize = {
129
120
  sm: "h-(--size-control-sm) text-xs",
@@ -34,6 +34,7 @@ import {
34
34
  } from "vaul-vue"
35
35
  import { computed, onBeforeUnmount, ref, useSlots, watch } from "vue"
36
36
  import { CLOSE_ICON } from "./dropdown"
37
+ import { prefersReducedMotion } from "../composables/easing"
37
38
  import { useGlass } from "../composables/useGlass"
38
39
  import { useLabels } from "../composables/useLpConfig"
39
40
  import LpIcon from "./LpIcon.vue"
@@ -328,10 +329,6 @@ function edgeEnabled(): boolean {
328
329
  let track: { id: number; x: number; y: number; t: number; lastX: number; lastT: number; speed: number } | null = null
329
330
  const owner = Symbol("edge-pull")
330
331
 
331
- function reducedMotion(): boolean {
332
- return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
333
- }
334
-
335
332
  // A horizontally scrollable ancestor that can still move the way the finger
336
333
  // pulls (a carousel, a wide table) keeps the gesture: the pull only claims
337
334
  // what nothing under the finger wants.
@@ -414,7 +411,7 @@ function settle() {
414
411
  peek.value = 0
415
412
  }, 60),
416
413
  )
417
- }, reducedMotion() ? 0 : 180),
414
+ }, prefersReducedMotion() ? 0 : 180),
418
415
  )
419
416
  } else {
420
417
  peek.value = 0
@@ -462,7 +459,7 @@ const previewStyle = computed(() => {
462
459
  const axis = dir.value === "right" ? off : -off
463
460
  return {
464
461
  transform: `translateX(${axis}%)`,
465
- transition: peeking.value || reducedMotion() ? "none" : "transform 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
462
+ transition: peeking.value || prefersReducedMotion() ? "none" : "transform 0.18s cubic-bezier(0.32, 0.72, 0, 1)",
466
463
  pointerEvents: "none" as const,
467
464
  }
468
465
  })
@@ -477,7 +474,7 @@ const previewStyle = computed(() => {
477
474
  v-show="peeking || peek > 0"
478
475
  class="lp-scrim pointer-events-none fixed inset-0"
479
476
  :class="{ 'lp-scrim--light': frosted, 'lp-scrim--nested': layer.nested }"
480
- :style="{ zIndex: layer.scrim, opacity: peek, transition: peeking || reducedMotion() ? 'none' : 'opacity 0.18s ease-out' }"
477
+ :style="{ zIndex: layer.scrim, opacity: peek, transition: peeking || prefersReducedMotion() ? 'none' : 'opacity 0.18s ease-out' }"
481
478
  />
482
479
  <div
483
480
  v-show="peeking || peek > 0"
@@ -0,0 +1,148 @@
1
+ <script lang="ts">
2
+ export interface HintNote {
3
+ title: string
4
+ text?: string
5
+ }
6
+ </script>
7
+
8
+ <script setup lang="ts">
9
+ /*
10
+ * Quiet "new" markers over `v-hint` elements, with a short note when the user
11
+ * reaches for one. Nothing pops up by itself: a dot sits on the control, and
12
+ * the note opens only when the pointer or focus lands on it. Reading the note
13
+ * (or simply using the control) is the app's cue to stop marking it.
14
+ *
15
+ * The layer only draws. Which ids are new, and when a read counts, is decided
16
+ * by the app from the registry's seen/used events.
17
+ */
18
+ import { PopoverAnchor, PopoverContent, PopoverPortal, PopoverRoot } from "reka-ui"
19
+ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue"
20
+ import { hints } from "../composables/useHints"
21
+ import { useLabels } from "../composables/useLpConfig"
22
+ import { PANEL_SURFACE, POPOVER_ANIM } from "./dropdown"
23
+ import LpButton from "./LpButton.vue"
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ /** Ids to mark, most important first. */
28
+ fresh: string[]
29
+ notes: Record<string, HintNote>
30
+ /** At most this many dots at once, so a page full of news stays calm. */
31
+ limit?: number
32
+ labels?: Partial<typeof DEFAULT_LABELS>
33
+ }>(),
34
+ { limit: 3 },
35
+ )
36
+
37
+ const emit = defineEmits<{ (e: "read", id: string): void }>()
38
+
39
+ const DEFAULT_LABELS = { gotIt: "Got it", badge: "New" }
40
+ const labels = useLabels("hintLayer", DEFAULT_LABELS, () => props.labels)
41
+
42
+ const OPEN_DELAY_MS = 350
43
+
44
+ const shown = computed(() => props.fresh.filter((id) => hints.present(id) && props.notes[id]).slice(0, props.limit))
45
+ const spots = ref<{ id: string; x: number; y: number }[]>([])
46
+ const openId = ref<string | null>(null)
47
+ const anchor = shallowRef<Element | null>(null)
48
+ let frame = 0
49
+ let timer: ReturnType<typeof setTimeout> | undefined
50
+
51
+ function place() {
52
+ spots.value = shown.value.flatMap((id) => {
53
+ const el = hints.element(id)
54
+ if (!el) return []
55
+ const r = el.getBoundingClientRect()
56
+ return r.width || r.height ? [{ id, x: r.right, y: r.top }] : []
57
+ })
58
+ frame = shown.value.length ? requestAnimationFrame(place) : 0
59
+ }
60
+
61
+ watch(
62
+ shown,
63
+ (ids) => {
64
+ if (ids.length && !frame) frame = requestAnimationFrame(place)
65
+ if (openId.value && !ids.includes(openId.value)) openId.value = null
66
+ },
67
+ { immediate: true },
68
+ )
69
+
70
+ function reveal(id: string) {
71
+ clearTimeout(timer)
72
+ timer = setTimeout(() => {
73
+ if (!shown.value.includes(id)) return
74
+ anchor.value = hints.element(id)
75
+ openId.value = anchor.value ? id : null
76
+ }, OPEN_DELAY_MS)
77
+ }
78
+
79
+ function read() {
80
+ const id = openId.value
81
+ openId.value = null
82
+ if (id) emit("read", id)
83
+ }
84
+
85
+ let off: (() => void)[] = []
86
+ onMounted(() => {
87
+ off = [
88
+ hints.on("hover", (id) => {
89
+ if (shown.value.includes(id)) reveal(id)
90
+ }),
91
+ hints.on("used", (id) => {
92
+ if (!shown.value.includes(id)) return
93
+ clearTimeout(timer)
94
+ if (openId.value === id) openId.value = null
95
+ emit("read", id)
96
+ }),
97
+ ]
98
+ })
99
+
100
+ onBeforeUnmount(() => {
101
+ cancelAnimationFrame(frame)
102
+ clearTimeout(timer)
103
+ for (const stop of off) stop()
104
+ })
105
+
106
+ const note = computed(() => (openId.value ? props.notes[openId.value] : undefined))
107
+ </script>
108
+
109
+ <template>
110
+ <Teleport to="body">
111
+ <button
112
+ v-for="spot in spots"
113
+ :key="spot.id"
114
+ type="button"
115
+ class="fixed z-(--z-popover) -translate-x-1/2 -translate-y-1/2 rounded-full p-1"
116
+ :style="{ left: `${spot.x}px`, top: `${spot.y}px` }"
117
+ :aria-label="`${labels.badge}: ${notes[spot.id]?.title ?? ''}`"
118
+ @pointerenter="reveal(spot.id)"
119
+ @focus="reveal(spot.id)"
120
+ @click="reveal(spot.id)"
121
+ >
122
+ <span class="relative flex size-2">
123
+ <span class="absolute inline-flex size-full animate-ping rounded-full bg-brand opacity-60 motion-reduce:animate-none" />
124
+ <span class="relative inline-flex size-2 rounded-full bg-brand ring-2 ring-surface" />
125
+ </span>
126
+ </button>
127
+ <PopoverRoot :open="!!openId" @update:open="(value) => !value && (openId = null)">
128
+ <PopoverAnchor v-if="anchor" :reference="anchor" />
129
+ <PopoverPortal>
130
+ <PopoverContent
131
+ side="bottom"
132
+ :side-offset="8"
133
+ :collision-padding="8"
134
+ :class="[PANEL_SURFACE, POPOVER_ANIM, 'z-(--z-popover) w-72 rounded-card p-3 text-sm text-ink outline-none']"
135
+ >
136
+ <p class="flex items-center gap-2 font-medium">
137
+ <span class="rounded-full bg-brand/15 px-1.5 text-[11px] text-brand">{{ labels.badge }}</span>
138
+ {{ note?.title }}
139
+ </p>
140
+ <p v-if="note?.text" class="mt-1.5 text-muted">{{ note.text }}</p>
141
+ <div class="mt-3 flex justify-end">
142
+ <LpButton size="sm" @click="read">{{ labels.gotIt }}</LpButton>
143
+ </div>
144
+ </PopoverContent>
145
+ </PopoverPortal>
146
+ </PopoverRoot>
147
+ </Teleport>
148
+ </template>
@@ -12,6 +12,7 @@
12
12
  import { Motion } from "motion-v"
13
13
  import { computed, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
14
14
  import { useLabels } from "../composables/useLpConfig"
15
+ import { scrollParent } from "../composables/scroll"
15
16
  import { usePillTransition } from "../composables/usePillTransition"
16
17
 
17
18
  export interface TocLink {
@@ -113,24 +114,11 @@ function unfreeze() {
113
114
  safetyTimer = null
114
115
  }
115
116
 
116
- /*
117
- * The element that actually scrolls to the heading. Inside an app shell that is
118
- * a scroll region, not the window, and watching window.scrollY there saw no
119
- * movement and unfroze on the first frame.
120
- */
121
- function scrollerOf(el: HTMLElement): HTMLElement | null {
122
- for (let node = el.parentElement; node; node = node.parentElement) {
123
- const { overflowY } = getComputedStyle(node)
124
- if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) return node
125
- }
126
- return null
127
- }
128
-
129
117
  function suppressSpyUntilScrollEnds(target: HTMLElement) {
130
118
  spySuppressed = true
131
119
  if (rafId != null) cancelAnimationFrame(rafId)
132
120
  if (safetyTimer != null) clearTimeout(safetyTimer)
133
- const scroller = scrollerOf(target)
121
+ const scroller = scrollParent(target)
134
122
  const position = () => (scroller ? scroller.scrollTop : window.scrollY)
135
123
  let last = position()
136
124
  const tick = () => {
@@ -42,6 +42,7 @@ import {
42
42
  type SpringState,
43
43
  } from "../composables/usePillTransition"
44
44
  import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
45
+ import { prefersReducedMotion } from "../composables/easing"
45
46
 
46
47
  const props = withDefaults(
47
48
  defineProps<{
@@ -116,9 +117,6 @@ const shared = injectTooltipProviderContext(null)
116
117
  */
117
118
  const PassThrough: FunctionalComponent = (_, { slots }) => slots.default?.()
118
119
 
119
- const reducedMotion = () =>
120
- typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
121
-
122
120
  const target = { x: 0, y: 0 }
123
121
  const offset = ref({ x: 0, y: 0 })
124
122
 
@@ -174,7 +172,7 @@ const onPointerMove = (e: PointerEvent) => {
174
172
  const dir = props.side === "bottom" ? -1 : 1
175
173
  target.y = e.clientY - (rect.top + rect.height / 2) + half * dir
176
174
  // Without motion the tip goes straight to the reading instead of trailing.
177
- if (reducedMotion()) {
175
+ if (prefersReducedMotion()) {
178
176
  offset.value = { x: target.x, y: target.y }
179
177
  return
180
178
  }