@leavepulse/ui 0.59.0 → 0.61.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 (168) hide show
  1. package/README.md +8 -0
  2. package/dist/assets/{LpCommandPalette-BM_IyzwB.css → LpCommandPalette-DA5pmuPi.css} +21 -21
  3. package/dist/assets/{LpTable-B_QCQCbB.css → LpTable-DpXyPzs8.css} +19 -19
  4. package/dist/chunks/{ContextMenuItems-CioDScj3.js → ContextMenuItems-qXReFtQW.js} +1 -1
  5. package/dist/chunks/{LayoutCanvas-BcGLJVBq.js → LayoutCanvas-t7LWFCEn.js} +1 -1
  6. package/dist/chunks/{LayoutNode-BkBn6dvM.js → LayoutNode-DHsE6GwM.js} +1 -1
  7. package/dist/chunks/{LpActionBar-DCN-_pZP.js → LpActionBar-BsSp66S_.js} +2 -2
  8. package/dist/chunks/{LpAddressInput-D-H94m4q.js → LpAddressInput-CzdZguBo.js} +7 -7
  9. package/dist/chunks/{LpAlert-CXMn1GYE.js → LpAlert--KLL4NLk.js} +1 -1
  10. package/dist/chunks/{LpAppShell-BeMuKEjh.js → LpAppShell-BXMNDEpF.js} +3 -3
  11. package/dist/chunks/{LpAutocomplete-hJ_6CHlh.js → LpAutocomplete-D9mY5Tqk.js} +102 -37
  12. package/dist/chunks/{LpAvatar-CeW4Z5CL.js → LpAvatar-K6MOMfgn.js} +2 -2
  13. package/dist/chunks/{LpBreadcrumbs-BLzOEgAF.js → LpBreadcrumbs-CndzPYiG.js} +1 -1
  14. package/dist/chunks/{LpButton-vhlwmpBy.js → LpButton-CJSD7N0Z.js} +1 -1
  15. package/dist/chunks/{LpCalendar-BLoE8Qm7.js → LpCalendar-CXVDbb2M.js} +1 -1
  16. package/dist/chunks/{LpCard-Gbr8a1xK.js → LpCard-D9HVgX8Y.js} +1 -1
  17. package/dist/chunks/{LpCheckbox-B_C8sOoT.js → LpCheckbox-DV9W6T3H.js} +1 -1
  18. package/dist/chunks/{LpCodeBlock-D6zhp30a.js → LpCodeBlock-t6MLA3fb.js} +1 -1
  19. package/dist/chunks/{LpColorPicker-CVXwjEtX.js → LpColorPicker-BO1ZTY51.js} +2 -2
  20. package/dist/chunks/{LpConfirmDialog-DkgIT-Xg.js → LpConfirmDialog-tkgyY3eu.js} +3 -3
  21. package/dist/chunks/{LpConfirmHost-CYzXWFOI.js → LpConfirmHost-CoYIcUdl.js} +1 -1
  22. package/dist/chunks/{LpContextMenu-DRFd-iyc.js → LpContextMenu-Cv_F2iCG.js} +20 -5
  23. package/dist/chunks/{LpDatePicker-B-VMHMgy.js → LpDatePicker-DxdrVNCG.js} +3 -3
  24. package/dist/chunks/{LpDateRangePicker-B68KPKPl.js → LpDateRangePicker-DjeaqikC.js} +2 -2
  25. package/dist/chunks/{LpDisclosure-Ct-VBHxt.js → LpDisclosure-s8cQ7KQq.js} +1 -1
  26. package/dist/chunks/{LpDrawer-Rc6eAJrr.js → LpDrawer-C97yoWmh.js} +1 -1
  27. package/dist/chunks/{LpDropdownMenu-BoJQsjzt.js → LpDropdownMenu-CbyYu4Rw.js} +11 -4
  28. package/dist/chunks/{LpEmptyState-CBwwkkdA.js → LpEmptyState-CX8fdGSU.js} +1 -1
  29. package/dist/chunks/{LpFieldNotice-9hH4ITZa.js → LpFieldNotice-B--RzKfj.js} +1 -1
  30. package/dist/chunks/{LpFileTree-Bs57DhVa.js → LpFileTree-DTwUj0L-.js} +2 -2
  31. package/dist/chunks/{LpFileTreeNode-XgJW65v5.js → LpFileTreeNode-D-XLPZ6T.js} +3 -3
  32. package/dist/chunks/{LpFormField-BpQ6WJPe.js → LpFormField-DMPPL1jF.js} +1 -1
  33. package/dist/chunks/{LpHintLayer-B5-6FyEf.js → LpHintLayer-DsclGYcD.js} +1 -1
  34. package/dist/chunks/{LpHints-BqoZXHZk.js → LpHints-BTpvisgP.js} +10 -4
  35. package/dist/chunks/{LpIcon-B5guGKrW.js → LpIcon-CTuDVTYv.js} +1 -1
  36. package/dist/chunks/{LpInput-WvyP02ID.js → LpInput-C7FHxzj2.js} +2 -2
  37. package/dist/chunks/{LpLightbox-Dq-e36uT.js → LpLightbox-DiksFa4L.js} +1 -1
  38. package/dist/chunks/{LpLogViewer-B92bAftG.js → LpLogViewer-D9UgNz2m.js} +2 -2
  39. package/dist/chunks/{LpMapPicker-BH5csaBt.js → LpMapPicker-DybIlWnF.js} +1 -1
  40. package/dist/chunks/{LpMediaGallery-B1kDypJ-.js → LpMediaGallery-Cic9UHsa.js} +2 -2
  41. package/dist/chunks/{LpModal-DvGD6kEe.js → LpModal-QFfctZsj.js} +1 -1
  42. package/dist/chunks/{LpModalNav-B-_zRTS-.js → LpModalNav-D4rmflQn.js} +4 -4
  43. package/dist/chunks/{LpNotificationBell-63pvatBj.js → LpNotificationBell-CyylOlZR.js} +4 -4
  44. package/dist/chunks/{LpNumberField-CJPjmoDz.js → LpNumberField-BFSRVGkJ.js} +1 -1
  45. package/dist/chunks/{LpPagination-DehAaJLY.js → LpPagination-BFl0I7tL.js} +1 -1
  46. package/dist/chunks/{LpPasswordInput-BK25_vNG.js → LpPasswordInput-C1G4k_r0.js} +2 -2
  47. package/dist/chunks/{LpPhoneInput-Bxg7a_qD.js → LpPhoneInput-CZrpVxla.js} +2 -2
  48. package/dist/chunks/{LpPie-Coa2GMHt.js → LpPie-B2deu8CI.js} +2 -2
  49. package/dist/chunks/{LpRangeCalendar-D5sDID9W.js → LpRangeCalendar-COeOYF52.js} +1 -1
  50. package/dist/chunks/{LpSection-B19VPYmr.js → LpSection-BAkbf0OK.js} +1 -1
  51. package/dist/chunks/{LpSegmented-DbrUjuSA.js → LpSegmented-DslFku0N.js} +1 -1
  52. package/dist/chunks/{LpSelect-DynjWK1A.js → LpSelect-C4OXN_q3.js} +1 -1
  53. package/dist/chunks/{LpSidebar-CKamWtPo.js → LpSidebar-D8tub3bt.js} +3 -3
  54. package/dist/chunks/{LpSidebarNav-Y4LV0ZEn.js → LpSidebarNav-BBSRg34G.js} +2 -2
  55. package/dist/chunks/{LpStat-BS2TEXYK.js → LpStat-oYk6V1LZ.js} +1 -1
  56. package/dist/chunks/{LpStepper-BSglWF45.js → LpStepper-YgHGzgfD.js} +1 -1
  57. package/dist/chunks/LpSwap-D82jiecV.js +38 -0
  58. package/dist/chunks/{LpTableFilter-CwHXVxJt.js → LpTableFilter-Ba-rS4H1.js} +2 -2
  59. package/dist/chunks/{LpTabs-D01b8jBI.js → LpTabs-00v3VQx4.js} +1 -1
  60. package/dist/chunks/{LpTagsInput-CHbQoAAA.js → LpTagsInput-Sc56iy_j.js} +3 -3
  61. package/dist/chunks/{LpTextarea-Bz2IWhTH.js → LpTextarea-CQZccQI3.js} +1 -1
  62. package/dist/chunks/{LpThemeSwitcher-CbzL6ipO.js → LpThemeSwitcher-ZFFftBQn.js} +2 -2
  63. package/dist/chunks/{LpTimeField-DvGXgdES.js → LpTimeField-BTEt0kvL.js} +1 -1
  64. package/dist/chunks/{LpToaster-C9EacEfR.js → LpToaster-D3OisETH.js} +2 -2
  65. package/dist/chunks/{LpTour-D7m9ZxNk.js → LpTour-C29-1wha.js} +4 -1
  66. package/dist/chunks/{MenuTitle-B9HdaBwE.js → MenuTitle-FeC6I24N.js} +1 -1
  67. package/dist/chunks/{TourCard-DCHOjr6e.js → TourCard-AjN0uhs1.js} +2 -2
  68. package/dist/chunks/{icons-QqBscG5I.js → icons-CuO3W46H.js} +1 -1
  69. package/dist/component-names.d.ts +1 -1
  70. package/dist/components/ContextMenuItems.vue.js +1 -1
  71. package/dist/components/LayoutCanvas.vue.js +1 -1
  72. package/dist/components/LayoutNode.vue.js +1 -1
  73. package/dist/components/LpActionBar.vue.js +1 -1
  74. package/dist/components/LpAddressInput.vue.js +1 -1
  75. package/dist/components/LpAlert.vue.js +1 -1
  76. package/dist/components/LpAppShell.vue.js +1 -1
  77. package/dist/components/LpAutocomplete.vue.d.ts +24 -0
  78. package/dist/components/LpAutocomplete.vue.js +1 -1
  79. package/dist/components/LpAvatar.vue.js +1 -1
  80. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  81. package/dist/components/LpButton.vue.js +1 -1
  82. package/dist/components/LpCalendar.vue.js +1 -1
  83. package/dist/components/LpCard.vue.js +1 -1
  84. package/dist/components/LpChart.vue.js +2 -2
  85. package/dist/components/LpCheckbox.vue.js +1 -1
  86. package/dist/components/LpCodeBlock.vue.js +1 -1
  87. package/dist/components/LpColorPicker.vue.js +1 -1
  88. package/dist/components/LpCommandPalette.vue.js +30 -19
  89. package/dist/components/LpConfirmDialog.vue.js +1 -1
  90. package/dist/components/LpConfirmHost.vue.js +1 -1
  91. package/dist/components/LpContextMenu.vue.d.ts +7 -2
  92. package/dist/components/LpContextMenu.vue.js +1 -1
  93. package/dist/components/LpDatePicker.vue.js +1 -1
  94. package/dist/components/LpDateRangePicker.vue.js +1 -1
  95. package/dist/components/LpDisclosure.vue.js +1 -1
  96. package/dist/components/LpDrawer.vue.js +1 -1
  97. package/dist/components/LpDropdownMenu.vue.d.ts +7 -2
  98. package/dist/components/LpDropdownMenu.vue.js +1 -1
  99. package/dist/components/LpDropzone.vue.js +1 -1
  100. package/dist/components/LpEmptyState.vue.js +1 -1
  101. package/dist/components/LpFieldNotice.vue.js +1 -1
  102. package/dist/components/LpFileTree.vue.js +1 -1
  103. package/dist/components/LpFileTreeNode.vue.js +1 -1
  104. package/dist/components/LpFormField.vue.js +1 -1
  105. package/dist/components/LpHintLayer.vue.js +1 -1
  106. package/dist/components/LpHints.vue.js +1 -1
  107. package/dist/components/LpIcon.vue.js +1 -1
  108. package/dist/components/LpImageEditor.vue.js +4 -2
  109. package/dist/components/LpInput.vue.js +1 -1
  110. package/dist/components/LpLightbox.vue.js +1 -1
  111. package/dist/components/LpLogViewer.vue.js +1 -1
  112. package/dist/components/LpMapPicker.vue.js +1 -1
  113. package/dist/components/LpMediaGallery.vue.js +1 -1
  114. package/dist/components/LpModal.vue.js +1 -1
  115. package/dist/components/LpModalNav.vue.js +1 -1
  116. package/dist/components/LpNotificationBell.vue.js +1 -1
  117. package/dist/components/LpNumberField.vue.js +1 -1
  118. package/dist/components/LpPagination.vue.js +1 -1
  119. package/dist/components/LpPaletteFacts.vue.js +1 -1
  120. package/dist/components/LpPaletteHeader.vue.js +2 -2
  121. package/dist/components/LpPasswordInput.vue.js +1 -1
  122. package/dist/components/LpPhoneInput.vue.js +1 -1
  123. package/dist/components/LpPie.vue.js +1 -1
  124. package/dist/components/LpRangeCalendar.vue.js +1 -1
  125. package/dist/components/LpSection.vue.js +1 -1
  126. package/dist/components/LpSegmented.vue.js +1 -1
  127. package/dist/components/LpSelect.vue.js +1 -1
  128. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  129. package/dist/components/LpSidebar.vue.js +1 -1
  130. package/dist/components/LpSidebarNav.vue.js +1 -1
  131. package/dist/components/LpStat.vue.js +1 -1
  132. package/dist/components/LpStepper.vue.js +1 -1
  133. package/dist/components/LpSwap.vue.d.ts +19 -0
  134. package/dist/components/LpSwap.vue.js +4 -0
  135. package/dist/components/LpTable.vue.js +13 -11
  136. package/dist/components/LpTableFilter.vue.js +1 -1
  137. package/dist/components/LpTabs.vue.js +1 -1
  138. package/dist/components/LpTagsInput.vue.js +1 -1
  139. package/dist/components/LpTextarea.vue.js +1 -1
  140. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  141. package/dist/components/LpTimeField.vue.js +1 -1
  142. package/dist/components/LpToaster.vue.js +1 -1
  143. package/dist/components/LpTour.vue.js +1 -1
  144. package/dist/components/MenuTitle.vue.js +1 -1
  145. package/dist/components/TourCard.vue.js +1 -1
  146. package/dist/composables/useHintSystem.d.ts +67 -0
  147. package/dist/composables/useHintSystem.js +110 -12
  148. package/dist/composables/useToast.d.ts +13 -4
  149. package/dist/composables/useToast.js +13 -2
  150. package/dist/icons-all.js +1 -1
  151. package/dist/index.d.ts +5 -4
  152. package/dist/index.js +101 -95
  153. package/package.json +2 -1
  154. package/src/component-names.ts +1 -0
  155. package/src/components/LpAutocomplete.vue +110 -24
  156. package/src/components/LpCommandPalette.vue +26 -4
  157. package/src/components/LpContextMenu.vue +22 -1
  158. package/src/components/LpDropdownMenu.vue +13 -2
  159. package/src/components/LpHints.vue +7 -1
  160. package/src/components/LpSwap.vue +45 -0
  161. package/src/components/LpTable.vue +2 -1
  162. package/src/components/LpToaster.vue +1 -1
  163. package/src/components/LpTour.vue +3 -0
  164. package/src/composables/useHintSystem.ts +152 -12
  165. package/src/composables/useToast.ts +32 -5
  166. package/src/index.ts +5 -2
  167. package/src/kit-icons.ts +1 -1
  168. package/src/tokens/selection.css +20 -0
