@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.
- package/dist/chunks/{ContextMenuItems-DmaKSJVv.js → ContextMenuItems-DLK4q-mm.js} +1 -1
- package/dist/chunks/{DropzoneLinkField-CyE7TJ5f.js → DropzoneLinkField-CcJu8De3.js} +1 -1
- package/dist/chunks/{FieldLeading-mr81aEAP.js → FieldLeading-CldGLINn.js} +1 -1
- package/dist/chunks/{FieldWrapper-DnuMTrqW.js → FieldWrapper-Cj8ZgoKp.js} +1 -1
- package/dist/chunks/{IconCloseButton-COZEsa9u.js → IconCloseButton-CV4iHRpy.js} +1 -1
- package/dist/chunks/{LayoutCanvas-CZKOltfA.js → LayoutCanvas-BQbq7Ekw.js} +1 -1
- package/dist/chunks/{LayoutNode-D2f7v683.js → LayoutNode-BMedRk3g.js} +1 -1
- package/dist/chunks/{LpActionBar-BGMk5KoG.js → LpActionBar-C7g52Cyo.js} +1 -1
- package/dist/chunks/{LpAddressInput-ft8fQOGx.js → LpAddressInput-1uakhQk5.js} +7 -7
- package/dist/chunks/{LpAlert-CjvTZIKF.js → LpAlert-DWFeR82L.js} +1 -1
- package/dist/chunks/{LpAppShell-DkvU6IBB.js → LpAppShell-InIK01Nn.js} +37 -20
- package/dist/chunks/{LpAutocomplete-CrZbd0uv.js → LpAutocomplete-DY3LvpB9.js} +4 -4
- package/dist/chunks/{LpAvatar-cQVsNk6F.js → LpAvatar-CEZmMcGh.js} +1 -1
- package/dist/chunks/{LpBadge-D5QQdDQ_.js → LpBadge-C_VqLHtg.js} +1 -1
- package/dist/chunks/{LpBottomNav-zxLGGbjm.js → LpBottomNav-4I89sPDZ.js} +1 -1
- package/dist/chunks/{LpBreadcrumbs-CIIBsygI.js → LpBreadcrumbs-1BBzrfxG.js} +1 -1
- package/dist/chunks/{LpCalendar-D4-LVjfF.js → LpCalendar-D2dcXjUK.js} +1 -1
- package/dist/chunks/{LpCheckbox-CCWCwD0y.js → LpCheckbox-CN-MEnbX.js} +2 -2
- package/dist/chunks/{LpCodeBlock-DBqy4RLd.js → LpCodeBlock-Cj7zk4QE.js} +2 -2
- package/dist/chunks/{LpCodeDisplay-D59uPwRb.js → LpCodeDisplay-COzosm77.js} +4 -4
- package/dist/chunks/{LpColorPicker-BONURBFN.js → LpColorPicker-BezW6DX1.js} +3 -3
- package/dist/chunks/{LpConfirmDialog-CnrmzkuN.js → LpConfirmDialog-D9GkDMYr.js} +2 -2
- package/dist/chunks/{LpConfirmHost-CwbObVGH.js → LpConfirmHost-BH7kf0dm.js} +1 -1
- package/dist/chunks/{LpContextMenu-Bx_IhQDS.js → LpContextMenu-bn5bc9t7.js} +2 -2
- package/dist/chunks/{LpContextSwitcher-1C1tExdl.js → LpContextSwitcher-CGr6ZAuq.js} +2 -2
- package/dist/chunks/{LpCopyButton-sAMvWeLP.js → LpCopyButton-BSr6YaFR.js} +1 -1
- package/dist/chunks/{LpDataList-CaVfsozH.js → LpDataList-Cv7ZUlyy.js} +2 -2
- package/dist/chunks/{LpDatePicker-C2AOtxgF.js → LpDatePicker-CR7vsb5M.js} +5 -5
- package/dist/chunks/{LpDateRangePicker-CiXdIAyh.js → LpDateRangePicker-B9fRO3G6.js} +3 -3
- package/dist/chunks/{LpDisclosure-DG7JCo-d.js → LpDisclosure-CE4mneaE.js} +1 -1
- package/dist/chunks/{LpDrawer-DXNQFpAA.js → LpDrawer-DU_5MqBz.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-CLzxIOgM.js → LpDropdownMenu-_u6GPWhp.js} +2 -2
- package/dist/chunks/{LpEditable-DVT0Rh8F.js → LpEditable-DYoMayjt.js} +2 -2
- package/dist/chunks/{LpEmptyState-DurPF001.js → LpEmptyState-DpdHeSfz.js} +1 -1
- package/dist/chunks/{LpFieldNotice-DJ10cYIo.js → LpFieldNotice-DONFCWV7.js} +1 -1
- package/dist/chunks/{LpFileTree-CpNurwZp.js → LpFileTree-f5ktGqsN.js} +4 -4
- package/dist/chunks/{LpFileTreeNode-DdF8Cq-s.js → LpFileTreeNode-nbp-JwV9.js} +4 -4
- package/dist/chunks/{LpFormField-BKYn9dyP.js → LpFormField-Bz50_LZW.js} +1 -1
- package/dist/chunks/{LpHintLayer-BeKRsRon.js → LpHintLayer-B6bA5Cb_.js} +1 -1
- package/dist/chunks/{LpHints-qf3xYsza.js → LpHints-DyJ5hVcr.js} +3 -3
- package/dist/chunks/{LpIcon-C6JpQ5td.js → LpIcon-BCQLuHSP.js} +1 -1
- package/dist/chunks/{LpInfraNode-Bb5nbuFY.js → LpInfraNode-BS1RXYAs.js} +2 -2
- package/dist/chunks/{LpInput-CbI7z5fQ.js → LpInput-BjbXdjcW.js} +3 -3
- package/dist/chunks/{LpInspector-DO3fTCQ1.js → LpInspector-BIyJyM6r.js} +4 -4
- package/dist/chunks/{LpLightbox-CtDTQV01.js → LpLightbox-CSAJuUaJ.js} +2 -2
- package/dist/chunks/{LpLogInspector-CcSJSGan.js → LpLogInspector-OJEz1vod.js} +2 -2
- package/dist/chunks/{LpLogViewer-CWu49Dko.js → LpLogViewer-CRLwQpk0.js} +6 -6
- package/dist/chunks/{LpMapPicker-D5_YLLBC.js → LpMapPicker-DW2EnrCJ.js} +1 -1
- package/dist/chunks/{LpMediaGallery-BYEwySoF.js → LpMediaGallery-BNEYctDP.js} +3 -3
- package/dist/chunks/{LpModal-DDvrSAnf.js → LpModal-DdTqdf4x.js} +1 -1
- package/dist/chunks/{LpModalNav-DI3BW5ff.js → LpModalNav-BL7Dp0om.js} +3 -3
- package/dist/chunks/LpNotificationBell-d0F6MWxD.js +163 -0
- package/dist/chunks/LpNotificationFeed-BIe5lapb.js +470 -0
- package/dist/chunks/{LpNumberField-C8LfUz_R.js → LpNumberField-D5Dx-hzD.js} +3 -3
- package/dist/chunks/{LpOtpInput-B1X9l9no.js → LpOtpInput-BVl74Lfl.js} +1 -1
- package/dist/chunks/{LpPagination-snQ020xQ.js → LpPagination-D0wb-Uru.js} +1 -1
- package/dist/chunks/{LpPaletteTheme-B-xuZiKK.js → LpPaletteTheme-DdilCBXA.js} +2 -2
- package/dist/chunks/{LpPasswordInput-Duc_3wPv.js → LpPasswordInput-DUtqWMkA.js} +2 -2
- package/dist/chunks/{LpPhoneInput-CctcRK9K.js → LpPhoneInput-CalRjkqc.js} +3 -3
- package/dist/chunks/{LpPie-oVNHjsKD.js → LpPie-BVAEmVBT.js} +2 -2
- package/dist/chunks/{LpPipeline-BSXT6oUJ.js → LpPipeline-Dk7_sRP1.js} +2 -2
- package/dist/chunks/{LpQuota-CgWPgdVt.js → LpQuota-DKx0IZYA.js} +1 -1
- package/dist/chunks/{LpRadioGroup-DTM0nd9G.js → LpRadioGroup-E3SP6j9h.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-BrqlGZzE.js → LpRangeCalendar-BFJFaen4.js} +1 -1
- package/dist/chunks/{LpRangeSlider-DJsEZXad.js → LpRangeSlider-b6ipywRB.js} +1 -1
- package/dist/chunks/{LpRepeater-Czib3rmv.js → LpRepeater-BLrw62Bh.js} +2 -2
- package/dist/chunks/{LpSecretField-BtlCk3IH.js → LpSecretField-BV8BNjKc.js} +5 -5
- package/dist/chunks/{LpSection-BfteipxU.js → LpSection-CghNNxIv.js} +1 -1
- package/dist/chunks/{LpSegmented-sskrJKJJ.js → LpSegmented-DjvrHL_G.js} +2 -2
- package/dist/chunks/{LpSelect-JFZehOkd.js → LpSelect-DYPimzAZ.js} +4 -4
- package/dist/chunks/{LpSidebar-CvRvVBTt.js → LpSidebar-D3S8rYFf.js} +4 -4
- package/dist/chunks/{LpSidebarNav-CE0jQS8i.js → LpSidebarNav-Dlse8vxT.js} +3 -3
- package/dist/chunks/{LpSidebarRow-DOTg1txp.js → LpSidebarRow-Cqd_6tGG.js} +3 -3
- package/dist/chunks/{LpSlider-C4ilCtZX.js → LpSlider-B9XIC75O.js} +1 -1
- package/dist/chunks/{LpStat-DPjc6_fX.js → LpStat-kyLRmX-m.js} +2 -2
- package/dist/chunks/{LpStatus-DGJFwk5F.js → LpStatus-szB2dWzM.js} +1 -1
- package/dist/chunks/{LpStepMark-Dw2MRTrt.js → LpStepMark-DP3CDSm9.js} +1 -1
- package/dist/chunks/{LpStepper-DNnh7lCu.js → LpStepper-BS3iaJIz.js} +1 -1
- package/dist/chunks/{LpSwitch-DyAYTchz.js → LpSwitch-DRFy8RNr.js} +1 -1
- package/dist/chunks/{LpTabFace-DQoV8mQG.js → LpTabFace-C9-OperL.js} +2 -2
- package/dist/chunks/{LpTableFilter-CJg5eIcV.js → LpTableFilter-CEIwLupk.js} +2 -2
- package/dist/chunks/{LpTagsInput-CkNxpNYa.js → LpTagsInput-ClDeF_aU.js} +5 -5
- package/dist/chunks/{LpTextarea-CFK_5QB8.js → LpTextarea-DEY6pUBy.js} +1 -1
- package/dist/chunks/{LpThemeStudio-R-wohbCY.js → LpThemeStudio-WY72Z1Db.js} +5 -5
- package/dist/chunks/{LpThemeSwitcher-Bzy15MCP.js → LpThemeSwitcher-9uCH9c-K.js} +3 -3
- package/dist/chunks/{LpTimeField-B5n0m8m0.js → LpTimeField-BmgxCzQO.js} +2 -2
- package/dist/chunks/{LpToaster-Ct_ZOhCh.js → LpToaster-DLKO-efn.js} +2 -2
- package/dist/chunks/{LpTopologyCanvas-BNgw9RyZ.js → LpTopologyCanvas-B-PhPa7k.js} +4 -4
- package/dist/chunks/{LpTour-ClKYhx4h.js → LpTour-ODlBcat2.js} +1 -1
- package/dist/chunks/{LpTruncate-ellq9RYM.js → LpTruncate--nhT0iUo.js} +1 -1
- package/dist/chunks/{LpWindow-B8YSRVTH.js → LpWindow-DJxE8Zl-.js} +2 -2
- package/dist/chunks/{LpWorkspaceSwitcher-ugv-fyig.js → LpWorkspaceSwitcher-Bpf41Bw5.js} +3 -3
- package/dist/chunks/{MenuTitle-Bh5dAdV1.js → MenuTitle-CMjvLsvC.js} +1 -1
- package/dist/chunks/{OptionContent-CGb8QlHA.js → OptionContent-DY8NaG8s.js} +1 -1
- package/dist/chunks/{PickerField-BM1dyafh.js → PickerField-CgfkaBgg.js} +1 -1
- package/dist/chunks/{RevealToggle-D_iyrB8d.js → RevealToggle-CPMXxaKv.js} +1 -1
- package/dist/chunks/{TourCard-DAzTYfav.js → TourCard-D4b3lMHi.js} +1 -1
- package/dist/chunks/icons-C1vppR4f.js +59 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/DropzoneLinkField.vue.js +1 -1
- package/dist/components/FieldLeading.vue.js +1 -1
- package/dist/components/FieldWrapper.vue.js +1 -1
- package/dist/components/IconCloseButton.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.d.ts +18 -0
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBadge.vue.js +1 -1
- package/dist/components/LpBottomNav.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpCodeDisplay.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpContextSwitcher.vue.js +1 -1
- package/dist/components/LpCopyButton.vue.js +1 -1
- package/dist/components/LpDataList.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDock.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +2 -2
- package/dist/components/LpEditable.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.js +1 -1
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +1 -1
- package/dist/components/LpInfraNode.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpInspector.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogInspector.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.d.ts +28 -64
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNotificationFeed.vue.d.ts +78 -0
- package/dist/components/LpNotificationFeed.vue.js +4 -0
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpOtpInput.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPaletteTheme.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpPipeline.vue.js +1 -1
- package/dist/components/LpQuota.vue.js +1 -1
- package/dist/components/LpRadioGroup.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpRangeSlider.vue.js +1 -1
- package/dist/components/LpRepeater.vue.js +1 -1
- package/dist/components/LpSecretField.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpSidebarRow.vue.js +1 -1
- package/dist/components/LpSlider.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStatus.vue.js +1 -1
- package/dist/components/LpStepMark.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpSwitch.vue.js +1 -1
- package/dist/components/LpTabFace.vue.js +1 -1
- package/dist/components/LpTable.vue.js +9 -9
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTabs.vue.js +2 -2
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeStudio.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTopologyCanvas.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/LpTruncate.vue.js +1 -1
- package/dist/components/LpWindow.vue.js +1 -1
- package/dist/components/LpWorkspaceSwitcher.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/OptionContent.vue.js +1 -1
- package/dist/components/PickerField.vue.js +1 -1
- package/dist/components/RevealToggle.vue.js +1 -1
- package/dist/components/TourCard.vue.js +1 -1
- package/dist/components/menuWhen.js +1 -1
- package/dist/components/notifications.d.ts +156 -0
- package/dist/components/notifications.js +59 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +208 -206
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpAppShell.vue +56 -18
- package/src/components/LpNotificationBell.vue +105 -279
- package/src/components/LpNotificationFeed.vue +404 -0
- package/src/components/notifications.ts +197 -0
- package/src/index.ts +6 -1
- package/src/kit-icons.ts +1 -1
- package/dist/chunks/LpNotificationBell-DUbVyK6R.js +0 -311
- 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
|
-
|
|
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"
|