@leavepulse/ui 0.44.0 → 0.45.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 (265) hide show
  1. package/README.md +49 -3
  2. package/dist/assets/{LpCommandPalette-DCP5g2ik.css → LpCommandPalette-nGNnzXqQ.css} +21 -21
  3. package/dist/assets/{LpRollingText-CR1mb0Vt.css → LpRollingText-BGnE6q9j.css} +3 -0
  4. package/dist/assets/{LpShortcutsDialog-ChUyU7oV.css → LpShortcutsDialog-CqYX3yy6.css} +28 -7
  5. package/dist/assets/{LpTable-DDQNdf86.css → LpTable-CPwWxTJS.css} +49 -9
  6. package/dist/chunks/{ContextMenuItems-CrfeNPWT.js → ContextMenuItems-BtKU7074.js} +2 -1
  7. package/dist/chunks/{LayoutCanvas-D-jPOAvg.js → LayoutCanvas-BkaGXTjx.js} +1 -1
  8. package/dist/chunks/{LayoutNode-D8Xy9N-o.js → LayoutNode-B3_9E1Xa.js} +9 -5
  9. package/dist/chunks/{LpActionBar-BbwCF6l5.js → LpActionBar-CnfFVIwC.js} +3 -3
  10. package/dist/chunks/{LpAddressInput-BrOqkZqg.js → LpAddressInput-BwX8uo4_.js} +7 -7
  11. package/dist/chunks/{LpAlert-DkOIjsbq.js → LpAlert-B6ilrM7T.js} +1 -1
  12. package/dist/chunks/{LpAppShell-BUKPcHco.js → LpAppShell-C53_RaGF.js} +4 -4
  13. package/dist/chunks/{LpAutocomplete-DxV3Y9h4.js → LpAutocomplete-C8IgSMsi.js} +2 -1
  14. package/dist/chunks/{LpAvatar-ClBbEYZN.js → LpAvatar-C1XStdOo.js} +3 -3
  15. package/dist/chunks/{LpBreadcrumbs-B8IJgFIc.js → LpBreadcrumbs-CQJd73Gm.js} +1 -1
  16. package/dist/chunks/{LpButton-kqJBHIIb.js → LpButton-CKSwC6Mu.js} +1 -1
  17. package/dist/chunks/{LpCalendar-B7Ko5RdR.js → LpCalendar-DrA2McTU.js} +1 -1
  18. package/dist/chunks/{LpCard-CpRByR0q.js → LpCard-C2IvucmX.js} +1 -1
  19. package/dist/chunks/{LpCheckbox-DpqchzH4.js → LpCheckbox-Bkj13CbW.js} +1 -1
  20. package/dist/chunks/{LpCodeBlock-DeHJPq7p.js → LpCodeBlock-ca7ANFyW.js} +1 -1
  21. package/dist/chunks/{LpColorPicker-Bhf1qxhB.js → LpColorPicker-zz4VGkG4.js} +46 -20
  22. package/dist/chunks/{LpConfirmDialog-CIUOSnWo.js → LpConfirmDialog-DCw5jR1t.js} +3 -3
  23. package/dist/chunks/{LpConfirmHost-CDm0Wtpv.js → LpConfirmHost-CbywMpMP.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-DQw5dGfG.js → LpContextMenu-BqkAYa9X.js} +2 -1
  25. package/dist/chunks/{LpDatePicker-Drirx1AK.js → LpDatePicker-DuusgT69.js} +13 -9
  26. package/dist/chunks/LpDateRangePicker-DHeuznfC.js +208 -0
  27. package/dist/chunks/{LpDisclosure-ChQYmjlR.js → LpDisclosure-Cc23-JOT.js} +1 -1
  28. package/dist/chunks/{LpDrawer-bScx-KMO.js → LpDrawer-Dt4aB4Fr.js} +2 -2
  29. package/dist/chunks/{LpDropdownMenu-DJpoqRCb.js → LpDropdownMenu-DdJGQzPn.js} +2 -1
  30. package/dist/chunks/{LpEmptyState-Dsdqz1Wj.js → LpEmptyState-BJbZXC8D.js} +1 -1
  31. package/dist/chunks/LpFieldNotice-wtieLUTj.js +64 -0
  32. package/dist/chunks/{LpFileTree-CSI_5SK2.js → LpFileTree-CNurOvkV.js} +3 -3
  33. package/dist/chunks/{LpFileTreeNode-B4MiSQX2.js → LpFileTreeNode-D_qRRdsH.js} +3 -3
  34. package/dist/chunks/{LpFormField-CIk5qosh.js → LpFormField-CtVYj6tO.js} +29 -15
  35. package/dist/chunks/{LpIcon-CCnX5_2j.js → LpIcon-DYtaid0K.js} +20 -6
  36. package/dist/chunks/{LpInput-C0e8y8_d.js → LpInput-D-YGQbEv.js} +39 -16
  37. package/dist/chunks/{LpLightbox-Mr0W_6HK.js → LpLightbox-DSDahF23.js} +1 -1
  38. package/dist/chunks/{LpLogViewer-YBOaFnc_.js → LpLogViewer-KypbgOyo.js} +252 -58
  39. package/dist/chunks/{LpMapPicker-NUzS9e37.js → LpMapPicker-Cc84LaM3.js} +1 -1
  40. package/dist/chunks/{LpMediaGallery-DfJmMW0I.js → LpMediaGallery-YKr-cQPN.js} +2 -2
  41. package/dist/chunks/{LpModal-DPwGKBPv.js → LpModal-DMMDQHAu.js} +3 -3
  42. package/dist/chunks/{LpModalNav-DPg5H9f3.js → LpModalNav-BVPDyIup.js} +4 -4
  43. package/dist/chunks/{LpNotificationBell-t5yn-siX.js → LpNotificationBell-CCwyJP5C.js} +26 -14
  44. package/dist/chunks/{LpNumberField-DWnQKktS.js → LpNumberField-B77O-z28.js} +1 -1
  45. package/dist/chunks/{LpPagination-DFngX7Xo.js → LpPagination-BcDlo3r_.js} +1 -1
  46. package/dist/chunks/{LpPasswordInput-CWVDuBJ5.js → LpPasswordInput-eKzQhbQP.js} +2 -2
  47. package/dist/chunks/{LpPhoneInput-CD-ZTeSd.js → LpPhoneInput-BCbrrhgE.js} +126 -23
  48. package/dist/chunks/{LpPie-BLco31Nx.js → LpPie-BEJ2SfI6.js} +9 -10
  49. package/dist/chunks/{LpPopover-p_e_shN3.js → LpPopover-DCKeqiVn.js} +1 -0
  50. package/dist/chunks/LpRangeCalendar-DZuaRV0W.js +331 -0
  51. package/dist/chunks/{LpRollingText-CM5gkSeF.js → LpRollingText-DB7upf-5.js} +1 -1
  52. package/dist/chunks/{LpScrollArea-D8xyKXcF.js → LpScrollArea-Bry1fdOM.js} +14 -2
  53. package/dist/chunks/{LpSection-aM6J_u51.js → LpSection-tKEH0jYf.js} +1 -1
  54. package/dist/chunks/{LpSegmented-Dejd7cIx.js → LpSegmented-C09EEam2.js} +1 -1
  55. package/dist/chunks/{LpSelect-DrRD9I7L.js → LpSelect-DNIL6MB9.js} +20 -23
  56. package/dist/chunks/{LpSidebar-BgwUNWpJ.js → LpSidebar-CQNB0-yU.js} +3 -3
  57. package/dist/chunks/{LpSidebarNav-Bqxz8aWT.js → LpSidebarNav-kx-LdqNU.js} +3 -3
  58. package/dist/chunks/{LpStat-dXtyrdX9.js → LpStat-B-7udBRP.js} +1 -1
  59. package/dist/chunks/{LpStepper-Bk-XaQYU.js → LpStepper-C7XiVKZj.js} +34 -19
  60. package/dist/chunks/{LpSwitch-B1aLKulj.js → LpSwitch-OrcOJX9m.js} +1 -1
  61. package/dist/chunks/LpTableFilter-BV8PpLM9.js +158 -0
  62. package/dist/chunks/{LpTabs-_qMVaZJ6.js → LpTabs-29iW1XOx.js} +1 -1
  63. package/dist/chunks/LpTagsInput-D1SuqbhB.js +300 -0
  64. package/dist/chunks/{LpTextarea-yP8hGedR.js → LpTextarea-uuScWo6-.js} +1 -1
  65. package/dist/chunks/{LpThemeProvider-Wjm7Tuit.js → LpThemeProvider-Be4r2TQX.js} +1 -1
  66. package/dist/chunks/{LpThemeSwitcher-CKLc10KZ.js → LpThemeSwitcher-B4aVPbNC.js} +19 -22
  67. package/dist/chunks/{LpTimeField-C5HRgS5H.js → LpTimeField-Cqe7fXnh.js} +1 -1
  68. package/dist/chunks/{LpToaster--oT6lfhL.js → LpToaster-DpJvoCOh.js} +37 -3
  69. package/dist/chunks/{LpTooltip-CBdzeKNf.js → LpTooltip-CXVKcOXT.js} +3 -2
  70. package/dist/chunks/{LpTopologyCanvas-DocsLal3.js → LpTopologyCanvas-6ozNrQKh.js} +2 -1
  71. package/dist/chunks/{LpUptimeBar-DLtxOMRy.js → LpUptimeBar-ugcv25xj.js} +2 -2
  72. package/dist/chunks/icons-CkA0njzm.js +43 -0
  73. package/dist/chunks/{useTheme-C8JHUlPM.js → useTheme-DJm1JSsY.js} +47 -50
  74. package/dist/component-names.d.ts +1 -1
  75. package/dist/components/ContextMenuItems.vue.js +1 -1
  76. package/dist/components/LayoutCanvas.vue.js +1 -1
  77. package/dist/components/LayoutNode.vue.js +1 -1
  78. package/dist/components/LpActionBar.vue.js +1 -1
  79. package/dist/components/LpAddressInput.vue.js +1 -1
  80. package/dist/components/LpAlert.vue.js +1 -1
  81. package/dist/components/LpAppShell.vue.js +1 -1
  82. package/dist/components/LpAutocomplete.vue.js +1 -1
  83. package/dist/components/LpAvatar.vue.js +1 -1
  84. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  85. package/dist/components/LpButton.vue.d.ts +2 -2
  86. package/dist/components/LpButton.vue.js +1 -1
  87. package/dist/components/LpCalendar.vue.js +1 -1
  88. package/dist/components/LpCard.vue.js +1 -1
  89. package/dist/components/LpChart.vue.d.ts +16 -1
  90. package/dist/components/LpChart.vue.js +63 -5
  91. package/dist/components/LpCheckbox.vue.js +1 -1
  92. package/dist/components/LpCodeBlock.vue.js +1 -1
  93. package/dist/components/LpColorPicker.vue.d.ts +2 -2
  94. package/dist/components/LpColorPicker.vue.js +1 -1
  95. package/dist/components/LpCommandPalette.vue.d.ts +6 -0
  96. package/dist/components/LpCommandPalette.vue.js +61 -10
  97. package/dist/components/LpConfirmDialog.vue.js +1 -1
  98. package/dist/components/LpConfirmHost.vue.js +1 -1
  99. package/dist/components/LpContextMenu.vue.js +1 -1
  100. package/dist/components/LpDatePicker.vue.js +1 -1
  101. package/dist/components/LpDateRangePicker.vue.d.ts +60 -0
  102. package/dist/components/LpDateRangePicker.vue.js +4 -0
  103. package/dist/components/LpDisclosure.vue.js +1 -1
  104. package/dist/components/LpDrawer.vue.js +1 -1
  105. package/dist/components/LpDropdownMenu.vue.js +1 -1
  106. package/dist/components/LpDropzone.vue.js +1 -1
  107. package/dist/components/LpEmptyState.vue.js +1 -1
  108. package/dist/components/LpFieldNotice.vue.d.ts +8 -0
  109. package/dist/components/LpFieldNotice.vue.js +4 -0
  110. package/dist/components/LpFileTree.vue.js +1 -1
  111. package/dist/components/LpFileTreeNode.vue.js +1 -1
  112. package/dist/components/LpFormField.vue.d.ts +4 -1
  113. package/dist/components/LpFormField.vue.js +1 -1
  114. package/dist/components/LpIcon.vue.d.ts +0 -1
  115. package/dist/components/LpIcon.vue.js +1 -1
  116. package/dist/components/LpImageEditor.vue.js +2 -2
  117. package/dist/components/LpInput.vue.d.ts +19 -0
  118. package/dist/components/LpInput.vue.js +1 -1
  119. package/dist/components/LpLightbox.vue.js +1 -1
  120. package/dist/components/LpLogViewer.vue.d.ts +52 -1
  121. package/dist/components/LpLogViewer.vue.js +1 -1
  122. package/dist/components/LpMapPicker.vue.js +1 -1
  123. package/dist/components/LpMediaGallery.vue.js +1 -1
  124. package/dist/components/LpModal.vue.js +1 -1
  125. package/dist/components/LpModalNav.vue.js +1 -1
  126. package/dist/components/LpNotificationBell.vue.js +1 -1
  127. package/dist/components/LpNumberField.vue.js +1 -1
  128. package/dist/components/LpPagination.vue.js +1 -1
  129. package/dist/components/LpPasswordInput.vue.js +1 -1
  130. package/dist/components/LpPhoneInput.vue.d.ts +13 -0
  131. package/dist/components/LpPhoneInput.vue.js +1 -1
  132. package/dist/components/LpPie.vue.js +1 -1
  133. package/dist/components/LpPopover.vue.js +1 -1
  134. package/dist/components/LpProgress.vue.d.ts +1 -1
  135. package/dist/components/LpRangeCalendar.vue.d.ts +51 -0
  136. package/dist/components/LpRangeCalendar.vue.js +4 -0
  137. package/dist/components/LpRollingText.vue.d.ts +1 -1
  138. package/dist/components/LpRollingText.vue.js +1 -1
  139. package/dist/components/LpScrollArea.vue.d.ts +2 -2
  140. package/dist/components/LpScrollArea.vue.js +1 -1
  141. package/dist/components/LpSection.vue.js +1 -1
  142. package/dist/components/LpSegmented.vue.js +1 -1
  143. package/dist/components/LpSelect.vue.js +1 -1
  144. package/dist/components/LpShortcutsDialog.vue.d.ts +6 -0
  145. package/dist/components/LpShortcutsDialog.vue.js +56 -14
  146. package/dist/components/LpSidebar.vue.js +1 -1
  147. package/dist/components/LpSidebarNav.vue.js +1 -1
  148. package/dist/components/LpStat.vue.js +1 -1
  149. package/dist/components/LpStepper.vue.js +1 -1
  150. package/dist/components/LpSwitch.vue.js +1 -1
  151. package/dist/components/LpTable.vue.d.ts +84 -2
  152. package/dist/components/LpTable.vue.js +481 -107
  153. package/dist/components/LpTableFilter.vue.d.ts +31 -0
  154. package/dist/components/LpTableFilter.vue.js +4 -0
  155. package/dist/components/LpTabs.vue.js +1 -1
  156. package/dist/components/LpTagsInput.vue.d.ts +77 -0
  157. package/dist/components/LpTagsInput.vue.js +4 -0
  158. package/dist/components/LpTextarea.vue.js +1 -1
  159. package/dist/components/LpThemeProvider.vue.js +1 -1
  160. package/dist/components/LpThemeSwitcher.vue.d.ts +2 -2
  161. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  162. package/dist/components/LpTimeField.vue.js +1 -1
  163. package/dist/components/LpToaster.vue.js +1 -1
  164. package/dist/components/LpTooltip.vue.js +1 -1
  165. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  166. package/dist/components/LpUptimeBar.vue.js +1 -1
  167. package/dist/components/chart.js +3 -2
  168. package/dist/components/commandSearch.js +3 -0
  169. package/dist/components/dateRange.d.ts +43 -0
  170. package/dist/components/dateRange.js +76 -0
  171. package/dist/components/exportData.d.ts +58 -0
  172. package/dist/components/exportData.js +165 -0
  173. package/dist/components/logSearch.d.ts +18 -0
  174. package/dist/components/logSearch.js +36 -0
  175. package/dist/components/phone.d.ts +41 -0
  176. package/dist/components/phone.js +80 -0
  177. package/dist/components/tableFilter.d.ts +36 -0
  178. package/dist/components/tableFilter.js +84 -0
  179. package/dist/components/tags.d.ts +20 -0
  180. package/dist/components/tags.js +37 -0
  181. package/dist/composables/useField.d.ts +4 -0
  182. package/dist/composables/useFieldNotice.d.ts +17 -0
  183. package/dist/composables/useFieldNotice.js +28 -0
  184. package/dist/composables/useForm.d.ts +53 -0
  185. package/dist/composables/useForm.js +207 -0
  186. package/dist/composables/useHotkeys.d.ts +65 -5
  187. package/dist/composables/useHotkeys.js +170 -9
  188. package/dist/composables/useInputMask.d.ts +59 -0
  189. package/dist/composables/useInputMask.js +170 -0
  190. package/dist/composables/usePaintSelect.d.ts +22 -0
  191. package/dist/composables/usePaintSelect.js +169 -0
  192. package/dist/composables/useShift.js +7 -2
  193. package/dist/icons-all.d.ts +1 -0
  194. package/dist/icons-all.js +3 -0
  195. package/dist/icons.d.ts +14 -1
  196. package/dist/index.d.ts +29 -3
  197. package/dist/index.js +195 -138
  198. package/dist/kit-icons.d.ts +2 -0
  199. package/dist/theme/backdrop.d.ts +12 -0
  200. package/dist/theme/presets.d.ts +0 -2
  201. package/package.json +13 -4
  202. package/src/component-names.ts +4 -0
  203. package/src/components/ContextMenuItems.vue +1 -1
  204. package/src/components/LayoutNode.vue +11 -2
  205. package/src/components/LpActionBar.vue +1 -1
  206. package/src/components/LpAutocomplete.vue +1 -0
  207. package/src/components/LpAvatar.vue +2 -2
  208. package/src/components/LpButton.vue +1 -1
  209. package/src/components/LpChart.vue +80 -1
  210. package/src/components/LpColorPicker.vue +34 -1
  211. package/src/components/LpCommandPalette.vue +81 -7
  212. package/src/components/LpContextMenu.vue +1 -1
  213. package/src/components/LpDatePicker.vue +8 -3
  214. package/src/components/LpDateRangePicker.vue +224 -0
  215. package/src/components/LpDropdownMenu.vue +1 -0
  216. package/src/components/LpFieldNotice.vue +65 -0
  217. package/src/components/LpFormField.vue +33 -4
  218. package/src/components/LpIcon.vue +22 -4
  219. package/src/components/LpInput.vue +46 -6
  220. package/src/components/LpLogViewer.vue +225 -22
  221. package/src/components/LpModal.vue +1 -1
  222. package/src/components/LpNotificationBell.vue +15 -7
  223. package/src/components/LpPhoneInput.vue +179 -20
  224. package/src/components/LpPie.vue +8 -5
  225. package/src/components/LpPopover.vue +1 -0
  226. package/src/components/LpRangeCalendar.vue +365 -0
  227. package/src/components/LpRollingText.vue +3 -0
  228. package/src/components/LpScrollArea.vue +22 -2
  229. package/src/components/LpSelect.vue +0 -0
  230. package/src/components/LpShortcutsDialog.vue +77 -8
  231. package/src/components/LpStepper.vue +23 -10
  232. package/src/components/LpSwitch.vue +1 -1
  233. package/src/components/LpTable.vue +584 -65
  234. package/src/components/LpTableFilter.vue +175 -0
  235. package/src/components/LpTagsInput.vue +364 -0
  236. package/src/components/LpThemeSwitcher.vue +19 -23
  237. package/src/components/LpToaster.vue +49 -1
  238. package/src/components/LpTooltip.vue +9 -2
  239. package/src/components/LpTopologyCanvas.vue +7 -1
  240. package/src/components/chart.ts +5 -3
  241. package/src/components/commandSearch.ts +6 -0
  242. package/src/components/dateRange.ts +134 -0
  243. package/src/components/exportData.ts +228 -0
  244. package/src/components/logSearch.ts +46 -0
  245. package/src/components/phone.ts +140 -0
  246. package/src/components/tableFilter.ts +122 -0
  247. package/src/components/tags.ts +62 -0
  248. package/src/composables/useField.ts +4 -0
  249. package/src/composables/useFieldNotice.ts +59 -0
  250. package/src/composables/useForm.ts +314 -0
  251. package/src/composables/useHotkeys.ts +269 -13
  252. package/src/composables/useInputMask.ts +254 -0
  253. package/src/composables/usePaintSelect.ts +243 -0
  254. package/src/composables/useShift.ts +13 -2
  255. package/src/iconSubset.d.mts +10 -0
  256. package/src/iconSubset.mjs +51 -0
  257. package/src/icons-all.ts +10 -0
  258. package/src/icons.ts +59 -8
  259. package/src/index.ts +37 -3
  260. package/src/kit-icons.ts +5 -0
  261. package/src/nuxt.ts +30 -2
  262. package/src/runtime/theme.plugin.ts +2 -2
  263. package/src/theme/backdrop.ts +69 -0
  264. package/src/theme/presets.ts +6 -64
  265. package/src/tokens/tokens.css +46 -0
