@leavepulse/ui 0.78.2 → 0.79.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 (225) hide show
  1. package/dist/chunks/{ContextMenuItems-DmaKSJVv.js → ContextMenuItems-DLK4q-mm.js} +1 -1
  2. package/dist/chunks/{DropzoneLinkField-CyE7TJ5f.js → DropzoneLinkField-CcJu8De3.js} +1 -1
  3. package/dist/chunks/{FieldLeading-mr81aEAP.js → FieldLeading-CldGLINn.js} +1 -1
  4. package/dist/chunks/{FieldWrapper-DnuMTrqW.js → FieldWrapper-Cj8ZgoKp.js} +1 -1
  5. package/dist/chunks/{IconCloseButton-COZEsa9u.js → IconCloseButton-CV4iHRpy.js} +1 -1
  6. package/dist/chunks/{LayoutCanvas-CZKOltfA.js → LayoutCanvas-BQbq7Ekw.js} +1 -1
  7. package/dist/chunks/{LayoutNode-D2f7v683.js → LayoutNode-BMedRk3g.js} +1 -1
  8. package/dist/chunks/{LpActionBar-BGMk5KoG.js → LpActionBar-C7g52Cyo.js} +1 -1
  9. package/dist/chunks/{LpAddressInput-ft8fQOGx.js → LpAddressInput-1uakhQk5.js} +7 -7
  10. package/dist/chunks/{LpAlert-CjvTZIKF.js → LpAlert-DWFeR82L.js} +1 -1
  11. package/dist/chunks/{LpAppShell-DkvU6IBB.js → LpAppShell-InIK01Nn.js} +37 -20
  12. package/dist/chunks/{LpAutocomplete-CrZbd0uv.js → LpAutocomplete-DY3LvpB9.js} +4 -4
  13. package/dist/chunks/{LpAvatar-cQVsNk6F.js → LpAvatar-CEZmMcGh.js} +1 -1
  14. package/dist/chunks/{LpBadge-D5QQdDQ_.js → LpBadge-C_VqLHtg.js} +1 -1
  15. package/dist/chunks/{LpBottomNav-zxLGGbjm.js → LpBottomNav-4I89sPDZ.js} +1 -1
  16. package/dist/chunks/{LpBreadcrumbs-CIIBsygI.js → LpBreadcrumbs-1BBzrfxG.js} +1 -1
  17. package/dist/chunks/{LpCalendar-D4-LVjfF.js → LpCalendar-D2dcXjUK.js} +1 -1
  18. package/dist/chunks/{LpCheckbox-CCWCwD0y.js → LpCheckbox-CN-MEnbX.js} +2 -2
  19. package/dist/chunks/{LpCodeBlock-DBqy4RLd.js → LpCodeBlock-Cj7zk4QE.js} +2 -2
  20. package/dist/chunks/{LpCodeDisplay-D59uPwRb.js → LpCodeDisplay-COzosm77.js} +4 -4
  21. package/dist/chunks/{LpColorPicker-BONURBFN.js → LpColorPicker-BezW6DX1.js} +3 -3
  22. package/dist/chunks/{LpConfirmDialog-CnrmzkuN.js → LpConfirmDialog-D9GkDMYr.js} +2 -2
  23. package/dist/chunks/{LpConfirmHost-CwbObVGH.js → LpConfirmHost-BH7kf0dm.js} +1 -1
  24. package/dist/chunks/{LpContextMenu-Bx_IhQDS.js → LpContextMenu-bn5bc9t7.js} +2 -2
  25. package/dist/chunks/{LpContextSwitcher-1C1tExdl.js → LpContextSwitcher-CGr6ZAuq.js} +2 -2
  26. package/dist/chunks/{LpCopyButton-sAMvWeLP.js → LpCopyButton-BSr6YaFR.js} +1 -1
  27. package/dist/chunks/{LpDataList-CaVfsozH.js → LpDataList-Cv7ZUlyy.js} +2 -2
  28. package/dist/chunks/{LpDatePicker-C2AOtxgF.js → LpDatePicker-CR7vsb5M.js} +5 -5
  29. package/dist/chunks/{LpDateRangePicker-CiXdIAyh.js → LpDateRangePicker-B9fRO3G6.js} +3 -3
  30. package/dist/chunks/{LpDisclosure-DG7JCo-d.js → LpDisclosure-CE4mneaE.js} +1 -1
  31. package/dist/chunks/{LpDrawer-DXNQFpAA.js → LpDrawer-DU_5MqBz.js} +1 -1
  32. package/dist/chunks/{LpDropdownMenu-CLzxIOgM.js → LpDropdownMenu-_u6GPWhp.js} +2 -2
  33. package/dist/chunks/{LpEditable-DVT0Rh8F.js → LpEditable-DYoMayjt.js} +2 -2
  34. package/dist/chunks/{LpEmptyState-DurPF001.js → LpEmptyState-DpdHeSfz.js} +1 -1
  35. package/dist/chunks/{LpFieldNotice-DJ10cYIo.js → LpFieldNotice-DONFCWV7.js} +1 -1
  36. package/dist/chunks/{LpFileTree-CpNurwZp.js → LpFileTree-f5ktGqsN.js} +4 -4
  37. package/dist/chunks/{LpFileTreeNode-DdF8Cq-s.js → LpFileTreeNode-nbp-JwV9.js} +4 -4
  38. package/dist/chunks/{LpFormField-BKYn9dyP.js → LpFormField-Bz50_LZW.js} +1 -1
  39. package/dist/chunks/{LpHintLayer-BeKRsRon.js → LpHintLayer-B6bA5Cb_.js} +1 -1
  40. package/dist/chunks/{LpHints-qf3xYsza.js → LpHints-DyJ5hVcr.js} +3 -3
  41. package/dist/chunks/{LpIcon-C6JpQ5td.js → LpIcon-BCQLuHSP.js} +1 -1
  42. package/dist/chunks/{LpInfraNode-Bb5nbuFY.js → LpInfraNode-BS1RXYAs.js} +2 -2
  43. package/dist/chunks/{LpInput-CbI7z5fQ.js → LpInput-BjbXdjcW.js} +3 -3
  44. package/dist/chunks/{LpInspector-DO3fTCQ1.js → LpInspector-BIyJyM6r.js} +4 -4
  45. package/dist/chunks/{LpLightbox-CtDTQV01.js → LpLightbox-CSAJuUaJ.js} +2 -2
  46. package/dist/chunks/{LpLogInspector-CcSJSGan.js → LpLogInspector-OJEz1vod.js} +2 -2
  47. package/dist/chunks/{LpLogViewer-CWu49Dko.js → LpLogViewer-CRLwQpk0.js} +6 -6
  48. package/dist/chunks/{LpMapPicker-D5_YLLBC.js → LpMapPicker-DW2EnrCJ.js} +1 -1
  49. package/dist/chunks/{LpMediaGallery-BYEwySoF.js → LpMediaGallery-BNEYctDP.js} +3 -3
  50. package/dist/chunks/{LpModal-DDvrSAnf.js → LpModal-DdTqdf4x.js} +1 -1
  51. package/dist/chunks/{LpModalNav-DI3BW5ff.js → LpModalNav-BL7Dp0om.js} +3 -3
  52. package/dist/chunks/LpNotificationBell-d0F6MWxD.js +163 -0
  53. package/dist/chunks/LpNotificationFeed-BIe5lapb.js +470 -0
  54. package/dist/chunks/{LpNumberField-C8LfUz_R.js → LpNumberField-D5Dx-hzD.js} +3 -3
  55. package/dist/chunks/{LpOtpInput-B1X9l9no.js → LpOtpInput-BVl74Lfl.js} +1 -1
  56. package/dist/chunks/{LpPagination-snQ020xQ.js → LpPagination-D0wb-Uru.js} +1 -1
  57. package/dist/chunks/{LpPaletteTheme-B-xuZiKK.js → LpPaletteTheme-DdilCBXA.js} +2 -2
  58. package/dist/chunks/{LpPasswordInput-Duc_3wPv.js → LpPasswordInput-DUtqWMkA.js} +2 -2
  59. package/dist/chunks/{LpPhoneInput-CctcRK9K.js → LpPhoneInput-CalRjkqc.js} +3 -3
  60. package/dist/chunks/{LpPie-oVNHjsKD.js → LpPie-BVAEmVBT.js} +2 -2
  61. package/dist/chunks/{LpPipeline-BSXT6oUJ.js → LpPipeline-Dk7_sRP1.js} +2 -2
  62. package/dist/chunks/{LpQuota-CgWPgdVt.js → LpQuota-DKx0IZYA.js} +1 -1
  63. package/dist/chunks/{LpRadioGroup-DTM0nd9G.js → LpRadioGroup-E3SP6j9h.js} +2 -2
  64. package/dist/chunks/{LpRangeCalendar-BrqlGZzE.js → LpRangeCalendar-BFJFaen4.js} +1 -1
  65. package/dist/chunks/{LpRangeSlider-DJsEZXad.js → LpRangeSlider-b6ipywRB.js} +1 -1
  66. package/dist/chunks/{LpRepeater-Czib3rmv.js → LpRepeater-BLrw62Bh.js} +2 -2
  67. package/dist/chunks/{LpSecretField-BtlCk3IH.js → LpSecretField-BV8BNjKc.js} +5 -5
  68. package/dist/chunks/{LpSection-BfteipxU.js → LpSection-CghNNxIv.js} +1 -1
  69. package/dist/chunks/{LpSegmented-sskrJKJJ.js → LpSegmented-DjvrHL_G.js} +2 -2
  70. package/dist/chunks/{LpSelect-JFZehOkd.js → LpSelect-DYPimzAZ.js} +4 -4
  71. package/dist/chunks/{LpSidebar-CvRvVBTt.js → LpSidebar-D3S8rYFf.js} +4 -4
  72. package/dist/chunks/{LpSidebarNav-CE0jQS8i.js → LpSidebarNav-Dlse8vxT.js} +3 -3
  73. package/dist/chunks/{LpSidebarRow-DOTg1txp.js → LpSidebarRow-Cqd_6tGG.js} +3 -3
  74. package/dist/chunks/{LpSlider-C4ilCtZX.js → LpSlider-B9XIC75O.js} +1 -1
  75. package/dist/chunks/{LpStat-DPjc6_fX.js → LpStat-kyLRmX-m.js} +2 -2
  76. package/dist/chunks/{LpStatus-DGJFwk5F.js → LpStatus-szB2dWzM.js} +1 -1
  77. package/dist/chunks/{LpStepMark-Dw2MRTrt.js → LpStepMark-DP3CDSm9.js} +1 -1
  78. package/dist/chunks/{LpStepper-DNnh7lCu.js → LpStepper-BS3iaJIz.js} +1 -1
  79. package/dist/chunks/{LpSwitch-DyAYTchz.js → LpSwitch-DRFy8RNr.js} +1 -1
  80. package/dist/chunks/{LpTabFace-DQoV8mQG.js → LpTabFace-C9-OperL.js} +2 -2
  81. package/dist/chunks/{LpTableFilter-CJg5eIcV.js → LpTableFilter-CEIwLupk.js} +2 -2
  82. package/dist/chunks/{LpTagsInput-CkNxpNYa.js → LpTagsInput-ClDeF_aU.js} +5 -5
  83. package/dist/chunks/{LpTextarea-CFK_5QB8.js → LpTextarea-DEY6pUBy.js} +1 -1
  84. package/dist/chunks/{LpThemeStudio-R-wohbCY.js → LpThemeStudio-WY72Z1Db.js} +5 -5
  85. package/dist/chunks/{LpThemeSwitcher-Bzy15MCP.js → LpThemeSwitcher-9uCH9c-K.js} +3 -3
  86. package/dist/chunks/{LpTimeField-B5n0m8m0.js → LpTimeField-BmgxCzQO.js} +2 -2
  87. package/dist/chunks/{LpToaster-Ct_ZOhCh.js → LpToaster-DLKO-efn.js} +2 -2
  88. package/dist/chunks/{LpTopologyCanvas-BNgw9RyZ.js → LpTopologyCanvas-B-PhPa7k.js} +4 -4
  89. package/dist/chunks/{LpTour-ClKYhx4h.js → LpTour-ODlBcat2.js} +1 -1
  90. package/dist/chunks/{LpTruncate-ellq9RYM.js → LpTruncate--nhT0iUo.js} +1 -1
  91. package/dist/chunks/{LpWindow-B8YSRVTH.js → LpWindow-DJxE8Zl-.js} +2 -2
  92. package/dist/chunks/{LpWorkspaceSwitcher-ugv-fyig.js → LpWorkspaceSwitcher-Bpf41Bw5.js} +3 -3
  93. package/dist/chunks/{MenuTitle-Bh5dAdV1.js → MenuTitle-CMjvLsvC.js} +1 -1
  94. package/dist/chunks/{OptionContent-CGb8QlHA.js → OptionContent-DY8NaG8s.js} +1 -1
  95. package/dist/chunks/{PickerField-BM1dyafh.js → PickerField-CgfkaBgg.js} +1 -1
  96. package/dist/chunks/{RevealToggle-D_iyrB8d.js → RevealToggle-CPMXxaKv.js} +1 -1
  97. package/dist/chunks/{TourCard-DAzTYfav.js → TourCard-D4b3lMHi.js} +1 -1
  98. package/dist/chunks/icons-C1vppR4f.js +59 -0
  99. package/dist/component-names.d.ts +1 -1
  100. package/dist/components/ContextMenuItems.vue.js +1 -1
  101. package/dist/components/DropzoneLinkField.vue.js +1 -1
  102. package/dist/components/FieldLeading.vue.js +1 -1
  103. package/dist/components/FieldWrapper.vue.js +1 -1
  104. package/dist/components/IconCloseButton.vue.js +1 -1
  105. package/dist/components/LayoutCanvas.vue.js +1 -1
  106. package/dist/components/LayoutNode.vue.js +1 -1
  107. package/dist/components/LpActionBar.vue.js +1 -1
  108. package/dist/components/LpAddressInput.vue.js +1 -1
  109. package/dist/components/LpAlert.vue.js +1 -1
  110. package/dist/components/LpAppShell.vue.d.ts +18 -0
  111. package/dist/components/LpAppShell.vue.js +1 -1
  112. package/dist/components/LpAutocomplete.vue.js +1 -1
  113. package/dist/components/LpAvatar.vue.js +1 -1
  114. package/dist/components/LpBadge.vue.js +1 -1
  115. package/dist/components/LpBottomNav.vue.js +1 -1
  116. package/dist/components/LpBreadcrumbs.vue.js +1 -1
  117. package/dist/components/LpCalendar.vue.js +1 -1
  118. package/dist/components/LpChart.vue.js +2 -2
  119. package/dist/components/LpCheckbox.vue.js +1 -1
  120. package/dist/components/LpCodeBlock.vue.js +1 -1
  121. package/dist/components/LpCodeDisplay.vue.js +1 -1
  122. package/dist/components/LpColorPicker.vue.js +1 -1
  123. package/dist/components/LpCommandPalette.vue.js +2 -2
  124. package/dist/components/LpConfirmDialog.vue.js +1 -1
  125. package/dist/components/LpConfirmHost.vue.js +1 -1
  126. package/dist/components/LpContextMenu.vue.js +1 -1
  127. package/dist/components/LpContextSwitcher.vue.js +1 -1
  128. package/dist/components/LpCopyButton.vue.js +1 -1
  129. package/dist/components/LpDataList.vue.js +1 -1
  130. package/dist/components/LpDatePicker.vue.js +1 -1
  131. package/dist/components/LpDateRangePicker.vue.js +1 -1
  132. package/dist/components/LpDisclosure.vue.js +1 -1
  133. package/dist/components/LpDock.vue.js +1 -1
  134. package/dist/components/LpDrawer.vue.js +1 -1
  135. package/dist/components/LpDropdownMenu.vue.js +1 -1
  136. package/dist/components/LpDropzone.vue.js +2 -2
  137. package/dist/components/LpEditable.vue.js +1 -1
  138. package/dist/components/LpEmptyState.vue.js +1 -1
  139. package/dist/components/LpFieldNotice.vue.js +1 -1
  140. package/dist/components/LpFileTree.vue.js +1 -1
  141. package/dist/components/LpFileTreeNode.vue.js +1 -1
  142. package/dist/components/LpFormField.vue.js +1 -1
  143. package/dist/components/LpHintLayer.vue.js +1 -1
  144. package/dist/components/LpHints.vue.js +1 -1
  145. package/dist/components/LpIcon.vue.js +1 -1
  146. package/dist/components/LpImageEditor.vue.js +1 -1
  147. package/dist/components/LpInfraNode.vue.js +1 -1
  148. package/dist/components/LpInput.vue.js +1 -1
  149. package/dist/components/LpInspector.vue.js +1 -1
  150. package/dist/components/LpLightbox.vue.js +1 -1
  151. package/dist/components/LpLogInspector.vue.js +1 -1
  152. package/dist/components/LpLogViewer.vue.js +1 -1
  153. package/dist/components/LpMapPicker.vue.js +1 -1
  154. package/dist/components/LpMediaGallery.vue.js +1 -1
  155. package/dist/components/LpModal.vue.js +1 -1
  156. package/dist/components/LpModalNav.vue.js +1 -1
  157. package/dist/components/LpNotificationBell.vue.d.ts +28 -64
  158. package/dist/components/LpNotificationBell.vue.js +1 -1
  159. package/dist/components/LpNotificationFeed.vue.d.ts +78 -0
  160. package/dist/components/LpNotificationFeed.vue.js +4 -0
  161. package/dist/components/LpNumberField.vue.js +1 -1
  162. package/dist/components/LpOtpInput.vue.js +1 -1
  163. package/dist/components/LpPagination.vue.js +1 -1
  164. package/dist/components/LpPaletteFacts.vue.js +1 -1
  165. package/dist/components/LpPaletteHeader.vue.js +2 -2
  166. package/dist/components/LpPaletteTheme.vue.js +1 -1
  167. package/dist/components/LpPasswordInput.vue.js +1 -1
  168. package/dist/components/LpPhoneInput.vue.js +1 -1
  169. package/dist/components/LpPie.vue.js +1 -1
  170. package/dist/components/LpPipeline.vue.js +1 -1
  171. package/dist/components/LpQuota.vue.js +1 -1
  172. package/dist/components/LpRadioGroup.vue.js +1 -1
  173. package/dist/components/LpRangeCalendar.vue.js +1 -1
  174. package/dist/components/LpRangeSlider.vue.js +1 -1
  175. package/dist/components/LpRepeater.vue.js +1 -1
  176. package/dist/components/LpSecretField.vue.js +1 -1
  177. package/dist/components/LpSection.vue.js +1 -1
  178. package/dist/components/LpSegmented.vue.js +1 -1
  179. package/dist/components/LpSelect.vue.js +1 -1
  180. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  181. package/dist/components/LpSidebar.vue.js +1 -1
  182. package/dist/components/LpSidebarNav.vue.js +1 -1
  183. package/dist/components/LpSidebarRow.vue.js +1 -1
  184. package/dist/components/LpSlider.vue.js +1 -1
  185. package/dist/components/LpStat.vue.js +1 -1
  186. package/dist/components/LpStatus.vue.js +1 -1
  187. package/dist/components/LpStepMark.vue.js +1 -1
  188. package/dist/components/LpStepper.vue.js +1 -1
  189. package/dist/components/LpSwitch.vue.js +1 -1
  190. package/dist/components/LpTabFace.vue.js +1 -1
  191. package/dist/components/LpTable.vue.js +9 -9
  192. package/dist/components/LpTableFilter.vue.js +1 -1
  193. package/dist/components/LpTabs.vue.js +2 -2
  194. package/dist/components/LpTagsInput.vue.js +1 -1
  195. package/dist/components/LpTextarea.vue.js +1 -1
  196. package/dist/components/LpThemeStudio.vue.js +1 -1
  197. package/dist/components/LpThemeSwitcher.vue.js +1 -1
  198. package/dist/components/LpTimeField.vue.js +1 -1
  199. package/dist/components/LpToaster.vue.js +1 -1
  200. package/dist/components/LpTopologyCanvas.vue.js +1 -1
  201. package/dist/components/LpTour.vue.js +1 -1
  202. package/dist/components/LpTruncate.vue.js +1 -1
  203. package/dist/components/LpWindow.vue.js +1 -1
  204. package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
  205. package/dist/components/MenuTitle.vue.js +1 -1
  206. package/dist/components/OptionContent.vue.js +1 -1
  207. package/dist/components/PickerField.vue.js +1 -1
  208. package/dist/components/RevealToggle.vue.js +1 -1
  209. package/dist/components/TourCard.vue.js +1 -1
  210. package/dist/components/menuWhen.js +1 -1
  211. package/dist/components/notifications.d.ts +156 -0
  212. package/dist/components/notifications.js +59 -0
  213. package/dist/icons-all.js +1 -1
  214. package/dist/index.d.ts +2 -1
  215. package/dist/index.js +208 -206
  216. package/package.json +1 -1
  217. package/src/component-names.ts +1 -0
  218. package/src/components/LpAppShell.vue +56 -18
  219. package/src/components/LpNotificationBell.vue +105 -279
  220. package/src/components/LpNotificationFeed.vue +404 -0
  221. package/src/components/notifications.ts +197 -0
  222. package/src/index.ts +6 -1
  223. package/src/kit-icons.ts +1 -1
  224. package/dist/chunks/LpNotificationBell-DUbVyK6R.js +0 -311
  225. package/dist/chunks/icons-CYfcpSDS.js +0 -59