@@ -25,7 +25,7 @@ import {
25
25
  ComboboxRoot,
26
26
  ComboboxViewport,
27
27
  } from "reka-ui"
28
- import { computed, ref, useAttrs, watch } from "vue"
28
+ import { computed, nextTick, ref, useAttrs, watch } from "vue"
29
29
  import { useFieldControl } from "../composables/useField"
30
30
  import { useInputFilter } from "../composables/useInputFilter"
31
31
  import { useLabels } from "../composables/useLpConfig"
@@ -36,6 +36,15 @@ export interface AutocompleteOption {
36
36
  value: string
37
37
  label?: string
38
38
  description?: string
39
+ /** A picture beside the option: an avatar, an icon of a server. */
40
+ image?: string
41
+ }
42
+
43
+ /** What the field's value stands for: a name for an id, with an optional muted hint and picture. */
44
+ export interface AutocompleteSelection {
45
+ label: string
46
+ hint?: string
47
+ image?: string
39
48
  }
40
49
 
41
50
  const props = withDefaults(
@@ -65,6 +74,14 @@ const props = withDefaults(
65
74
  pattern?: RegExp | string
66
75
  /** Allowed-character RegExp; hard-blocks other input (see LpInput). */
67
76
  restrict?: RegExp
77
+ /**
78
+ * What the current value stands for, shown in the field itself while it is
79
+ * not being edited: a picked id reads as a person (avatar in place of the
80
+ * icon, name, muted hint) instead of digits nobody recognises, and nothing
81
+ * appears or jumps below the field. Focusing it brings the raw text back.
82
+ * When room runs short the hint gives way first; the name stays.
83
+ */
84
+ selected?: AutocompleteSelection | null
68
85
  }>(),
69
86
  {
70
87
  placeholder: "Type…",
@@ -136,6 +153,12 @@ const filtered = computed(() => {
136
153
 
137
154
  const open = ref(false)
138
155
  const focused = ref(false)
156
+ const showSelected = computed(() => !!props.selected?.label && !focused.value)
157
+ // Focusing a picked value starts a new search from its name, not from the raw
158
+ // value: the name is put in the box selected, so typing replaces it, and
159
+ // leaving without typing keeps the pick as it was.
160
+ const draft = ref<string | null>(null)
161
+ const showImage = computed(() => !!props.selected?.image && (!focused.value || draft.value !== null))
139
162
 
140
163
  // Threshold met AND something to show → the list is allowed to open.
141
164
  const canOpen = computed(
@@ -174,19 +197,31 @@ const anchorSize = {
174
197
  function onInput(event: Event) {
175
198
  filterInput(event)
176
199
  typing.value = true
200
+ draft.value = null
177
201
  text.value = (event.target as HTMLInputElement).value
178
202
  }
179
203
 
180
- function onFocus() {
204
+ function onFocus(event: FocusEvent) {
181
205
  // Arriving at a field that already holds a value shows every option, not just
182
206
  // the one it currently holds.
183
207
  typing.value = false
184
208
  focused.value = true
209
+ if (props.selected?.label) {
210
+ draft.value = props.selected.label
211
+ const input = event.target as HTMLInputElement
212
+ void nextTick(() => input.select())
213
+ }
185
214
  if (canOpen.value) open.value = true
186
215
  }
187
216
 
217
+ function onBlur() {
218
+ focused.value = false
219
+ draft.value = null
220
+ }
221
+
188
222
  function choose(opt: AutocompleteOption) {
189
223
  typing.value = false
224
+ draft.value = null
190
225
  emit("update:modelValue", opt.value)
191
226
  emit("select", opt.value)
192
227
  open.value = false
@@ -194,6 +229,7 @@ function choose(opt: AutocompleteOption) {
194
229
 
195
230
  function clear() {
196
231
  typing.value = false
232
+ draft.value = null
197
233
  emit("update:modelValue", "")
198
234
  open.value = false
199
235
  }
@@ -220,7 +256,28 @@ function clear() {
220
256
  : 'border-line focus-within:border-brand',
221
257
  ]"
222
258
  >
223
- <LpIcon v-if="icon" :name="icon" :size="16" class="shrink-0 text-muted" />
259
+ <!-- Picture and icon share one fixed box and cross-fade, so the text
260
+ beside them never moves when one gives way to the other. -->
261
+ <span
262
+ v-if="icon || selected?.image"
263
+ class="relative grid shrink-0 place-items-center"
264
+ :class="selected?.image ? 'size-5' : 'size-4'"
265
+ >
266
+ <LpIcon
267
+ v-if="icon"
268
+ :name="icon"
269
+ :size="16"
270
+ class="col-start-1 row-start-1 text-muted transition-[opacity,scale] duration-[var(--duration-fast)] motion-reduce:transition-none"
271
+ :class="showImage ? 'scale-75 opacity-0' : 'opacity-100'"
272
+ />
273
+ <img
274
+ v-if="selected?.image"
275
+ :src="selected.image"
276
+ alt=""
277
+ class="col-start-1 row-start-1 size-5 rounded-full object-cover transition-[opacity,scale] duration-[var(--duration-fast)] motion-reduce:transition-none"
278
+ :class="showImage ? 'opacity-100' : 'scale-75 opacity-0'"
279
+ />
280
+ </span>
224
281
  <!--
225
282
  Bind value one-way and update from the raw DOM event, NOT via reka's
226
283
  ComboboxInput v-model: reka defers the value/search update to nextTick
@@ -228,23 +285,49 @@ function clear() {
228
285
  keystroke lag by one char. Reading event.target.value here is synchronous
229
286
  so `text` (and the reactive `open`) track the field exactly.
230
287
  -->
231
- <ComboboxInput
232
- v-bind="inputAttrs"
233
- :id="field.id"
234
- :model-value="text"
235
- :placeholder="placeholder"
236
- :disabled="field.disabled"
237
- :required="field.required || undefined"
238
- :aria-invalid="isInvalidState || undefined"
239
- :aria-describedby="field.describedBy"
240
- class="lp-autofill min-w-0 flex-1 self-stretch bg-transparent outline-none placeholder:text-muted"
241
- @beforeinput="onBeforeInput"
242
- @paste="onPaste"
243
- @compositionend="onCompositionEnd"
244
- @input="onInput"
245
- @focus="onFocus"
246
- @blur="focused = false"
247
- />
288
+ <div class="relative flex min-w-0 flex-1 self-stretch">
289
+ <ComboboxInput
290
+ v-bind="inputAttrs"
291
+ :id="field.id"
292
+ :model-value="draft ?? text"
293
+ :placeholder="placeholder"
294
+ :disabled="field.disabled"
295
+ :required="field.required || undefined"
296
+ :aria-invalid="isInvalidState || undefined"
297
+ :aria-describedby="field.describedBy"
298
+ class="lp-autofill min-w-0 flex-1 self-stretch bg-transparent outline-none placeholder:text-muted"
299
+ :class="showSelected && 'text-transparent placeholder:text-transparent'"
300
+ @beforeinput="onBeforeInput"
301
+ @paste="onPaste"
302
+ @compositionend="onCompositionEnd"
303
+ @input="onInput"
304
+ @focus="onFocus"
305
+ @blur="onBlur"
306
+ />
307
+ <!-- Focus and blur do not animate the name: it sits exactly where the
308
+ input's own text sits, so that swap cannot be seen. The hint, which
309
+ exists in one state alone, eases in and out beside it, and the
310
+ whole line fades when a pick appears or is cleared. -->
311
+ <Transition
312
+ enter-active-class="transition-opacity duration-[var(--duration-fast)] motion-reduce:transition-none"
313
+ leave-active-class="transition-opacity duration-[var(--duration-fast)] motion-reduce:transition-none"
314
+ enter-from-class="opacity-0"
315
+ leave-to-class="opacity-0"
316
+ >
317
+ <span
318
+ v-if="selected?.label"
319
+ class="pointer-events-none absolute inset-0 flex min-w-0 items-center gap-1.5"
320
+ aria-hidden="true"
321
+ >
322
+ <span class="max-w-full shrink-0 truncate" :class="showSelected ? '' : 'invisible'">{{ selected.label }}</span>
323
+ <span
324
+ v-if="selected.hint"
325
+ class="min-w-0 truncate text-sm text-muted transition-[opacity,translate] duration-[var(--duration-fast)] motion-reduce:transition-none"
326
+ :class="showSelected ? 'opacity-100' : '-translate-x-1 opacity-0'"
327
+ >{{ selected.hint }}</span>
328
+ </span>
329
+ </Transition>
330
+ </div>
248
331
  <button
249
332
  v-if="clearable && hasValue"
250
333
  type="button"
@@ -285,10 +368,13 @@ function clear() {
285
368
  :class="[OPTION_ITEM, 'justify-between']"
286
369
  @select.prevent="choose(opt)"
287
370
  >
288
- <span class="flex min-w-0 flex-col">
289
- <span class="truncate">{{ opt.label ?? opt.value }}</span>
290
- <span v-if="opt.description" class="truncate text-xs text-muted">
291
- {{ opt.description }}
371
+ <span class="flex min-w-0 items-center gap-2.5">
372
+ <img v-if="opt.image" :src="opt.image" alt="" class="size-6 shrink-0 rounded-full object-cover" />
373
+ <span class="flex min-w-0 flex-col">
374
+ <span class="truncate">{{ opt.label ?? opt.value }}</span>
375
+ <span v-if="opt.description" class="truncate text-xs text-muted">
376
+ {{ opt.description }}
377
+ </span>
292
378
  </span>
293
379
  </span>
294
380
  </ComboboxItem>
@@ -44,6 +44,8 @@ import { useAsyncTask } from "../composables/useAsyncTask"
44
44
  import { useGlass } from "../composables/useGlass"
45
45
  import { useClipboard } from "../composables/useClipboard"
46
46
  import { useHotkeys } from "../composables/useHotkeys"
47
+ import { hints } from "../composables/useHints"
48
+ import { HINT_SIGNALS, runningHintSystem } from "../composables/useHintSystem"
47
49
  import { useModalLayer } from "./modalDepth"
48
50
  import { useLabels } from "../composables/useLpConfig"
49
51
  import {
@@ -516,9 +518,20 @@ function onTab(event: KeyboardEvent) {
516
518
  event.stopPropagation()
517
519
  const actionsMode: Command = { id: `${c.id}:actions`, label: c.label, icon: c.icon, children: actions }
518
520
  syntheticModes.add(actionsMode)
521
+ hints.use(HINT_SIGNALS.paletteActions)
519
522
  enter(actionsMode)
520
523
  }
521
524
 
525
+ // A returning user who never pressed Tab gets the footer key lit up, nothing
526
+ // more: the palette is the one place they already came to on purpose.
527
+ const PALETTE_ACTIONS_AFTER = 3
528
+ const showActionsKey = computed(() => {
529
+ const system = runningHintSystem()
530
+ return !!system?.state.loaded
531
+ && system.count(HINT_SIGNALS.palette) >= PALETTE_ACTIONS_AFTER
532
+ && system.count(HINT_SIGNALS.paletteActions) === 0
533
+ })
534
+
522
535
  /*
523
536
  * A mode holds the command it was entered through, and a static `children`
524
537
  * list is a snapshot of that moment. When the app rebuilds its commands (a
@@ -1162,8 +1175,10 @@ function onEscape(event: KeyboardEvent) {
1162
1175
  watch(
1163
1176
  () => props.open,
1164
1177
  async (o) => {
1165
- if (o) claim()
1166
- else release()
1178
+ if (o) {
1179
+ claim()
1180
+ hints.use(HINT_SIGNALS.palette)
1181
+ } else release()
1167
1182
  cancel()
1168
1183
  query.value = ""
1169
1184
  select(0)
@@ -1461,8 +1476,15 @@ useHotkeys(() =>
1461
1476
  </span>
1462
1477
  <!-- Only inside a mode: at the root Backspace just edits the query,
1463
1478
  and advertising it there would be a lie. -->
1464
- <span v-if="selectedActions.length" class="flex items-center gap-1">
1465
- <kbd class="rounded-small border border-line px-1 text-[10px]">tab</kbd>
1479
+ <span
1480
+ v-if="selectedActions.length"
1481
+ class="flex items-center gap-1 transition-colors"
1482
+ :class="showActionsKey && 'font-medium text-brand'"
1483
+ >
1484
+ <kbd
1485
+ class="rounded-small border px-1 text-[10px] transition-colors"
1486
+ :class="showActionsKey ? 'border-brand' : 'border-line'"
1487
+ >tab</kbd>
1466
1488
  {{ text.actions }}
1467
1489
  </span>
1468
1490
  <span v-if="trail.length" class="flex items-center gap-1">
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import { ContextMenuContent, ContextMenuPortal, ContextMenuRoot, ContextMenuTrigger } from "reka-ui"
10
10
  import { computed } from "vue"
11
+ import { hints } from "../composables/useHints"
12
+ import { HINT_SIGNALS, noteContextTarget } from "../composables/useHintSystem"
11
13
  import ContextMenuItems from "./ContextMenuItems.vue"
12
14
  import MenuTitle from "./MenuTitle.vue"
13
15
  import { POPOVER_PANEL } from "./dropdown"
@@ -52,6 +54,11 @@ const props = defineProps<{
52
54
  /** An icon beside the title, or `titleImage` for a picture (an avatar, a logo). */
53
55
  titleIcon?: string
54
56
  titleImage?: string
57
+ /**
58
+ * Do not count this element as "has a right-click menu" for hint tips: for
59
+ * menus that only hold generic items (a whole table without row actions).
60
+ */
61
+ noHint?: boolean
55
62
  }>()
56
63
 
57
64
  // With no items the trigger is disabled, so the element keeps the browser's
@@ -59,6 +66,7 @@ const props = defineProps<{
59
66
  // per element. `always` overrides this so a late-populated menu is ready on the
60
67
  // very first click.
61
68
  const enabled = computed(() => props.always || props.items.length > 0)
69
+ const hinted = computed(() => enabled.value && !props.noHint)
62
70
 
63
71
  const PANEL = `${POPOVER_PANEL} z-(--z-popover) min-w-48 p-1 outline-none`
64
72
 
@@ -91,6 +99,17 @@ function keepContextMenu(event: Event) {
91
99
  if (enabled.value) event.stopPropagation()
92
100
  }
93
101
 
102
+ function opened(open: boolean) {
103
+ if (open) hints.use(HINT_SIGNALS.contextMenu)
104
+ }
105
+
106
+ function leftClick(event: MouseEvent) {
107
+ if (!hinted.value) return
108
+ hints.use(HINT_SIGNALS.contextTarget)
109
+ const el = event.currentTarget as Element
110
+ noteContextTarget((event.target as Element).closest?.("[data-lp-context-row]") ?? el)
111
+ }
112
+
94
113
  function returnFocus(event: Event) {
95
114
  const saved = field
96
115
  field = null
@@ -106,10 +125,12 @@ function returnFocus(event: Event) {
106
125
  the reka trigger remounted the wrapped content whenever items arrived
107
126
  late, losing its state and focus. A disabled trigger leaves the browser's
108
127
  own menu in place. -->
109
- <ContextMenuRoot>
128
+ <ContextMenuRoot @update:open="opened">
110
129
  <ContextMenuTrigger
111
130
  as-child
112
131
  :disabled="!enabled"
132
+ :data-lp-context-menu="hinted ? '' : undefined"
133
+ @click.capture="leftClick"
113
134
  @contextmenu.capture="rememberField"
114
135
  @contextmenu="keepContextMenu"
115
136
  >
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { hints } from "../composables/useHints"
3
+ import { HINT_SIGNALS } from "../composables/useHintSystem"
2
4
  import {
3
5
  DropdownMenuContent,
4
6
  DropdownMenuItem,
@@ -20,18 +22,27 @@ export interface MenuItem {
20
22
  onSelect?: () => void
21
23
  }
22
24
 
23
- defineProps<{
25
+ const props = defineProps<{
24
26
  items: MenuItem[]
25
27
  /** A heading naming what the menu acts on; see LpContextMenu's `title`. */
26
28
  title?: string
27
29
  subtitle?: string
28
30
  titleIcon?: string
29
31
  titleImage?: string
32
+ /**
33
+ * The same items are also on a right click (a row's "…" next to its context
34
+ * menu). Opening it then counts toward the hint system's right-click tip.
35
+ */
36
+ rightClickToo?: boolean
30
37
  }>()
38
+
39
+ function opened(open: boolean) {
40
+ if (open && props.rightClickToo) hints.use(HINT_SIGNALS.menuButton)
41
+ }
31
42
  </script>
32
43
 
33
44
  <template>
34
- <DropdownMenuRoot>
45
+ <DropdownMenuRoot @update:open="opened">
35
46
  <DropdownMenuTrigger as-child>
36
47
  <slot name="trigger" />
37
48
  </DropdownMenuTrigger>
@@ -68,6 +68,7 @@ const title = computed(() => {
68
68
  const o = offer.value
69
69
  if (!o) return ""
70
70
  if (o.kind === "nudge") return notes.value[o.nudge.feature]?.title ?? ""
71
+ if (o.kind === "tip") return o.tip.title
71
72
  if (o.kind === "news") return fill(labels.value.newsTitle)
72
73
  return fill(o.kind === "assigned" ? labels.value.assignedTitle : labels.value.tourTitle)
73
74
  })
@@ -76,6 +77,7 @@ const description = computed(() => {
76
77
  const o = offer.value
77
78
  if (!o) return ""
78
79
  if (o.kind === "nudge") return o.nudge.text
80
+ if (o.kind === "tip") return o.tip.keys ? `${o.tip.text} ${o.tip.keys}` : o.tip.text
79
81
  if (o.kind === "news") return o.items.map((n) => n.text).join(" ")
80
82
  return o.kind === "assigned" ? labels.value.assignedText : labels.value.tourText
81
83
  })
@@ -86,6 +88,10 @@ const actions = computed<ActionBarAction[]>(() => {
86
88
  const answer = (choice: "show" | "later" | "never") => () => props.system.answer(choice)
87
89
  const show: ActionBarAction = { label: labels.value.show, icon: "lucide:sparkles", onClick: answer("show") }
88
90
  if (o.kind === "nudge") return [{ label: labels.value.noThanks, variant: "ghost", onClick: answer("never") }, show]
91
+ if (o.kind === "tip") {
92
+ const later: ActionBarAction = { label: labels.value.gotIt, variant: o.tip.step ? "ghost" : undefined, onClick: answer("later") }
93
+ return [{ label: labels.value.never, variant: "ghost", onClick: answer("never") }, later, ...(o.tip.step ? [show] : [])]
94
+ }
89
95
  if (o.kind === "news") return [{ label: labels.value.gotIt, variant: "ghost", onClick: answer("later") }, show]
90
96
  return [
91
97
  ...(o.kind === "tour" ? [{ label: labels.value.never, variant: "ghost" as const, onClick: answer("never") }] : []),
@@ -109,7 +115,7 @@ const actions = computed<ActionBarAction[]>(() => {
109
115
  :open="!!offer"
110
116
  :title="title"
111
117
  :description="description"
112
- :icon="offer?.kind === 'news' ? 'lucide:gift' : 'lucide:compass'"
118
+ :icon="offer?.kind === 'news' ? 'lucide:gift' : offer?.kind === 'tip' ? 'lucide:lightbulb' : 'lucide:compass'"
113
119
  :actions="actions"
114
120
  />
115
121
  </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * One piece of content standing in for another: the section picked in a side
4
+ * nav, the step of a wizard, the view chosen in a segmented control. Without
5
+ * it the old content vanishes and the new one appears in the same frame, and
6
+ * everything below jumps to the new height.
7
+ *
8
+ * <LpSidebar v-model="section" :items="items" />
9
+ * <LpSwap :swap-key="section">
10
+ * <SectionBody :name="section" />
11
+ * </LpSwap>
12
+ *
13
+ * The outgoing content fades out quickly, the incoming one rises in the way
14
+ * LpTabs panels do (`tab-in`), and the box eases between the two heights
15
+ * through LpShift, so what sits under it glides instead of jolting.
16
+ *
17
+ * `swapKey` is what decides that the content is "another one". Change it per
18
+ * section, not per record: a key that changes on every reload replays the
19
+ * entrance for data that merely refreshed.
20
+ */
21
+ import LpShift from "./LpShift.vue"
22
+
23
+ defineProps<{
24
+ /** Identity of the current content; a new value plays the swap. */
25
+ swapKey: string | number
26
+ /** Turn the motion off (the content still swaps). */
27
+ disabled?: boolean
28
+ }>()
29
+ </script>
30
+
31
+ <template>
32
+ <LpShift axis="height" :disabled="disabled">
33
+ <Transition
34
+ mode="out-in"
35
+ :css="!disabled"
36
+ enter-active-class="animate-[tab-in_220ms_var(--ease-emphasized)] motion-reduce:animate-none"
37
+ leave-active-class="transition-opacity duration-[var(--duration-fast)] ease-out motion-reduce:transition-none"
38
+ leave-to-class="opacity-0"
39
+ >
40
+ <div :key="swapKey">
41
+ <slot />
42
+ </div>
43
+ </Transition>
44
+ </LpShift>
45
+ </template>
@@ -1157,7 +1157,7 @@ const barInsetTop = computed(() =>
1157
1157
  <!-- The table-wide menu, which is what a right-click on empty header space
1158
1158
  reaches: rows and headers carry their own and take the event first, so
1159
1159
  this is the fallback rather than a competitor. -->
1160
- <LpContextMenu :items="contextMenu" always>
1160
+ <LpContextMenu :items="contextMenu" always :no-hint="!rowMenu">
1161
1161
  <table ref="tableEl" class="w-full border-collapse text-sm">
1162
1162
  <!-- The background belongs on `thead` itself, not only on its row:
1163
1163
  `border-collapse` collapses the borders, and with a sticky header the
@@ -1293,6 +1293,7 @@ const barInsetTop = computed(() =>
1293
1293
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
1294
1294
  v-bind="roving.itemProps(String(keyFor(row, index)))"
1295
1295
  :data-paint-key="selectable ? String(keyFor(row, index)) : undefined"
1296
+ :data-lp-context-row="rowMenu ? '' : undefined"
1296
1297
  @click="onRowClick(row, keyFor(row, index))"
1297
1298
  @pointerdown="onRowPointerDown($event, keyFor(row, index))"
1298
1299
  @keydown="onRowKeydown"
@@ -225,7 +225,7 @@ let copiedTimer: ReturnType<typeof setTimeout> | undefined
225
225
  const copyable = (t: ToastItem) => t.variant === "error" || t.variant === "warning"
226
226
 
227
227
  async function copyToast(t: ToastItem) {
228
- if (!(await copy([t.title, t.message].filter(Boolean).join("\n")))) return
228
+ if (!(await copy([t.title, t.message, t.details].filter(Boolean).join("\n")))) return
229
229
  copiedId.value = tag(t)
230
230
  clearTimeout(copiedTimer)
231
231
  copiedTimer = setTimeout(() => (copiedId.value = null), 1500)
@@ -270,10 +270,12 @@ watch(
270
270
  if (value) {
271
271
  direction = 1
272
272
  document.addEventListener("click", onTargetClick, true)
273
+ document.addEventListener("contextmenu", onTargetClick, true)
273
274
  frame ||= requestAnimationFrame(track)
274
275
  void show()
275
276
  } else {
276
277
  document.removeEventListener("click", onTargetClick, true)
278
+ document.removeEventListener("contextmenu", onTargetClick, true)
277
279
  end()
278
280
  }
279
281
  },
@@ -286,6 +288,7 @@ watch(index, () => {
286
288
 
287
289
  onBeforeUnmount(() => {
288
290
  document.removeEventListener("click", onTargetClick, true)
291
+ document.removeEventListener("contextmenu", onTargetClick, true)
289
292
  if (open.value) end()
290
293
  })
291
294