@@ -0,0 +1,175 @@
1
+ <script lang="ts">
2
+ export interface TableFilterOption {
3
+ value: unknown
4
+ label: string
5
+ /** Rows holding the value; omitted when unknown (a server-backed table). */
6
+ count?: number
7
+ }
8
+
9
+ export interface TableFilterLabels {
10
+ search: string
11
+ all: string
12
+ none: string
13
+ invert: string
14
+ only: string
15
+ onlyShown: string
16
+ addShown: string
17
+ noValues: string
18
+ }
19
+ </script>
20
+
21
+ <script setup lang="ts">
22
+ /*
23
+ * The value filter of one LpTable column, opened from the funnel in its
24
+ * header. Built for columns with many values: a search narrows the LIST
25
+ * without touching what is ticked, "Only" next to a value shows just that one
26
+ * in a click, and after a search the matches can be taken as a whole.
27
+ */
28
+ import { computed, ref } from "vue"
29
+ import LpCheckbox from "./LpCheckbox.vue"
30
+ import LpInput from "./LpInput.vue"
31
+ import LpShift from "./LpShift.vue"
32
+ import { usePaintSelect } from "../composables/usePaintSelect"
33
+ import { hideAllValues, invertHidden, matchesQuery, onlyValues, unhideValues } from "./tableFilter"
34
+
35
+ const props = defineProps<{
36
+ options: TableFilterOption[]
37
+ hidden: ReadonlySet<unknown>
38
+ labels: TableFilterLabels
39
+ }>()
40
+
41
+ const emit = defineEmits<{ (e: "update:hidden", value: Set<unknown>): void }>()
42
+
43
+ /** Past this many values a list is easier searched than scanned. */
44
+ const SEARCH_FROM = 8
45
+
46
+ const query = ref("")
47
+ const values = computed(() => props.options.map((o) => o.value))
48
+ const shown = computed(() => props.options.filter((o) => matchesQuery(o.label, query.value)))
49
+
50
+ function set(next: Set<unknown>) {
51
+ emit("update:hidden", next)
52
+ }
53
+
54
+ function toggle(value: unknown) {
55
+ const next = new Set(props.hidden)
56
+ if (next.has(value)) next.delete(value)
57
+ else next.add(value)
58
+ set(next)
59
+ }
60
+
61
+ const list = ref<HTMLElement | null>(null)
62
+
63
+ /*
64
+ * Press a value's tick and sweep down the list to tick or clear the run, the
65
+ * same way rows of the table are painted. The selection here is the values
66
+ * shown, so painting writes back the complement as the hidden set.
67
+ */
68
+ const byKey = computed(() => new Map(props.options.map((o) => [String(o.value), o.value])))
69
+ const paint = usePaintSelect<unknown>({
70
+ root: list,
71
+ selection: () => values.value.filter((v) => !props.hidden.has(v)),
72
+ set: (shownValues) => {
73
+ const keep = new Set(shownValues)
74
+ set(new Set(values.value.filter((v) => !keep.has(v))))
75
+ },
76
+ resolve: (raw) => (byKey.value.has(raw) ? byKey.value.get(raw) : undefined),
77
+ })
78
+
79
+ /* Up and down walk the ticks, so a long list is not a long run of Tab. */
80
+ function onListKeydown(event: KeyboardEvent) {
81
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
82
+ const boxes = [...(list.value?.querySelectorAll<HTMLElement>("[role=checkbox]") ?? [])]
83
+ const at = boxes.indexOf(document.activeElement as HTMLElement)
84
+ const next = boxes[at + (event.key === "ArrowDown" ? 1 : -1)]
85
+ if (!next) return
86
+ event.preventDefault()
87
+ next.focus()
88
+ }
89
+
90
+ const action =
91
+ "rounded-control px-1.5 py-0.5 text-xs font-medium text-muted outline-none transition-colors duration-fast hover:bg-surface-soft hover:text-ink focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
92
+ </script>
93
+
94
+ <template>
95
+ <div class="flex flex-col gap-2">
96
+ <LpInput
97
+ v-if="options.length >= SEARCH_FROM"
98
+ v-model="query"
99
+ size="sm"
100
+ icon="lucide:search"
101
+ :placeholder="labels.search"
102
+ :aria-label="labels.search"
103
+ />
104
+
105
+ <div class="flex flex-wrap items-center gap-1">
106
+ <template v-if="query">
107
+ <button
108
+ type="button"
109
+ :class="action"
110
+ :disabled="!shown.length"
111
+ @click="set(onlyValues(values, shown.map((o) => o.value)))"
112
+ >
113
+ {{ labels.onlyShown.replace("{count}", String(shown.length)) }}
114
+ </button>
115
+ <button
116
+ type="button"
117
+ :class="action"
118
+ :disabled="!shown.length"
119
+ @click="set(unhideValues(hidden, shown.map((o) => o.value)))"
120
+ >
121
+ {{ labels.addShown.replace("{count}", String(shown.length)) }}
122
+ </button>
123
+ </template>
124
+ <template v-else>
125
+ <button type="button" :class="action" :disabled="!hidden.size" @click="set(new Set())">
126
+ {{ labels.all }}
127
+ </button>
128
+ <button
129
+ type="button"
130
+ :class="action"
131
+ :disabled="hidden.size === options.length"
132
+ @click="set(hideAllValues(values))"
133
+ >
134
+ {{ labels.none }}
135
+ </button>
136
+ <button type="button" :class="action" @click="set(invertHidden(values, hidden))">
137
+ {{ labels.invert }}
138
+ </button>
139
+ </template>
140
+ </div>
141
+
142
+ <LpShift>
143
+ <div
144
+ ref="list"
145
+ class="-mx-1 flex max-h-64 flex-col overflow-y-auto overscroll-contain"
146
+ @keydown="onListKeydown"
147
+ >
148
+ <div
149
+ v-for="option in shown"
150
+ :key="String(option.value)"
151
+ class="group/opt flex items-center gap-2 rounded-control px-1.5 py-1 transition-colors duration-fast select-none hover:bg-surface-soft"
152
+ :data-paint-key="String(option.value)"
153
+ >
154
+ <LpCheckbox
155
+ :model-value="!hidden.has(option.value)"
156
+ class="min-w-0 flex-1"
157
+ @pointerdown="paint.onPointerDown($event, option.value)"
158
+ @update:model-value="toggle(option.value)"
159
+ >
160
+ <span class="truncate">{{ option.label }}</span>
161
+ </LpCheckbox>
162
+ <span v-if="option.count !== undefined" class="text-xs text-muted tabular-nums">{{ option.count }}</span>
163
+ <button
164
+ type="button"
165
+ class="rounded-control px-1.5 py-0.5 text-xs font-medium text-brand opacity-0 outline-none transition-opacity duration-fast group-hover/opt:opacity-100 hover:bg-brand-soft focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
166
+ @click="set(onlyValues(values, [option.value]))"
167
+ >
168
+ {{ labels.only }}
169
+ </button>
170
+ </div>
171
+ <p v-if="!shown.length" class="px-1.5 py-2 text-xs text-muted">{{ labels.noValues }}</p>
172
+ </div>
173
+ </LpShift>
174
+ </div>
175
+ </template>
@@ -0,0 +1,364 @@
1
+ <script lang="ts">
2
+ export default { inheritAttrs: false }
3
+
4
+ export type TagsInputLabels = {
5
+ remove: string
6
+ duplicate: string
7
+ max: string
8
+ added: string
9
+ removed: string
10
+ }
11
+ </script>
12
+
13
+ <script setup lang="ts">
14
+ /*
15
+ * Free-form list of short values: emails, labels, hostnames. Typing a
16
+ * delimiter or Enter turns the text into a tag, a paste is split into many,
17
+ * Backspace in an empty field selects the last tag and removes it on the next
18
+ * press, and the arrow keys walk the tags (reka TagsInput).
19
+ *
20
+ * Adding is the kit's own (see tags.ts) rather than reka's, so a tag that
21
+ * fails `validate`, the limit or the duplicate policy stays in the field for
22
+ * fixing instead of vanishing. The reason floats under the field
23
+ * (LpFieldNotice) rather than taking a line, so a refused paste never moves
24
+ * the form, and a repeat makes the tag it repeats pulse.
25
+ */
26
+ import {
27
+ TagsInputInput,
28
+ TagsInputItem,
29
+ TagsInputItemDelete,
30
+ TagsInputItemText,
31
+ TagsInputRoot,
32
+ } from "reka-ui"
33
+ import { computed, ref, useAttrs, useId } from "vue"
34
+ import { fieldMessageId, useField, useFieldControl } from "../composables/useField"
35
+ import { prefersReducedMotion } from "../composables/easing"
36
+ import { useFieldNotice, type FieldNoticeOptions } from "../composables/useFieldNotice"
37
+ import { useShift } from "../composables/useShift"
38
+ import { useLabels } from "../composables/useLpConfig"
39
+ import { mergeClass } from "../composables/useMergedClass"
40
+ import LpFieldNotice from "./LpFieldNotice.vue"
41
+ import LpFormField from "./LpFormField.vue"
42
+ import LpIcon from "./LpIcon.vue"
43
+ import { addTags, splitTags, type TagDuplicates, type TagRejection, type TagValidator } from "./tags"
44
+
45
+ const props = withDefaults(
46
+ defineProps<{
47
+ modelValue?: string[]
48
+ placeholder?: string
49
+ /** Characters (or a RegExp) that end a tag while typing and split a paste. */
50
+ delimiter?: string | readonly string[] | RegExp
51
+ /** At most this many tags. */
52
+ max?: number
53
+ /**
54
+ * "reject" keeps a repeat typed with a note, "ignore" drops it with a passing
55
+ * note, "allow" keeps it. Either way the tag it repeats pulses.
56
+ */
57
+ duplicates?: TagDuplicates
58
+ /** Return an error message to refuse a tag. */
59
+ validate?: TagValidator
60
+ /** Applied to each tag before it is checked and stored (trim is always applied). */
61
+ normalize?: (tag: string) => string
62
+ /** Turn leftover text into a tag when the field loses focus. */
63
+ addOnBlur?: boolean
64
+ disabled?: boolean
65
+ invalid?: boolean
66
+ required?: boolean
67
+ label?: string
68
+ hint?: string
69
+ error?: string
70
+ labels?: Partial<TagsInputLabels>
71
+ }>(),
72
+ { modelValue: () => [], delimiter: () => [",", "\n", "\t"], duplicates: "reject", addOnBlur: true },
73
+ )
74
+
75
+ const emit = defineEmits<{
76
+ (e: "update:modelValue", value: string[]): void
77
+ (e: "reject", rejected: TagRejection[]): void
78
+ (e: "blur", event: FocusEvent): void
79
+ }>()
80
+
81
+ const text = useLabels<TagsInputLabels>(
82
+ "tagsInput",
83
+ {
84
+ remove: "Remove {tag}",
85
+ duplicate: "{tag} is already added",
86
+ max: "Up to {max} items",
87
+ added: "{tag} added",
88
+ removed: "{tag} removed",
89
+ },
90
+ () => props.labels,
91
+ )
92
+
93
+ const attrs = useAttrs()
94
+ const hasField = computed(() => !!props.label || !!props.hint || !!props.error)
95
+ /*
96
+ * Notes go to the field that frames the control, which fades its own hint out
97
+ * under them: this component's own LpFormField when it has a label or hint,
98
+ * the one around it otherwise, and a notice of its own only when it stands
99
+ * alone.
100
+ */
101
+ const notice = useFieldNotice()
102
+ const outerField = useField()
103
+ type FieldHandle = { notify: (text: string, options?: FieldNoticeOptions) => void; clearNotice: () => void }
104
+ const ownField = ref<FieldHandle | null>(null)
105
+ function bindField(c: unknown): void {
106
+ ownField.value = hasField.value && c ? (c as FieldHandle) : null
107
+ }
108
+ const standalone = computed(() => !hasField.value && !outerField?.value.notify)
109
+ function say(message: string, options?: FieldNoticeOptions): void {
110
+ if (hasField.value) ownField.value?.notify(message, options)
111
+ else if (outerField?.value.notify) outerField.value.notify(message, options)
112
+ else notice.show(message, options)
113
+ }
114
+ function unsay(): void {
115
+ if (hasField.value) ownField.value?.clearNotice()
116
+ else if (outerField?.value.clearNotice) outerField.value.clearNotice()
117
+ else notice.clear()
118
+ }
119
+ /** A refusal the user still has to act on: the text stays typed and the field reads invalid. */
120
+ const stuck = ref(false)
121
+ const announcement = ref("")
122
+ const noticeId = `${useId()}-notice`
123
+
124
+ const field = useFieldControl(() => ({
125
+ wrapped: hasField.value,
126
+ hasMessage: !!props.error || !!props.hint,
127
+ id: typeof attrs.id === "string" ? attrs.id : undefined,
128
+ invalid: props.invalid || !!props.error || stuck.value,
129
+ required: props.required,
130
+ disabled: props.disabled,
131
+ }))
132
+
133
+ const describedBy = computed(
134
+ () =>
135
+ [
136
+ field.value.describedBy,
137
+ hasField.value && field.value.id ? `${field.value.id}-notice` : undefined,
138
+ standalone.value && notice.message.value ? noticeId : undefined,
139
+ ]
140
+ .filter(Boolean)
141
+ .join(" ") || undefined,
142
+ )
143
+
144
+ const rootAttrs = computed(() => {
145
+ const { class: _c, style: _s, id: _i, ...rest } = attrs
146
+ return rest
147
+ })
148
+
149
+ function explain(rejected: TagRejection): string {
150
+ if (rejected.reason === "invalid") return rejected.message ?? ""
151
+ if (rejected.reason === "max") return text.value.max.replace("{max}", String(props.max))
152
+ return text.value.duplicate.replace("{tag}", rejected.value)
153
+ }
154
+
155
+ const inputEl = ref<HTMLInputElement | null>(null)
156
+
157
+ /*
158
+ * The box grows a row at a time as tags wrap, and shrinks back when they go:
159
+ * eased rather than snapped, so the fields below glide instead of jumping.
160
+ */
161
+ const { el: boxEl, tweening, resizing } = useShift({ axis: "height" })
162
+
163
+ /*
164
+ * Keys follow the tag, not its position: keyed by index, removing the first
165
+ * tag re-keyed every later one and they all replayed their entry. A repeated
166
+ * tag (duplicates "allow") is told apart by which occurrence it is.
167
+ */
168
+ const keyed = computed(() => {
169
+ const seen = new Map<string, number>()
170
+ return props.modelValue.map((tag) => {
171
+ const n = seen.get(tag) ?? 0
172
+ seen.set(tag, n + 1)
173
+ return { tag, key: `${n}:${tag}` }
174
+ })
175
+ })
176
+
177
+ /** Draw the eye to a tag that was just typed again. */
178
+ function pulse(tag: string): void {
179
+ if (prefersReducedMotion()) return
180
+ const chip = boxEl.value?.querySelector<HTMLElement>(`[data-tag-key="${CSS.escape(`0:${tag}`)}"]`)
181
+ chip?.animate(
182
+ [
183
+ { transform: "scale(1)", boxShadow: "0 0 0 0 transparent" },
184
+ { transform: "scale(1.08)", boxShadow: "0 0 0 2px var(--color-ring)" },
185
+ { transform: "scale(1)", boxShadow: "0 0 0 0 transparent" },
186
+ ],
187
+ { duration: 420, easing: "ease-out" },
188
+ )
189
+ }
190
+
191
+ /** Add whatever `raw` splits into; returns the text that should stay typed. */
192
+ function add(raw: string): string {
193
+ const parts = splitTags(raw, props.delimiter)
194
+ if (!parts.length) return ""
195
+ const { tags, rejected, ignored } = addTags(props.modelValue, parts, {
196
+ max: props.max,
197
+ duplicates: props.duplicates,
198
+ validate: props.validate,
199
+ normalize: props.normalize,
200
+ })
201
+ if (tags.length !== props.modelValue.length) {
202
+ const added = tags.slice(props.modelValue.length)
203
+ announcement.value = added.map((t) => text.value.added.replace("{tag}", t)).join(", ")
204
+ emit("update:modelValue", tags)
205
+ }
206
+ stuck.value = rejected.length > 0
207
+ const first = rejected[0]
208
+ if (first) {
209
+ say(explain(first), { tone: first.reason === "invalid" ? "danger" : "info", duration: 0 })
210
+ emit("reject", rejected)
211
+ } else if (ignored.length) {
212
+ say(text.value.duplicate.replace("{tag}", ignored[0]!))
213
+ } else {
214
+ unsay()
215
+ }
216
+ for (const r of rejected) if (r.reason === "duplicate") pulse(props.normalize ? props.normalize(r.value) : r.value)
217
+ for (const tag of ignored) pulse(tag)
218
+ return rejected.map((r) => r.value).join(joiner.value)
219
+ }
220
+
221
+ // Rejected parts go back into the field joined by a delimiter, so committing
222
+ // them again splits them the same way.
223
+ const joiner = computed(() => {
224
+ const d = props.delimiter
225
+ const visible = d instanceof RegExp ? undefined : (typeof d === "string" ? [d] : d).find((x) => x.trim())
226
+ return visible ? `${visible} ` : " "
227
+ })
228
+
229
+ function commit(el: HTMLInputElement): void {
230
+ el.value = add(el.value)
231
+ }
232
+
233
+ function isDelimiter(data: string): boolean {
234
+ const d = props.delimiter
235
+ if (d instanceof RegExp) return new RegExp(d.source, d.flags.replace(/[gy]/g, "")).test(data)
236
+ return (typeof d === "string" ? [d] : d).includes(data)
237
+ }
238
+
239
+ function onKeydown(event: KeyboardEvent): void {
240
+ if (event.isComposing || !(event.target instanceof HTMLInputElement)) return
241
+ if (event.key === "Enter" && event.target.value.trim()) {
242
+ event.preventDefault()
243
+ commit(event.target)
244
+ }
245
+ }
246
+
247
+ function onBeforeInput(event: InputEvent): void {
248
+ if (event.isComposing || !(event.target instanceof HTMLInputElement)) return
249
+ if (event.inputType === "insertText" && event.data && isDelimiter(event.data)) {
250
+ event.preventDefault()
251
+ commit(event.target)
252
+ }
253
+ }
254
+
255
+ function onInput(): void {
256
+ if (!stuck.value) return
257
+ stuck.value = false
258
+ unsay()
259
+ }
260
+
261
+ function onPaste(event: ClipboardEvent): void {
262
+ const pasted = event.clipboardData?.getData("text") ?? ""
263
+ if (!(event.target instanceof HTMLInputElement) || !pasted) return
264
+ if (splitTags(pasted, props.delimiter).length < 2) return
265
+ event.preventDefault()
266
+ const el = event.target
267
+ const typed = el.value.slice(0, el.selectionStart ?? el.value.length) + pasted + el.value.slice(el.selectionEnd ?? el.value.length)
268
+ el.value = add(typed)
269
+ }
270
+
271
+ function onBlur(event: FocusEvent): void {
272
+ emit("blur", event)
273
+ if (props.addOnBlur && event.target instanceof HTMLInputElement && event.target.value.trim()) commit(event.target)
274
+ }
275
+
276
+ function onModel(value: (string | Record<string, unknown>)[] | null): void {
277
+ const next = (value ?? []).map(String)
278
+ const removed = props.modelValue.filter((t) => !next.includes(t))
279
+ if (removed.length) announcement.value = removed.map((t) => text.value.removed.replace("{tag}", t)).join(", ")
280
+ stuck.value = false
281
+ unsay()
282
+ emit("update:modelValue", next)
283
+ }
284
+
285
+ defineExpose({ focus: () => inputEl.value?.focus(), add: (value: string) => add(value) })
286
+ </script>
287
+
288
+ <template>
289
+ <component
290
+ :is="hasField ? LpFormField : 'div'"
291
+ :ref="bindField"
292
+ :label="hasField ? label : undefined"
293
+ :hint="hasField ? hint : undefined"
294
+ :error="hasField ? error : undefined"
295
+ :required="hasField ? required : undefined"
296
+ :id="hasField ? field.id : undefined"
297
+ :class="hasField ? attrs.class : 'contents'"
298
+ :style="hasField ? attrs.style : undefined"
299
+ >
300
+ <div class="relative">
301
+ <TagsInputRoot
302
+ v-bind="rootAttrs"
303
+ :model-value="modelValue"
304
+ :disabled="field.disabled"
305
+ :required="field.required"
306
+ :id="field.id"
307
+ :delimiter="/(?!)/"
308
+ data-lp-ring-owner
309
+ :ref="(c) => (boxEl = (c as { $el?: HTMLElement } | null)?.$el ?? null)"
310
+ :class="
311
+ mergeClass(
312
+ 'flex min-h-(--size-control-md) w-full flex-wrap items-center gap-1.5 rounded-control border border-line bg-surface-soft px-2 py-1.5 text-sm text-ink transition-colors duration-[var(--duration-fast)] focus-within:border-brand focus-within:ring-2 focus-within:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-55',
313
+ field.invalid ? 'border-danger focus-within:border-danger focus-within:ring-danger-soft' : '',
314
+ hasField ? '' : (attrs.class as string),
315
+ tweening
316
+ ? 'transition-[height,border-color,box-shadow] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none'
317
+ : '',
318
+ resizing ? 'overflow-hidden' : '',
319
+ )
320
+ "
321
+ :style="hasField ? undefined : attrs.style"
322
+ @update:model-value="onModel"
323
+ >
324
+ <!-- Tags grow in when added and shrink out when removed; the ones
325
+ already there on mount stay still (no `appear`). -->
326
+ <TransitionGroup
327
+ enter-active-class="animate-[chip-in_var(--duration-fast)_var(--ease-emphasized)] motion-reduce:animate-none"
328
+ leave-active-class="animate-[chip-out_var(--duration-fast)_ease] motion-reduce:animate-none"
329
+ >
330
+ <TagsInputItem
331
+ v-for="{ tag, key } in keyed"
332
+ :key="key"
333
+ :data-tag-key="key"
334
+ :value="tag"
335
+ class="inline-flex max-w-full items-center gap-1 rounded-pill bg-surface-raised py-0.5 pr-1 pl-2.5 text-xs font-medium text-ink outline-none data-[state=active]:ring-2 data-[state=active]:ring-ring"
336
+ >
337
+ <TagsInputItemText class="truncate" />
338
+ <TagsInputItemDelete
339
+ :aria-labelledby="undefined"
340
+ :aria-label="text.remove.replace('{tag}', tag)"
341
+ class="grid size-4 shrink-0 place-items-center rounded-pill text-muted transition-colors hover:bg-ink/10 hover:text-ink"
342
+ >
343
+ <LpIcon name="lucide:x" :size="12" />
344
+ </TagsInputItemDelete>
345
+ </TagsInputItem>
346
+ </TransitionGroup>
347
+ <TagsInputInput
348
+ :ref="(c) => (inputEl = (c as { $el?: HTMLInputElement } | null)?.$el ?? null)"
349
+ :placeholder="modelValue.length ? undefined : placeholder"
350
+ :aria-invalid="field.invalid || undefined"
351
+ :aria-describedby="describedBy"
352
+ class="min-w-24 flex-1 bg-transparent px-1 outline-none placeholder:text-muted"
353
+ @keydown="onKeydown"
354
+ @beforeinput="onBeforeInput"
355
+ @input="onInput"
356
+ @paste="onPaste"
357
+ @blur="onBlur"
358
+ />
359
+ </TagsInputRoot>
360
+ <LpFieldNotice v-if="standalone" :notice="notice" :id="noticeId" />
361
+ </div>
362
+ <span class="sr-only" aria-live="polite">{{ announcement }}</span>
363
+ </component>
364
+ </template>
@@ -34,9 +34,9 @@ const props = withDefaults(
34
34
  cycleThemes?: (string | TokenSet)[]
35
35
  /**
36
36
  * Trigger style:
37
- * - "swatch" (default): a square showing the active theme's brand colour.
37
+ * - "swatch" (default): a round chip previewing the theme's surface and brand.
38
38
  * - "icon": a sun/moon icon following the active theme's mode.
39
- * - "pill": swatch + the theme name in a pill (always shows the label).
39
+ * - "pill": the chip and the theme name in a rounded pill.
40
40
  */
41
41
  variant?: "swatch" | "icon" | "pill"
42
42
  /** Show the active theme's name next to the trigger (forced on for "pill"). */
@@ -104,14 +104,12 @@ onMounted(() => {
104
104
  })
105
105
 
106
106
  const SIZES = { sm: "size-7", md: "size-8", lg: "size-9" } as const
107
+ const HEIGHTS = { sm: "h-7", md: "h-8", lg: "h-9" } as const
107
108
  const ICON_SIZE = { sm: 15, md: 16, lg: 18 } as const
109
+ const CHIP = { sm: "size-3.5", md: "size-4", lg: "size-[18px]" } as const
108
110
 
109
111
  const wantsLabel = computed(() => props.showLabel || props.variant === "pill")
110
112
 
111
- // Labelless icon variant renders as a plain square icon button (no border/fill),
112
- // so it sits inline with other toolbar icon buttons. Other variants stay framed.
113
- const bareIcon = computed(() => props.variant === "icon" && !wantsLabel.value)
114
-
115
113
  // Apply a theme through the kit's self-managed setter (it persists + updates
116
114
  // the shared `current`), then notify. The reveal animates from the click point.
117
115
  function applyTheme(t: TokenSet, ev?: MouseEvent) {
@@ -228,30 +226,28 @@ const triggerLabel = computed(
228
226
  type="button"
229
227
  :aria-label="triggerLabel"
230
228
  :title="triggerLabel"
231
- class="inline-flex items-center justify-center gap-2 rounded-control outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
229
+ class="inline-flex shrink-0 items-center justify-center gap-2 outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
232
230
  :class="
233
- bareIcon
234
- ? [SIZES[size], 'text-muted hover:bg-surface-soft hover:text-ink']
235
- : [
236
- 'border border-line bg-surface-raised px-1.5 text-ink hover:border-line-strong',
237
- wantsLabel ? 'py-1 pr-2.5' : '',
231
+ wantsLabel
232
+ ? [
233
+ HEIGHTS[size],
234
+ 'rounded-full border border-line bg-surface-raised pl-2 pr-3 text-ink hover:border-line-strong hover:bg-surface-soft',
238
235
  ]
236
+ : [SIZES[size], 'rounded-control text-muted hover:bg-surface-soft hover:text-ink']
239
237
  "
240
238
  @click="cycle"
241
239
  >
242
- <!-- swatch: brand over surface, read from the applied variables rather
243
- than the theme object, so the server's render (painted from the
244
- cookie) and the client's agree without a hydration patch -->
240
+ <!-- A preview of the applied theme: its surface and brand halves. Read from
241
+ the applied variables rather than the theme object, so the server's
242
+ render (painted from the cookie) and the client's agree. -->
245
243
  <span
246
244
  v-if="variant !== 'icon'"
247
- class="grid shrink-0 place-items-center rounded-control border border-line/60"
248
- :class="SIZES[size]"
249
- style="background: var(--color-surface)"
250
- >
251
- <span class="size-3 rounded-full" style="background: var(--color-brand)" />
252
- </span>
253
- <!-- icon: sun/moon by mode, chosen by CSS from the applied theme's mode -->
254
- <span v-else class="grid shrink-0 place-items-center" :class="SIZES[size]">
245
+ class="shrink-0 rounded-full shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-ink)_22%,transparent)]"
246
+ :class="CHIP[size]"
247
+ style="background: linear-gradient(135deg, var(--color-surface) 50%, var(--color-brand) 50%)"
248
+ />
249
+ <!-- sun/moon by mode, chosen by CSS from the applied theme's mode -->
250
+ <span v-else class="grid shrink-0 place-items-center">
255
251
  <LpIcon
256
252
  name="lucide:moon"
257
253
  :size="ICON_SIZE[size]"
@@ -11,7 +11,8 @@ import {
11
11
  ToastTitle,
12
12
  ToastViewport,
13
13
  } from "reka-ui"
14
- import { onBeforeUnmount, ref, watch } from "vue"
14
+ import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue"
15
+ import { prefersReducedMotion } from "../composables/easing"
15
16
  import { useLabels } from "../composables/useLpConfig"
16
17
  import { useToast, type ToastItem } from "../composables/useToast"
17
18
  import { CLOSE_ICON } from "./dropdown"
@@ -22,6 +23,52 @@ const text = useLabels("toast", { dismiss: "Dismiss" }, () => props.labels)
22
23
 
23
24
  const { queue, dismiss } = useToast()
24
25
 
26
+ /*
27
+ * The stack slides rather than jumps. A new toast lands at the bottom and the
28
+ * ones above used to leap up by its height in a single frame; one leaving let
29
+ * the rest drop the same way. Each toast's layout position (offsetTop, which
30
+ * ignores transforms) is remembered, and when the list changes every toast
31
+ * that moved is played from where it was to where it is (FLIP). The observer
32
+ * fires after the DOM changes and before the frame is painted, so the jump is
33
+ * never seen. Toasts render through reka's portal into the viewport, which is
34
+ * why this watches the DOM rather than a TransitionGroup.
35
+ */
36
+ let observer: MutationObserver | undefined
37
+ const tops = new WeakMap<Element, number>()
38
+
39
+ function toastsIn(root: Element): HTMLElement[] {
40
+ return [...root.querySelectorAll<HTMLElement>("li[data-state]")]
41
+ }
42
+
43
+ function slide(root: Element) {
44
+ const reduce = prefersReducedMotion()
45
+ const height = (root as HTMLElement).offsetHeight
46
+ for (const el of toastsIn(root)) {
47
+ const before = tops.get(el)
48
+ const now = el.offsetTop - height
49
+ tops.set(el, now)
50
+ if (before === undefined || before === now || reduce || el.hasAttribute("data-swipe")) continue
51
+ el.animate([{ translate: `0 ${before - now}px` }, { translate: "0 0" }], {
52
+ duration: 220,
53
+ easing: "cubic-bezier(0.2, 0, 0, 1)",
54
+ })
55
+ }
56
+ }
57
+
58
+ // The viewport's component root is a wrapper fragment, not the list, so the
59
+ // list is found by a mark of its own once it is teleported into place.
60
+ const viewportId = useId()
61
+ onMounted(() =>
62
+ nextTick(() => {
63
+ const root = document.querySelector(`[data-lp-toasts="${viewportId}"]`)
64
+ if (!root || typeof MutationObserver === "undefined") return
65
+ for (const el of toastsIn(root)) tops.set(el, el.offsetTop - (root as HTMLElement).offsetHeight)
66
+ observer = new MutationObserver(() => slide(root))
67
+ observer.observe(root, { childList: true, subtree: true })
68
+ }),
69
+ )
70
+ onBeforeUnmount(() => observer?.disconnect())
71
+
25
72
  // A toast replaced through its `key` keeps its id and gets a new revision, so
26
73
  // everything tied to one rendered toast goes by both.
27
74
  const tag = (t: ToastItem) => `${t.id}:${t.rev}`
@@ -256,6 +303,7 @@ function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[numbe
256
303
  --z-toast (200) > --z-modal (110) wins as intended. -->
257
304
  <Teleport to="body">
258
305
  <ToastViewport
306
+ :data-lp-toasts="viewportId"
259
307
  class="fixed bottom-0 right-0 z-(--z-toast) flex w-[min(92vw,22rem)] flex-col gap-2 p-4 outline-none"
260
308
  />
261
309
  </Teleport>