@@ -0,0 +1,404 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A notification feed: the list behind LpNotificationBell, usable on its own
4
+ * for a full "All notifications" page.
5
+ *
6
+ * <LpNotificationFeed :items="items" @mark-read="read" @dismiss="dismiss" />
7
+ *
8
+ * Data in, events out, no fetching inside (the kit convention). What it draws:
9
+ *
10
+ * - a header with All / Unread tabs (plus the caller's `categories`), Mark
11
+ * all read, and Clear and a settings button where those are handled;
12
+ * - rows grouped by day (Today, Yesterday, Earlier), each leading with the
13
+ * sender's avatar or a toned icon, its own buttons (`item.actions`: the
14
+ * answer the notification is waiting for, done where it is read) or what
15
+ * became of it (`item.outcome`), and quick actions on hover or focus
16
+ * (read / unread, dismiss) so the right-click menu is a shortcut, not the
17
+ * only way;
18
+ * - a footer: Load more while `hasMore`, and a link to the whole feed.
19
+ *
20
+ * Keyboard: ↑ ↓ move between rows, Home / End to the ends, Delete dismisses
21
+ * the focused one; Tab reaches each row's buttons.
22
+ *
23
+ * Tabs filter `items` here as well, so a caller holding the whole feed needs
24
+ * nothing more; one fetching per tab binds `v-model:filter` and refetches.
25
+ */
26
+ import { computed, getCurrentInstance, ref } from "vue"
27
+ import { useLabels } from "../composables/useLpConfig"
28
+ import { useNow } from "../composables/useNow"
29
+ import { isHTMLElement } from "../composables/useView"
30
+ import LpAvatar from "./LpAvatar.vue"
31
+ import LpButton from "./LpButton.vue"
32
+ import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
33
+ import LpEmptyState from "./LpEmptyState.vue"
34
+ import LpIcon from "./LpIcon.vue"
35
+ import LpRelativeTime from "./LpRelativeTime.vue"
36
+ import LpScrollArea from "./LpScrollArea.vue"
37
+ import LpShift from "./LpShift.vue"
38
+ import LpSkeleton from "./LpSkeleton.vue"
39
+ import LpTabs, { type TabItem } from "./LpTabs.vue"
40
+ import LpTooltip from "./LpTooltip.vue"
41
+ import {
42
+ groupByDay as groupRuns,
43
+ NOTIFICATION_LABELS,
44
+ untilMidnight,
45
+ type NotificationAction,
46
+ type NotificationCategory,
47
+ type NotificationItem,
48
+ type NotificationLabels,
49
+ type NotificationMenu,
50
+ } from "./notifications"
51
+ import { toneLook } from "./tone"
52
+
53
+ const props = withDefaults(
54
+ defineProps<{
55
+ items?: NotificationItem[]
56
+ /** Unread count for the Unread tab; falls back to counting unread items. */
57
+ unreadCount?: number
58
+ loading?: boolean
59
+ /** More is waiting past the last row: a Load more button asks for it (`@load-more`). */
60
+ hasMore?: boolean
61
+ /** A `@load-more` is in flight. */
62
+ loadingMore?: boolean
63
+ title?: string
64
+ emptyLabel?: string
65
+ /** Tabs after All and Unread, matched against `item.category`. */
66
+ categories?: NotificationCategory[]
67
+ /** Head runs of rows with Today / Yesterday / Earlier. Default true. */
68
+ groupByDay?: boolean
69
+ /** Drop the title row (tabs and buttons stay). */
70
+ hideTitle?: boolean
71
+ /** The footer's link to the whole feed. */
72
+ allLink?: string
73
+ /**
74
+ * Scroll inside the feed, capped at this height (any CSS length). `false`
75
+ * lets it grow with the page, for a full page of notifications.
76
+ */
77
+ maxHeight?: string | false
78
+ /** Extra right-click entries for a row, after mark read / open / dismiss. */
79
+ itemMenu?: NotificationMenu
80
+ labels?: NotificationLabels
81
+ /**
82
+ * Formats `item.createdAt` for display. Defaults to a narrow relative time
83
+ * in the app's locale ("now", "5m ago", "3h ago").
84
+ */
85
+ formatTime?: (iso: string) => string
86
+ }>(),
87
+ { items: () => [], groupByDay: true, maxHeight: "min(28rem, 70dvh)" },
88
+ )
89
+
90
+ const filter = defineModel<string>("filter", { default: "all" })
91
+
92
+ const emit = defineEmits<{
93
+ (e: "markRead", id: string): void
94
+ (e: "markUnread", id: string): void
95
+ (e: "markAllRead"): void
96
+ /** Empty the whole feed. The header button only appears when this is bound. */
97
+ (e: "clear"): void
98
+ (e: "dismiss", id: string): void
99
+ (e: "select", item: NotificationItem): void
100
+ /** One of the row's own buttons was pressed (after its `onSelect`, if any, was started). */
101
+ (e: "action", item: NotificationItem, action: NotificationAction): void
102
+ /** The header's gear. Only drawn when this is bound. */
103
+ (e: "settings"): void
104
+ (e: "loadMore"): void
105
+ }>()
106
+
107
+ const text = useLabels("notifications", NOTIFICATION_LABELS, () => props.labels as Partial<typeof NOTIFICATION_LABELS>)
108
+ const l = computed(() => ({
109
+ ...text.value,
110
+ title: props.title ?? text.value.title,
111
+ empty: props.emptyLabel ?? text.value.empty,
112
+ }))
113
+
114
+ /*
115
+ * Which optional actions the consumer handles. NOT `useAttrs()`: a listener
116
+ * for a DECLARED emit is removed from `$attrs`; the raw vnode props keep it.
117
+ */
118
+ const instance = getCurrentInstance()
119
+ const handles = (name: string) => Boolean(instance?.vnode.props?.[name])
120
+ const handlesClear = computed(() => handles("onClear"))
121
+ const handlesSettings = computed(() => handles("onSettings"))
122
+
123
+ const unread = computed(() => props.unreadCount ?? props.items.filter((n) => !n.read).length)
124
+ const hasUnread = computed(() => unread.value > 0)
125
+
126
+ const tabs = computed<TabItem[]>(() => [
127
+ { value: "all", label: l.value.all },
128
+ { value: "unread", label: l.value.unreadTab, badge: unread.value || undefined },
129
+ ...(props.categories ?? []).map((c) => ({ value: c.value, label: c.label, icon: c.icon })),
130
+ ])
131
+
132
+ const shown = computed(() => {
133
+ const f = filter.value
134
+ if (f === "all") return props.items
135
+ if (f === "unread") return props.items.filter((n) => !n.read)
136
+ return props.items.filter((n) => n.category === f)
137
+ })
138
+
139
+ // The groups move at midnight, not on every render.
140
+ const now = useNow(() => props.groupByDay, { nextIn: untilMidnight })
141
+ const groups = computed(() => groupRuns(shown.value, now.value, props.groupByDay))
142
+
143
+ const emptyTitle = computed(() => (filter.value === "unread" && props.items.length ? l.value.emptyUnread : l.value.empty))
144
+
145
+ function select(item: NotificationItem) {
146
+ if (!item.read) emit("markRead", item.id)
147
+ emit("select", item)
148
+ }
149
+
150
+ function toggleRead(item: NotificationItem) {
151
+ if (item.read) emit("markUnread", item.id)
152
+ else emit("markRead", item.id)
153
+ }
154
+
155
+ /* Rows with an action under way: their buttons wait for it. */
156
+ const pending = ref<Record<string, number>>({})
157
+ const busy = (item: NotificationItem) => pending.value[item.id] !== undefined
158
+
159
+ async function runAction(item: NotificationItem, action: NotificationAction, index: number) {
160
+ if (busy(item)) return
161
+ // Answering a notification is reading it.
162
+ if (!item.read) emit("markRead", item.id)
163
+ const result = action.onSelect?.(item)
164
+ emit("action", item, action)
165
+ if (typeof (result as PromiseLike<unknown> | undefined)?.then !== "function") return
166
+ pending.value = { ...pending.value, [item.id]: index }
167
+ try {
168
+ await result
169
+ } finally {
170
+ const { [item.id]: _, ...rest } = pending.value
171
+ pending.value = rest
172
+ }
173
+ }
174
+
175
+ const ACTION_VARIANT = { primary: "solid", secondary: "soft", danger: "danger" } as const
176
+
177
+ function rowMenu(item: NotificationItem): ContextMenuItemDef[] {
178
+ const actions: ContextMenuItemDef[] = [
179
+ item.read
180
+ ? { label: l.value.markAsUnread, icon: "lucide:mail", onSelect: () => emit("markUnread", item.id) }
181
+ : { label: l.value.markAsRead, icon: "lucide:check", onSelect: () => emit("markRead", item.id) },
182
+ ]
183
+ if (item.link) actions.push({ label: l.value.open, icon: "lucide:external-link", onSelect: () => select(item) })
184
+ const extra = props.itemMenu?.(item) ?? []
185
+ if (extra.length) actions.push({ ...extra[0]!, separatorBefore: true }, ...extra.slice(1))
186
+ actions.push({ label: l.value.dismiss, icon: "lucide:x", danger: true, separatorBefore: true, onSelect: () => emit("dismiss", item.id) })
187
+ return actions
188
+ }
189
+
190
+ // A `formatTime` prop takes over the row's time; it is re-asked twice a minute.
191
+ const customNow = useNow(() => !!props.formatTime, { nextIn: () => 30_000 })
192
+ function customTime(iso: string): string {
193
+ void customNow.value
194
+ return props.formatTime ? props.formatTime(iso) : ""
195
+ }
196
+
197
+ /* ↑ ↓ Home End between rows, Delete dismisses: the list is one place, not a Tab per row. */
198
+ const listEl = ref<HTMLElement | null>(null)
199
+ function onListKeydown(event: KeyboardEvent) {
200
+ const target = event.target
201
+ if (!isHTMLElement(target) || !target.matches("[data-lp-notification-main]")) return
202
+ const rows = [...(listEl.value?.querySelectorAll<HTMLElement>("[data-lp-notification-main]") ?? [])]
203
+ const at = rows.indexOf(target)
204
+ const to =
205
+ event.key === "ArrowDown" ? Math.min(rows.length - 1, at + 1)
206
+ : event.key === "ArrowUp" ? Math.max(0, at - 1)
207
+ : event.key === "Home" ? 0
208
+ : event.key === "End" ? rows.length - 1
209
+ : -1
210
+ if (to >= 0) {
211
+ event.preventDefault()
212
+ rows[to]?.focus()
213
+ return
214
+ }
215
+ if (event.key === "Delete") {
216
+ const id = target.dataset.lpNotificationMain
217
+ if (!id) return
218
+ event.preventDefault()
219
+ // Focus the neighbour first: the dismissed row takes its focus with it.
220
+ ;(rows[at + 1] ?? rows[at - 1])?.focus()
221
+ emit("dismiss", id)
222
+ }
223
+ }
224
+
225
+ const QUICK =
226
+ "relative z-1 grid size-7 place-items-center rounded-inner text-muted outline-none transition-colors hover:bg-ink/[0.07] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
227
+ </script>
228
+
229
+ <template>
230
+ <section class="flex min-w-0 flex-col" :aria-label="l.title">
231
+ <header class="flex flex-col gap-1.5 px-3 pt-2.5">
232
+ <div v-if="!hideTitle" class="flex min-h-7 items-center justify-between gap-2">
233
+ <h2 class="text-sm font-semibold text-ink">{{ l.title }}</h2>
234
+ <span class="flex items-center gap-0.5">
235
+ <button
236
+ v-if="hasUnread"
237
+ type="button"
238
+ class="rounded-inner px-1.5 py-1 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
239
+ @click="emit('markAllRead')"
240
+ >
241
+ {{ l.markAllRead }}
242
+ </button>
243
+ <!-- Emptying the feed is destructive and different from reading it:
244
+ an icon, muted, away from the primary action; only when handled. -->
245
+ <LpTooltip v-if="items.length && handlesClear" :content="l.clear" label>
246
+ <button type="button" :class="QUICK" @click="emit('clear')">
247
+ <LpIcon name="lucide:trash-2" :size="15" />
248
+ </button>
249
+ </LpTooltip>
250
+ <LpTooltip v-if="handlesSettings" :content="l.settings" label>
251
+ <button type="button" :class="QUICK" @click="emit('settings')">
252
+ <LpIcon name="lucide:settings" :size="15" />
253
+ </button>
254
+ </LpTooltip>
255
+ </span>
256
+ </div>
257
+ <LpTabs v-model="filter" :items="tabs" variant="underline" :hover-follow="false" :label="l.title" class="-mx-1" />
258
+ </header>
259
+
260
+ <!-- Skeleton → empty → feed are different heights; easing the swap keeps
261
+ a popover from snapping under its trigger. -->
262
+ <LpShift axis="height">
263
+ <div v-if="loading" class="space-y-1 p-2">
264
+ <div v-for="i in 4" :key="i" class="flex gap-3 px-2 py-2">
265
+ <LpSkeleton class="size-8 rounded-full" />
266
+ <div class="flex-1 space-y-1.5"><LpSkeleton class="h-3.5 w-2/3" /><LpSkeleton class="h-3 w-full" /></div>
267
+ </div>
268
+ </div>
269
+
270
+ <LpEmptyState
271
+ v-else-if="!shown.length"
272
+ :icon="filter === 'unread' && items.length ? 'lucide:check-check' : 'lucide:bell-off'"
273
+ :title="emptyTitle"
274
+ class="px-3 py-10"
275
+ />
276
+
277
+ <component
278
+ :is="maxHeight === false ? 'div' : LpScrollArea"
279
+ v-else
280
+ v-bind="maxHeight === false ? {} : { style: { maxHeight }, contentClass: 'py-1' }"
281
+ >
282
+ <div ref="listEl" @keydown="onListKeydown">
283
+ <section v-for="group in groups" :key="group.key" :aria-label="group.day ? l[group.day] : undefined">
284
+ <h3 v-if="group.day" class="px-4 pb-1 pt-2.5 text-[11px] font-medium uppercase tracking-wide text-muted">
285
+ {{ l[group.day] }}
286
+ </h3>
287
+ <ul class="flex flex-col gap-px px-1.5">
288
+ <LpContextMenu v-for="item in group.items" :key="item.id" :items="rowMenu(item)">
289
+ <li
290
+ class="group/notif relative flex gap-3 rounded-control px-2.5 py-2.5 transition-colors hover:bg-surface-soft focus-within:bg-surface-soft"
291
+ :class="item.read ? '' : 'bg-brand-soft/30'"
292
+ :data-lp-notification="item.id"
293
+ :data-read="item.read ? '' : undefined"
294
+ >
295
+ <!-- Leading: who it is from, else what kind of thing it is. A
296
+ read row's icon goes muted: kind only competes for
297
+ attention while it is news. -->
298
+ <span class="relative mt-0.5 flex shrink-0 self-start">
299
+ <LpAvatar v-if="item.actor" :src="item.actor.avatar" :alt="item.actor.name" size="sm" />
300
+ <span
301
+ v-if="!item.actor || item.icon"
302
+ class="grid place-items-center"
303
+ :class="[
304
+ item.actor
305
+ ? 'absolute -bottom-1 -right-1.5 size-3.5 rounded-full ring-2 ring-surface-overlay'
306
+ : 'size-7 rounded-control',
307
+ item.read ? 'bg-surface-soft text-muted' : toneLook(item.tone, 'brand').chip,
308
+ ]"
309
+ >
310
+ <LpIcon :name="item.icon || 'lucide:bell'" :size="item.actor ? 9 : 15" />
311
+ </span>
312
+ </span>
313
+
314
+ <div class="min-w-0 flex-1">
315
+ <div class="flex items-baseline gap-2">
316
+ <!-- The title is the row's control; its ::after stretches
317
+ over the tile, so a press anywhere on it opens the
318
+ notification while the buttons sit above it. -->
319
+ <component
320
+ :is="item.link ? 'a' : 'button'"
321
+ :href="item.link || undefined"
322
+ :type="item.link ? undefined : 'button'"
323
+ :data-lp-notification-main="item.id"
324
+ class="min-w-0 flex-1 truncate text-left text-sm outline-none after:absolute after:inset-0 after:rounded-control focus-visible:after:ring-2 focus-visible:after:ring-ring"
325
+ :class="item.read ? 'font-normal text-muted-strong' : 'font-semibold text-ink'"
326
+ @click="select(item)"
327
+ >
328
+ <span v-if="!item.read" class="sr-only">{{ l.unreadItem }}: </span>{{ item.title }}
329
+ </component>
330
+ <span
331
+ v-if="item.createdAt"
332
+ class="shrink-0 text-[11px] text-muted transition-opacity group-focus-within/notif:opacity-0 group-hover/notif:opacity-0 [@media(hover:none)]:opacity-100!"
333
+ >
334
+ <template v-if="formatTime">{{ customTime(item.createdAt) }}</template>
335
+ <LpRelativeTime v-else :date="item.createdAt" format="narrow" :tooltip="false" />
336
+ </span>
337
+ <span v-if="!item.read" aria-hidden="true" class="size-2 shrink-0 self-center rounded-full bg-brand" />
338
+ </div>
339
+ <p v-if="item.body" class="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">{{ item.body }}</p>
340
+
341
+ <p v-if="item.outcome" class="mt-1.5 flex items-center gap-1 text-xs font-medium text-muted-strong">
342
+ <LpIcon name="lucide:check" :size="13" />{{ item.outcome }}
343
+ </p>
344
+ <div
345
+ v-else-if="item.actions?.length"
346
+ role="group"
347
+ :aria-label="l.actions"
348
+ class="relative z-1 mt-2 flex flex-wrap gap-1.5"
349
+ >
350
+ <LpButton
351
+ v-for="(action, i) in item.actions"
352
+ :key="action.label"
353
+ size="xs"
354
+ class="h-7"
355
+ :variant="ACTION_VARIANT[action.variant ?? 'secondary']"
356
+ :href="action.href"
357
+ :loading="pending[item.id] === i"
358
+ :disabled="busy(item) && pending[item.id] !== i"
359
+ @click="runAction(item, action, i)"
360
+ >
361
+ <LpIcon v-if="action.icon" :name="action.icon" :size="13" />{{ action.label }}
362
+ </LpButton>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- Quick actions: on hover and focus where there is a pointer,
367
+ always on touch; they take the time's place. -->
368
+ <span
369
+ class="absolute right-2 top-1.5 z-1 flex gap-0.5 rounded-inner bg-surface-soft opacity-0 transition-opacity group-focus-within/notif:opacity-100 group-hover/notif:opacity-100 [@media(hover:none)]:static [@media(hover:none)]:self-start [@media(hover:none)]:bg-transparent [@media(hover:none)]:opacity-100"
370
+ >
371
+ <LpTooltip :content="item.read ? l.markAsUnread : l.markAsRead" label>
372
+ <button type="button" :class="QUICK" @click="toggleRead(item)">
373
+ <LpIcon :name="item.read ? 'lucide:mail' : 'lucide:check'" :size="15" />
374
+ </button>
375
+ </LpTooltip>
376
+ <LpTooltip :content="l.dismiss" label>
377
+ <button type="button" :class="QUICK" @click="emit('dismiss', item.id)">
378
+ <LpIcon name="lucide:x" :size="15" />
379
+ </button>
380
+ </LpTooltip>
381
+ </span>
382
+ </li>
383
+ </LpContextMenu>
384
+ </ul>
385
+ </section>
386
+ <div v-if="hasMore" class="px-3 pb-1 pt-2">
387
+ <LpButton variant="muted" size="sm" block :loading="loadingMore" @click="emit('loadMore')">{{ l.loadMore }}</LpButton>
388
+ </div>
389
+ </div>
390
+ </component>
391
+ </LpShift>
392
+
393
+ <footer v-if="allLink || $slots.footer" class="border-t border-line px-2 py-1.5">
394
+ <slot name="footer">
395
+ <a
396
+ :href="allLink"
397
+ class="flex items-center justify-center gap-1 rounded-inner py-1.5 text-xs font-medium text-brand outline-none transition-colors hover:bg-brand-soft focus-visible:ring-2 focus-visible:ring-ring"
398
+ >
399
+ {{ l.viewAll }}<LpIcon name="lucide:arrow-right" :size="13" />
400
+ </a>
401
+ </slot>
402
+ </footer>
403
+ </section>
404
+ </template>
@@ -0,0 +1,197 @@
1
+ import type { ContextMenuItemDef } from "./LpContextMenu.vue"
2
+ import type { ToneName } from "./tone"
3
+
4
+ /**
5
+ * A button on a notification itself: "Approve", "Reply", "View invoice". The
6
+ * thing the notification asks for, done where it is read instead of a page away.
7
+ */
8
+ export interface NotificationAction {
9
+ label: string
10
+ /**
11
+ * `primary` is the one answer the notification is waiting for; `danger` a
12
+ * destructive one ("Decline", "Revoke"). Unset is a quiet secondary button.
13
+ */
14
+ variant?: "primary" | "secondary" | "danger"
15
+ icon?: string
16
+ /** A destination instead of a handler: the button is a link. */
17
+ href?: string
18
+ /**
19
+ * Runs on press. A returned promise shows the button busy, and holds the
20
+ * row's other actions, until it settles; a rejection is left to the caller
21
+ * (toast it), and the row stays as it was.
22
+ */
23
+ onSelect?: (item: NotificationItem) => unknown
24
+ }
25
+
26
+ /** Who a notification is from, drawn as their avatar in place of the icon. */
27
+ export interface NotificationActor {
28
+ name: string
29
+ /** Picture URL; the initials of `name` show without one. */
30
+ avatar?: string
31
+ }
32
+
33
+ export interface NotificationItem {
34
+ id: string
35
+ title: string
36
+ body?: string
37
+ /** Optional icon name (e.g. "lucide:check-circle"). With an `actor`, it is a small badge on the avatar. */
38
+ icon?: string
39
+ /**
40
+ * Colours the row's icon by what KIND of notification it is — a billing
41
+ * notice and a security one should not look alike at a glance.
42
+ *
43
+ * A named tone rather than a class, so the categories stay themeable:
44
+ * consumers were mapping their own types onto raw palette classes, which is
45
+ * the one place a theme cannot follow them.
46
+ */
47
+ tone?: ToneName
48
+ /** Who it is from; the row leads with their avatar. */
49
+ actor?: NotificationActor
50
+ /** Optional deep link; the row becomes a link when set. */
51
+ link?: string
52
+ /** ISO-8601 creation time; rendered as a relative label and used for the day groups. */
53
+ createdAt?: string
54
+ /** Whether the notification has been read. */
55
+ read?: boolean
56
+ /** Buttons on the notification itself. See `NotificationAction`. */
57
+ actions?: NotificationAction[]
58
+ /**
59
+ * What became of it, once acted on ("Approved", "Declined by Anna"). Shown
60
+ * in place of the actions, so a handled request reads as handled.
61
+ */
62
+ outcome?: string
63
+ /** Which of the feed's `categories` tabs it belongs to. */
64
+ category?: string
65
+ }
66
+
67
+ /** A tab of the feed beside All and Unread, keyed by `NotificationItem.category`. */
68
+ export interface NotificationCategory {
69
+ value: string
70
+ label: string
71
+ icon?: string
72
+ }
73
+
74
+ /**
75
+ * Every user-facing string the feed and the bell render on their own. Apps
76
+ * with i18n pass their translations in (or set the `notifications` scope in
77
+ * the kit config); the English defaults keep the components usable bare.
78
+ */
79
+ export interface NotificationLabels {
80
+ title?: string
81
+ markAllRead?: string
82
+ clear?: string
83
+ markAsRead?: string
84
+ markAsUnread?: string
85
+ open?: string
86
+ dismiss?: string
87
+ empty?: string
88
+ /** The Unread tab's empty state. */
89
+ emptyUnread?: string
90
+ ariaLabel?: string
91
+ /** Appended to the button's name while there is unread news; `{count}` is filled in. */
92
+ unread?: string
93
+ /** Screen-reader marker on an unread row. */
94
+ unreadItem?: string
95
+ /** Tabs. */
96
+ all?: string
97
+ unreadTab?: string
98
+ /** Day groups. */
99
+ today?: string
100
+ yesterday?: string
101
+ earlier?: string
102
+ /** The header's settings button. */
103
+ settings?: string
104
+ /** The footer link (`allLink`). */
105
+ viewAll?: string
106
+ loadMore?: string
107
+ /** Actions of a row, as a group. */
108
+ actions?: string
109
+ /**
110
+ * No longer read: times are worded by Intl in the app's locale. Still
111
+ * accepted so existing label objects keep type-checking.
112
+ * @deprecated
113
+ */
114
+ justNow?: string
115
+ /** @deprecated See `justNow`. */
116
+ minutes?: string
117
+ /** @deprecated See `justNow`. */
118
+ hours?: string
119
+ /** @deprecated See `justNow`. */
120
+ days?: string
121
+ }
122
+
123
+ export const NOTIFICATION_LABELS = {
124
+ title: "Notifications",
125
+ markAllRead: "Mark all read",
126
+ clear: "Clear",
127
+ markAsRead: "Mark as read",
128
+ markAsUnread: "Mark as unread",
129
+ open: "Open",
130
+ dismiss: "Dismiss",
131
+ empty: "No notifications",
132
+ emptyUnread: "You're all caught up",
133
+ ariaLabel: "",
134
+ unread: "{count} unread",
135
+ unreadItem: "Unread",
136
+ all: "All",
137
+ unreadTab: "Unread",
138
+ today: "Today",
139
+ yesterday: "Yesterday",
140
+ earlier: "Earlier",
141
+ settings: "Notification settings",
142
+ viewAll: "View all notifications",
143
+ loadMore: "Load more",
144
+ actions: "Actions",
145
+ }
146
+
147
+ /** Extra right-click entries for a row, after the built-in ones. */
148
+ export type NotificationMenu = (item: NotificationItem) => ContextMenuItemDef[]
149
+
150
+ const DAY_MS = 86_400_000
151
+
152
+ function dayStart(time: number): number {
153
+ const d = new Date(time)
154
+ d.setHours(0, 0, 0, 0)
155
+ return d.getTime()
156
+ }
157
+
158
+ /** today | yesterday | earlier, in the viewer's own calendar. */
159
+ export function dayOf(iso: string | undefined, now: number): "today" | "yesterday" | "earlier" {
160
+ const time = iso ? Date.parse(iso) : Number.NaN
161
+ if (Number.isNaN(time)) return "earlier"
162
+ const today = dayStart(now)
163
+ if (time >= today) return "today"
164
+ // Not today - DAY_MS: a day across a clock change is 23 or 25 hours.
165
+ if (time >= dayStart(today - DAY_MS / 2)) return "yesterday"
166
+ return "earlier"
167
+ }
168
+
169
+ export interface NotificationGroup {
170
+ key: string
171
+ day: "today" | "yesterday" | "earlier" | null
172
+ items: NotificationItem[]
173
+ }
174
+
175
+ /**
176
+ * Runs of consecutive items from the same day. Consecutive, not sorted: the
177
+ * feed keeps the caller's order, and an out-of-order item opens a new run
178
+ * rather than being moved away from where the caller put it.
179
+ */
180
+ export function groupByDay(items: NotificationItem[], now: number, grouped: boolean): NotificationGroup[] {
181
+ if (!grouped || !items.some((item) => item.createdAt)) return [{ key: "all", day: null, items }]
182
+ const groups: NotificationGroup[] = []
183
+ for (const item of items) {
184
+ const day = dayOf(item.createdAt, now)
185
+ const last = groups.at(-1)
186
+ if (last?.day === day) last.items.push(item)
187
+ else groups.push({ key: `${day}-${groups.length}`, day, items: [item] })
188
+ }
189
+ return groups
190
+ }
191
+
192
+ /** The milliseconds until the next local midnight, when the day groups move. */
193
+ export function untilMidnight(now: number): number {
194
+ const next = new Date(now)
195
+ next.setHours(24, 0, 0, 0)
196
+ return Math.max(1_000, next.getTime() - now)
197
+ }
package/src/index.ts CHANGED
@@ -170,10 +170,15 @@ export { default as LpMediaGallery } from "./components/LpMediaGallery.vue"
170
170
  export { default as LpModal } from "./components/LpModal.vue"
171
171
  export { default as LpModalNav } from "./components/LpModalNav.vue"
172
172
  export { default as LpNotificationBell } from "./components/LpNotificationBell.vue"
173
+ export { default as LpNotificationFeed } from "./components/LpNotificationFeed.vue"
173
174
  export type {
175
+ NotificationAction,
176
+ NotificationActor,
177
+ NotificationCategory,
174
178
  NotificationItem,
175
179
  NotificationLabels,
176
- } from "./components/LpNotificationBell.vue"
180
+ NotificationMenu,
181
+ } from "./components/notifications"
177
182
  export { default as LpNumberField } from "./components/LpNumberField.vue"
178
183
  export type { NumberFieldLabels } from "./components/LpNumberField.vue"
179
184
  export { default as LpTimeField } from "./components/LpTimeField.vue"