@eifi1/ui-kit 0.45.0 → 0.46.1
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/README.md +26 -14
- package/dist/admin/admin-area-labels.d.ts +9 -0
- package/dist/admin/admin-area-labels.js +5 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +16 -1
- package/dist/admin/admin-overview.js +12 -1
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/mail-test-card.d.ts +3 -2
- package/dist/admin/mail-test-card.js +3 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/admin/user-roster.d.ts +6 -2
- package/dist/admin/user-roster.js +7 -3
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/components/appearance-labels.d.ts +10 -0
- package/dist/components/appearance-labels.js +6 -1
- package/dist/components/appearance-labels.js.map +1 -1
- package/dist/components/data-table-column-settings.js +2 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-head.js +2 -2
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-row-actions.d.ts +35 -2
- package/dist/components/data-table-row-actions.js +98 -5
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-shared.js +9 -2
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table.d.ts +58 -2
- package/dist/components/data-table.js +11 -4
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-format-setting.d.ts +29 -0
- package/dist/components/date-format-setting.js +36 -0
- package/dist/components/date-format-setting.js.map +1 -0
- package/dist/components/row-actions.d.ts +12 -5
- package/dist/components/row-actions.js +11 -4
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +8 -0
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tabs.d.ts +3 -2
- package/dist/components/tabs.js +1 -1
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -0
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-labels.d.ts +5 -0
- package/dist/components/translation-review-labels.js +2 -0
- package/dist/components/translation-review-labels.js.map +1 -1
- package/dist/components/translation-review.d.ts +18 -8
- package/dist/components/translation-review.js +67 -8
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/write-lock-labels.js +2 -1
- package/dist/components/write-lock-labels.js.map +1 -1
- package/dist/components/write-lock.d.ts +33 -3
- package/dist/components/write-lock.js +9 -0
- package/dist/components/write-lock.js.map +1 -1
- package/dist/data-table.d.ts +2 -1
- package/dist/data-table.js +2 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +4 -2
- package/dist/feedback/feedback-row-detail.js +17 -26
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-swipe.d.ts +18 -0
- package/dist/feedback/feedback-swipe.js +10 -1
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/i18n/german.js +41 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -1
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +40 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +41 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +41 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +41 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +41 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +41 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/date-format.d.ts +81 -0
- package/dist/lib/date-format.js +56 -0
- package/dist/lib/date-format.js.map +1 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/format.js +5 -4
- package/dist/lib/format.js.map +1 -1
- package/dist/notifications/device-zone-report.d.ts +34 -0
- package/dist/notifications/device-zone-report.js +31 -0
- package/dist/notifications/device-zone-report.js.map +1 -0
- package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
- package/dist/notifications/feedback-notification-types-labels.js +4 -1
- package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
- package/dist/notifications/feedback-notification-types.d.ts +24 -5
- package/dist/notifications/feedback-notification-types.js +7 -2
- package/dist/notifications/feedback-notification-types.js.map +1 -1
- package/dist/notifications/notification-bell.d.ts +33 -0
- package/dist/notifications/notification-bell.js +74 -0
- package/dist/notifications/notification-bell.js.map +1 -0
- package/dist/notifications/notification-inbox.d.ts +86 -0
- package/dist/notifications/notification-inbox.js +206 -0
- package/dist/notifications/notification-inbox.js.map +1 -0
- package/dist/notifications/notification-types-setting.d.ts +6 -4
- package/dist/notifications/notification-types-setting.js +103 -45
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notification-types.d.ts +19 -7
- package/dist/notifications/notifications-labels.d.ts +44 -8
- package/dist/notifications/notifications-labels.js +18 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-devices-setting.d.ts +20 -3
- package/dist/notifications/push-devices-setting.js +93 -33
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.d.ts +108 -0
- package/dist/notifications/push-worker.js +62 -0
- package/dist/notifications/push-worker.js.map +1 -0
- package/dist/notifications/quiet-hours-setting.d.ts +29 -17
- package/dist/notifications/quiet-hours-setting.js +9 -41
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/time-zone-select.d.ts +8 -4
- package/dist/notifications/time-zone-select.js +2 -7
- package/dist/notifications/time-zone-select.js.map +1 -1
- package/dist/notifications/use-push-device.d.ts +4 -17
- package/dist/notifications/use-push-device.js +7 -15
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/local-first.d.ts +42 -0
- package/dist/offline/local-first.js +53 -0
- package/dist/offline/local-first.js.map +1 -0
- package/dist/offline/query-persistence.d.ts +26 -1
- package/dist/offline/query-persistence.js +27 -23
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline/service-worker-updates.d.ts +46 -0
- package/dist/offline/service-worker-updates.js +39 -0
- package/dist/offline/service-worker-updates.js.map +1 -0
- package/dist/offline.d.ts +5 -2
- package/dist/offline.js +12 -2
- package/dist/offline.js.map +1 -1
- package/dist/push-worker.d.ts +2 -0
- package/dist/push-worker.js +14 -0
- package/dist/push-worker.js.map +1 -0
- package/dist/shell/account-menu-text-size.d.ts +33 -0
- package/dist/shell/account-menu-text-size.js +51 -0
- package/dist/shell/account-menu-text-size.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +6 -2
- package/dist/shell/top-bar-actions.js +1 -0
- package/dist/shell/top-bar-actions.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/sync/db.d.ts +93 -0
- package/dist/sync/db.js +33 -0
- package/dist/sync/db.js.map +1 -0
- package/dist/sync/engine.d.ts +126 -0
- package/dist/sync/engine.js +474 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hooks.d.ts +35 -0
- package/dist/sync/hooks.js +67 -0
- package/dist/sync/hooks.js.map +1 -0
- package/dist/sync/testing.d.ts +28 -0
- package/dist/sync/testing.js +41 -0
- package/dist/sync/testing.js.map +1 -0
- package/dist/sync/wire.d.ts +50 -0
- package/dist/sync/wire.js +2 -0
- package/dist/sync/wire.js.map +1 -0
- package/dist/sync.d.ts +7 -0
- package/dist/sync.js +25 -0
- package/dist/sync.js.map +1 -0
- package/dist/tour/tour-labels.js +1 -0
- package/dist/tour/tour-labels.js.map +1 -1
- package/dist/tour/tour.d.ts +30 -0
- package/dist/tour/tour.js +50 -6
- package/dist/tour/tour.js.map +1 -1
- package/package.json +19 -1
- package/src/admin/admin-area-labels.ts +13 -0
- package/src/admin/admin-overview.tsx +37 -2
- package/src/admin/mail-test-card.tsx +7 -3
- package/src/admin/user-roster.tsx +12 -3
- package/src/components/appearance-labels.ts +16 -0
- package/src/components/data-table-column-settings.tsx +2 -2
- package/src/components/data-table-head.tsx +2 -2
- package/src/components/data-table-row-actions.tsx +145 -5
- package/src/components/data-table-shared.tsx +21 -2
- package/src/components/data-table.tsx +68 -6
- package/src/components/date-format-setting.tsx +64 -0
- package/src/components/row-actions.tsx +33 -9
- package/src/components/swipeable-row.tsx +8 -0
- package/src/components/tabs.tsx +4 -3
- package/src/components/translation-review-editor.tsx +2 -0
- package/src/components/translation-review-labels.ts +7 -0
- package/src/components/translation-review.tsx +105 -15
- package/src/components/write-lock-labels.ts +1 -0
- package/src/components/write-lock.tsx +36 -3
- package/src/data-table.ts +4 -0
- package/src/feedback/feedback-row-detail.tsx +18 -23
- package/src/feedback/feedback-swipe.tsx +28 -1
- package/src/i18n/german.ts +44 -4
- package/src/i18n/kit-labels.tsx +18 -1
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +44 -4
- package/src/i18n/locales/fr.ts +44 -4
- package/src/i18n/locales/hu.ts +44 -4
- package/src/i18n/locales/it.ts +44 -4
- package/src/i18n/locales/zh.ts +44 -2
- package/src/i18n/review.ts +3 -1
- package/src/index.ts +50 -1
- package/src/lib/date-format.ts +151 -0
- package/src/lib/format.ts +10 -4
- package/src/notifications/device-zone-report.ts +67 -0
- package/src/notifications/feedback-notification-types-labels.ts +10 -1
- package/src/notifications/feedback-notification-types.ts +30 -5
- package/src/notifications/notification-bell.tsx +116 -0
- package/src/notifications/notification-inbox.tsx +314 -0
- package/src/notifications/notification-types-setting.tsx +151 -55
- package/src/notifications/notification-types.ts +20 -8
- package/src/notifications/notifications-labels.ts +72 -12
- package/src/notifications/push-devices-setting.tsx +123 -34
- package/src/notifications/push-worker.ts +160 -0
- package/src/notifications/quiet-hours-setting.tsx +41 -58
- package/src/notifications/time-zone-select.tsx +9 -10
- package/src/notifications/use-push-device.ts +20 -33
- package/src/offline/local-first.ts +100 -0
- package/src/offline/query-persistence.ts +57 -24
- package/src/offline/service-worker-updates.ts +81 -0
- package/src/offline.ts +7 -1
- package/src/push-worker.ts +25 -0
- package/src/shell/account-menu-text-size.tsx +77 -0
- package/src/shell/top-bar-actions.tsx +6 -1
- package/src/shell.ts +3 -0
- package/src/sync/db.ts +125 -0
- package/src/sync/engine.ts +701 -0
- package/src/sync/hooks.ts +98 -0
- package/src/sync/testing.ts +73 -0
- package/src/sync/wire.ts +61 -0
- package/src/sync.ts +29 -0
- package/src/tour/tour-labels.ts +1 -0
- package/src/tour/tour.tsx +100 -7
package/src/index.ts
CHANGED
|
@@ -408,6 +408,8 @@ export * from "./components/form-actions";
|
|
|
408
408
|
// (docs/billing-harmonization.md §12.36): the kinds, the holds, `CommitScope`,
|
|
409
409
|
// `COMMIT_EXCEPT_BILLING` and the pure `writeLockFor`.
|
|
410
410
|
export { WriteLockProvider, useWriteLock, writeLockFor, COMMIT_EXCEPT_BILLING, DEFAULT_WRITE_LOCK_LABELS } from "./components/write-lock";
|
|
411
|
+
// 0.46 (kastlan K4): the offline lock kind, its scope and its source.
|
|
412
|
+
export { OFFLINE_LOCK, COMMIT_EXCEPT_OFFLINE, useOfflineWriteLock } from "./components/write-lock";
|
|
411
413
|
export type { WriteLock, WriteLockLabels, WriteLockProviderProps, WriteLockKind, WriteLockHold, CommitScope } from "./components/write-lock";
|
|
412
414
|
// 0.18: the parts both apps' admin rosters repeat (Kurvenschmiede 6).
|
|
413
415
|
export { RoleChip, AccountStateChip, DateMark, dateColumn, DEFAULT_ACCOUNT_STATE_LABELS, ACCOUNT_STATE_TONES } from "./components/account-chips";
|
|
@@ -552,6 +554,19 @@ export type {
|
|
|
552
554
|
// actions, one of the behaviours at Large (§4, §10.7–10.9).
|
|
553
555
|
export { DEFAULT_APPEARANCE_LABELS, useAppearanceLabels } from "./components/appearance-labels";
|
|
554
556
|
export type { AppearanceLabels } from "./components/appearance-labels";
|
|
557
|
+
// 0.46 (keksdose's date format, lifted): the preference, its resolver and renderer, the
|
|
558
|
+
// provider's formatter for it, and the settings field.
|
|
559
|
+
export {
|
|
560
|
+
DATE_FORMAT_PREFERENCES,
|
|
561
|
+
isDateFormatPreference,
|
|
562
|
+
resolveDateFormat,
|
|
563
|
+
formatDateWithPreference,
|
|
564
|
+
dateFormatSample,
|
|
565
|
+
dateFormatterFor,
|
|
566
|
+
} from "./lib/date-format";
|
|
567
|
+
export type { DateFormatPreference, DateFormatOptions, ResolvedDateFormat } from "./lib/date-format";
|
|
568
|
+
export { DateFormatSetting } from "./components/date-format-setting";
|
|
569
|
+
export type { DateFormatSettingProps } from "./components/date-format-setting";
|
|
555
570
|
export { RowActions, rowActionsColumn, DEFAULT_ROW_ACTIONS_LABELS } from "./components/row-actions";
|
|
556
571
|
export type {
|
|
557
572
|
RowAction,
|
|
@@ -808,6 +823,10 @@ export type {
|
|
|
808
823
|
AdminExtraTile,
|
|
809
824
|
AdminOverviewProps,
|
|
810
825
|
AttentionTileProps,
|
|
826
|
+
// 0.46 (Kurvenschmiede 1): the feedback tiles with the kit's words.
|
|
827
|
+
AdminFeedbackTile,
|
|
828
|
+
AdminFeedbackTileKey,
|
|
829
|
+
AdminOwnTile,
|
|
811
830
|
} from "./admin/admin-overview";
|
|
812
831
|
export { AdminToolLinks } from "./admin/admin-tool-links";
|
|
813
832
|
export type { AdminToolLink, AdminToolLinksProps } from "./admin/admin-tool-links";
|
|
@@ -946,13 +965,27 @@ export type {
|
|
|
946
965
|
NotificationTypeGroup,
|
|
947
966
|
NotificationSettingsPatch,
|
|
948
967
|
PushDeviceItem,
|
|
968
|
+
NotificationChannel,
|
|
949
969
|
} from "./notifications/notification-types";
|
|
950
970
|
export { NotificationTypesSetting } from "./notifications/notification-types-setting";
|
|
951
971
|
export type { NotificationTypesSettingProps } from "./notifications/notification-types-setting";
|
|
952
972
|
export { QuietHoursSetting } from "./notifications/quiet-hours-setting";
|
|
953
973
|
export type { QuietHoursSettingProps } from "./notifications/quiet-hours-setting";
|
|
954
|
-
export { TimeZoneSelect, timeZoneOptions
|
|
974
|
+
export { TimeZoneSelect, timeZoneOptions } from "./notifications/time-zone-select";
|
|
955
975
|
export type { TimeZoneSelectProps } from "./notifications/time-zone-select";
|
|
976
|
+
// 0.46 (docs/notifications-harmonization.md §10.1, §10.3): the inbox list, the bell and
|
|
977
|
+
// the device-zone report (the account's zone is automatic only, amended 2026-10-20).
|
|
978
|
+
export { NotificationInbox, INBOX_BADGE_INTERVAL } from "./notifications/notification-inbox";
|
|
979
|
+
export type {
|
|
980
|
+
NotificationInboxAction,
|
|
981
|
+
NotificationInboxItem,
|
|
982
|
+
NotificationInboxProps,
|
|
983
|
+
} from "./notifications/notification-inbox";
|
|
984
|
+
export { NotificationBell } from "./notifications/notification-bell";
|
|
985
|
+
export type { NotificationBellProps } from "./notifications/notification-bell";
|
|
986
|
+
// `deviceTimeZone` lives beside the report since 0.46.1 (keksdose N6; sign-up needs it).
|
|
987
|
+
export { useDeviceZoneReport, deviceTimeZone } from "./notifications/device-zone-report";
|
|
988
|
+
export type { DeviceZoneReportOptions } from "./notifications/device-zone-report";
|
|
956
989
|
export {
|
|
957
990
|
usePushDevice,
|
|
958
991
|
PUSH_SUBSCRIPTION_CHANGED,
|
|
@@ -967,6 +1000,20 @@ export type {
|
|
|
967
1000
|
PushPermission,
|
|
968
1001
|
PushDeviceErrorCode,
|
|
969
1002
|
} from "./notifications/use-push-device";
|
|
1003
|
+
// 0.46 (NT:1028): the service worker re-registers a rotated subscription. A worker imports
|
|
1004
|
+
// them from `@eifi1/ui-kit/push-worker` (§10.6 rule 5); the barrel keeps them too.
|
|
1005
|
+
export {
|
|
1006
|
+
handlePushSubscriptionChange,
|
|
1007
|
+
resubscribeAfterChange,
|
|
1008
|
+
pushSubscriptionInput,
|
|
1009
|
+
} from "./notifications/push-worker";
|
|
1010
|
+
export type {
|
|
1011
|
+
PushReplaceInput,
|
|
1012
|
+
PushSubscriptionChangeOptions,
|
|
1013
|
+
PushSubscriptionChangeEventLike,
|
|
1014
|
+
PushResubscribeOutcome,
|
|
1015
|
+
PushWorkerScope,
|
|
1016
|
+
} from "./notifications/push-worker";
|
|
970
1017
|
export { PushDeviceSetting } from "./notifications/push-device-setting";
|
|
971
1018
|
export type { PushDeviceSettingProps, PushDeviceAction } from "./notifications/push-device-setting";
|
|
972
1019
|
export { PushDevicesSetting, describeUserAgent } from "./notifications/push-devices-setting";
|
|
@@ -974,8 +1021,10 @@ export type { PushDevicesSettingProps } from "./notifications/push-devices-setti
|
|
|
974
1021
|
export { UnsubscribePanel, unsubscribeTokenType } from "./notifications/unsubscribe-panel";
|
|
975
1022
|
export type { UnsubscribePanelProps } from "./notifications/unsubscribe-panel";
|
|
976
1023
|
// 0.38.0 (docs/feedback-harmonization.md §9.11): the two feedback types' setting words.
|
|
1024
|
+
// 0.46.1 (keksdose N9): `useFeedbackNotificationTypesLabels`, the words resolved, for `adminGroup`.
|
|
977
1025
|
export {
|
|
978
1026
|
useFeedbackNotificationTypes,
|
|
1027
|
+
useFeedbackNotificationTypesLabels,
|
|
979
1028
|
DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS,
|
|
980
1029
|
} from "./notifications/feedback-notification-types";
|
|
981
1030
|
export type {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { formatLocaleOf } from "../i18n/languages";
|
|
2
|
+
import type { KitDateFormatter } from "../i18n/kit-labels";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The date-format preference (0.46, lifted from keksdose's `date-format-store.ts`,
|
|
6
|
+
* feedback #180): how a DAY is written — the order of day, month and year and the
|
|
7
|
+
* separator between them — as a choice of its own, apart from the interface language.
|
|
8
|
+
* "Read the app in English and still see German dates", or ISO everywhere.
|
|
9
|
+
*
|
|
10
|
+
* keksdose's rules, which the kit keeps:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A label that identifies a day follows the preference** — anything that prints
|
|
13
|
+
* day, month and year goes through {@link formatDateWithPreference} (or the
|
|
14
|
+
* provider's `formatDate`, {@link dateFormatterFor}, for the kit's own date fields).
|
|
15
|
+
* 2. **A label without a day follows the language**: "August 2026", a month or weekday
|
|
16
|
+
* NAME has no order to arrange. {@link dateFormatterFor} answers `""` for a month or a
|
|
17
|
+
* year, so the kit keeps its own rendering in the language.
|
|
18
|
+
* 3. **A clock time follows the language**: `sv-SE` is a statement about digit order in
|
|
19
|
+
* a date, not about a 12- or 24-hour clock.
|
|
20
|
+
* 4. **A weekday's NAME follows the language** (dev#546): `sv-SE` means "year first,
|
|
21
|
+
* with dashes", not "the reader speaks Swedish" — so with `weekday` the day's name is
|
|
22
|
+
* the language's, joined to the date the preference renders.
|
|
23
|
+
*
|
|
24
|
+
* The preference is the APP's to keep — keksdose holds it per device (a persisted store,
|
|
25
|
+
* not an account column: nothing server-written formats a date for the reader). The kit
|
|
26
|
+
* owns the vocabulary, the resolver, the renderer and the settings field
|
|
27
|
+
* ({@link DateFormatSetting}).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** `"auto"` follows the interface language (the default — nobody's dates move until
|
|
31
|
+
* they ask); the rest are BCP 47 tags chosen for the SHAPE they produce: German
|
|
32
|
+
* 01.08.2026, British 01/08/2026, US 08/01/2026, ISO 2026-08-01. */
|
|
33
|
+
export type DateFormatPreference = "auto" | "de-DE" | "en-GB" | "en-US" | "sv-SE";
|
|
34
|
+
|
|
35
|
+
/** The options in the order the setting lists them: `auto` first (the default), then
|
|
36
|
+
* German → UK → US → ISO. */
|
|
37
|
+
export const DATE_FORMAT_PREFERENCES: readonly DateFormatPreference[] = ["auto", "de-DE", "en-GB", "en-US", "sv-SE"];
|
|
38
|
+
|
|
39
|
+
/** Whether `value` is one of {@link DATE_FORMAT_PREFERENCES} — for a value read back from
|
|
40
|
+
* storage, where anything else falls back to `"auto"`. */
|
|
41
|
+
export function isDateFormatPreference(value: unknown): value is DateFormatPreference {
|
|
42
|
+
return typeof value === "string" && (DATE_FORMAT_PREFERENCES as readonly string[]).includes(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A resolved preference: the locale and `Intl` options the date itself is rendered
|
|
46
|
+
* with, and — only when asked for — the locale the weekday's NAME comes from. */
|
|
47
|
+
export interface ResolvedDateFormat {
|
|
48
|
+
locale: string | undefined;
|
|
49
|
+
options: Intl.DateTimeFormatOptions;
|
|
50
|
+
/** The language's format locale, present when `weekday` was asked for. */
|
|
51
|
+
weekday?: { locale: string | undefined };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What {@link resolveDateFormat} and the renderers take beyond the preference. */
|
|
55
|
+
export interface DateFormatOptions {
|
|
56
|
+
/** Name the day of the week too, in the interface language ("Mo., 01.08.2026"). A date
|
|
57
|
+
* in a COLUMN carries it; a date in a SENTENCE does not (keksdose dev#546). */
|
|
58
|
+
weekday?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a date is rendered with, given the preference and the interface language
|
|
63
|
+
* (`language`: a code or tag — `"de"`, `"en"`, i18next's `resolvedLanguage`; it is read
|
|
64
|
+
* through `formatLocaleOf`, so English is British and an unshipped tag stays itself).
|
|
65
|
+
*
|
|
66
|
+
* `"auto"` keeps the short-month shape ("01 Aug 2026", "01. Aug. 2026"); every explicit
|
|
67
|
+
* choice is all-numeric, the only way it produces the shape its option promises —
|
|
68
|
+
* `month: "short"` under `sv-SE` would print "2026 aug. 01", not ISO.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveDateFormat(
|
|
71
|
+
preference: DateFormatPreference,
|
|
72
|
+
language: string | undefined,
|
|
73
|
+
options: DateFormatOptions = {},
|
|
74
|
+
): ResolvedDateFormat {
|
|
75
|
+
const languageLocale = language ? formatLocaleOf(language) : undefined;
|
|
76
|
+
const weekday = options.weekday ? { weekday: { locale: languageLocale } } : {};
|
|
77
|
+
if (preference === "auto") {
|
|
78
|
+
return { locale: languageLocale, options: { year: "numeric", month: "short", day: "2-digit" }, ...weekday };
|
|
79
|
+
}
|
|
80
|
+
return { locale: preference, options: { year: "numeric", month: "2-digit", day: "2-digit" }, ...weekday };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A bare calendar day: no time, no zone. */
|
|
84
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `dateText`, already rendered under the preference, with the day's NAME around it in
|
|
88
|
+
* `locale` (the language's). Not "weekday alone + comma": German says "So" alone and
|
|
89
|
+
* "So., 06.09.2026" in company. The full date is formatted in the language and split
|
|
90
|
+
* into parts; what that language puts before the first and after the last day/month/
|
|
91
|
+
* year part (the weekday, its comma) frames the preference's digits — so where the two
|
|
92
|
+
* locales coincide the output is the native string, character for character.
|
|
93
|
+
*/
|
|
94
|
+
function withWeekday(date: Date, dateText: string, locale: string | undefined, options: Intl.DateTimeFormatOptions) {
|
|
95
|
+
const isDatePart = (p: Intl.DateTimeFormatPart) => p.type === "day" || p.type === "month" || p.type === "year";
|
|
96
|
+
const parts = new Intl.DateTimeFormat(locale, { weekday: "short", ...options }).formatToParts(date);
|
|
97
|
+
const first = parts.findIndex(isDatePart);
|
|
98
|
+
if (first === -1) return dateText;
|
|
99
|
+
let last = first;
|
|
100
|
+
parts.forEach((p, i) => {
|
|
101
|
+
if (isDatePart(p)) last = i;
|
|
102
|
+
});
|
|
103
|
+
const join = (ps: Intl.DateTimeFormatPart[]) => ps.map((p) => p.value).join("");
|
|
104
|
+
return join(parts.slice(0, first)) + dateText + join(parts.slice(last + 1));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A date under the preference: `""` for no value, the input itself when it does not
|
|
109
|
+
* parse. A bare "YYYY-MM-DD" is the calendar day it names in every zone (formatted in
|
|
110
|
+
* UTC, undoing `new Date`'s UTC-midnight parse — west of Greenwich it read a day early);
|
|
111
|
+
* a timestamp is an instant and stays in the reader's zone. The weekday, when asked for,
|
|
112
|
+
* is rendered in the same zone, so a Sunday is never captioned Saturday.
|
|
113
|
+
*/
|
|
114
|
+
export function formatDateWithPreference(
|
|
115
|
+
value: string | Date | null | undefined,
|
|
116
|
+
preference: DateFormatPreference,
|
|
117
|
+
language: string | undefined,
|
|
118
|
+
options: DateFormatOptions = {},
|
|
119
|
+
): string {
|
|
120
|
+
if (!value) return "";
|
|
121
|
+
const resolved = resolveDateFormat(preference, language, options);
|
|
122
|
+
const dateOnly = typeof value === "string" && DATE_ONLY.test(value);
|
|
123
|
+
const date = typeof value === "string" ? new Date(value) : value;
|
|
124
|
+
if (Number.isNaN(date.getTime())) return String(value);
|
|
125
|
+
const intl: Intl.DateTimeFormatOptions = dateOnly ? { ...resolved.options, timeZone: "UTC" } : resolved.options;
|
|
126
|
+
try {
|
|
127
|
+
const text = date.toLocaleDateString(resolved.locale, intl);
|
|
128
|
+
return resolved.weekday ? withWeekday(date, text, resolved.weekday.locale, intl) : text;
|
|
129
|
+
} catch {
|
|
130
|
+
// A language tag `Intl` refuses: the ISO day rather than nothing (keksdose's fallback).
|
|
131
|
+
return date.toISOString().slice(0, 10);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The sample an option is labelled with — 1 August 2026 in its shape, the shape being
|
|
136
|
+
* the whole choice (a country name would say nothing about the digit order). */
|
|
137
|
+
export function dateFormatSample(preference: DateFormatPreference, language: string | undefined): string {
|
|
138
|
+
return formatDateWithPreference("2026-08-01", preference, language);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The `<UiKitProvider formatDate>` for a preference — so the kit's own date fields
|
|
143
|
+
* (DatePicker, DateRangePicker, DateMark) write a day as the app's dates do. A day
|
|
144
|
+
* follows the preference, with the weekday where the kit suggests it; a month or a year
|
|
145
|
+
* answers `""` and keeps the kit's rendering in the language (rule 2). Rebuild it when
|
|
146
|
+
* the preference or the language changes (a `useMemo` on both).
|
|
147
|
+
*/
|
|
148
|
+
export function dateFormatterFor(preference: DateFormatPreference, language: string | undefined): KitDateFormatter {
|
|
149
|
+
return (iso, { unit, weekday, locale }) =>
|
|
150
|
+
unit === "day" ? formatDateWithPreference(iso, preference, locale ?? language, { weekday }) : "";
|
|
151
|
+
}
|
package/src/lib/format.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
-
import { useKitLocale } from "../i18n/kit-labels";
|
|
2
|
+
import { useKitLocale, useKitTimeZone } from "../i18n/kit-labels";
|
|
3
3
|
import { parseIsoDate } from "./dates";
|
|
4
4
|
import { roundHalfAway } from "./round";
|
|
5
5
|
|
|
@@ -244,6 +244,9 @@ export function formatDate(
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
export interface FormatRelativeTimeOptions extends BaseOptions {
|
|
247
|
+
/** The zone of the date printed past `absoluteAfterDays` (0.46). Left out: the
|
|
248
|
+
* device's. */
|
|
249
|
+
timeZone?: string;
|
|
247
250
|
/** The moment it is measured from. Default: now. */
|
|
248
251
|
now?: DateInput;
|
|
249
252
|
/** `"auto"` (default): "yesterday", "now", "next week". `"always"`: "1 day ago". */
|
|
@@ -348,16 +351,19 @@ export interface KitFormat {
|
|
|
348
351
|
*/
|
|
349
352
|
export function useKitFormat(locale?: string): KitFormat {
|
|
350
353
|
const kitLocale = useKitLocale(locale);
|
|
354
|
+
// 0.46 (§10.3): the provider's `timeZone` for timestamps; a call's own wins, and a bare
|
|
355
|
+
// "YYYY-MM-DD" ignores it (formatDate).
|
|
356
|
+
const timeZone = useKitTimeZone();
|
|
351
357
|
return useMemo<KitFormat>(
|
|
352
358
|
() => ({
|
|
353
359
|
locale: kitLocale,
|
|
354
360
|
formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),
|
|
355
361
|
formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),
|
|
356
362
|
formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),
|
|
357
|
-
formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, ...options }),
|
|
358
|
-
formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, ...options }),
|
|
363
|
+
formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, timeZone, ...options }),
|
|
364
|
+
formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options }),
|
|
359
365
|
}),
|
|
360
|
-
[kitLocale],
|
|
366
|
+
[kitLocale, timeZone],
|
|
361
367
|
);
|
|
362
368
|
}
|
|
363
369
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
import { settle } from "../account/account-parts";
|
|
4
|
+
|
|
5
|
+
export interface DeviceZoneReportOptions {
|
|
6
|
+
/** The account's `device_timezone` from `/auth/me`; `undefined` while it loads (no
|
|
7
|
+
* report until it is known). */
|
|
8
|
+
stored: string | null | undefined;
|
|
9
|
+
/** `PATCH /auth/me {device_timezone}` with this device's zone. */
|
|
10
|
+
report: (deviceTimezone: string) => Promise<unknown> | void;
|
|
11
|
+
/** A demo account: never reported (the server ignores it too, §10.3 Settled). */
|
|
12
|
+
isDemo?: boolean;
|
|
13
|
+
/** The signed-in account (its user id): the once-per-start guard is per account, so a
|
|
14
|
+
* second person signed in on a shared PC in the same start is reported too. Without it
|
|
15
|
+
* the guard is cleared when `stored` goes `undefined` (signed out). */
|
|
16
|
+
account?: string | number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
|
|
21
|
+
* the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
|
|
22
|
+
* only), and the one sign-up sends (`RegisterRequest.device_timezone`). Here since 0.46.1
|
|
23
|
+
* (keksdose N6); the deprecated `time-zone-select` module re-exports it until 0.47.
|
|
24
|
+
*/
|
|
25
|
+
export function deviceTimeZone(): string {
|
|
26
|
+
try {
|
|
27
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
28
|
+
} catch {
|
|
29
|
+
return "UTC";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The (account, stored, device) triples already reported in this start of the app. */
|
|
34
|
+
const reportedThisStart = new Set<string>();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Report this device's zone to the account (0.46, §10.3): the account's zone follows the
|
|
38
|
+
* device last used, else `Europe/Zurich`; screens keep showing device time. Once per start
|
|
39
|
+
* of the app, and only when it differs from the stored `device_timezone` — a bench PC in Zurich never
|
|
40
|
+
* PATCHes on a sign-in — and never for a demo. Per account: a sign-out (`stored` back to
|
|
41
|
+
* `undefined`) clears the guard, and `account` keys it. The report IS the account's zone: it is
|
|
42
|
+
* automatic only (§10.3, amended 2026-10-20), so nothing is ever picked. A failed report
|
|
43
|
+
* is not retried in the same start; the next start tries again. Sign-up sends the zone
|
|
44
|
+
* itself (`RegisterRequest.device_timezone`).
|
|
45
|
+
*
|
|
46
|
+
* useDeviceZoneReport({ stored: me?.device_timezone, isDemo: me?.is_demo, account: me?.id,
|
|
47
|
+
* report: async (zone) => setUser((await api.patch("/auth/me", { device_timezone: zone })).data) });
|
|
48
|
+
*/
|
|
49
|
+
export function useDeviceZoneReport({ stored, report, isDemo = false, account = null }: DeviceZoneReportOptions): void {
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (stored === undefined) {
|
|
52
|
+
// Signed out (or still loading): the next account in this start is reported afresh.
|
|
53
|
+
reportedThisStart.clear();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isDemo) return;
|
|
57
|
+
const device = deviceTimeZone();
|
|
58
|
+
if (device === stored) return;
|
|
59
|
+
const key = `${account ?? ""}|${stored ?? ""}→${device}`;
|
|
60
|
+
if (reportedThisStart.has(key)) return;
|
|
61
|
+
reportedThisStart.add(key);
|
|
62
|
+
void settle(() => report(device)).catch(() => undefined);
|
|
63
|
+
// `report` is the app's request function: a new closure each render must not report
|
|
64
|
+
// again, so only the values decide.
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
+
}, [stored, isDemo, account]);
|
|
67
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// component code, as every `*-labels.ts`: the English catalogue imports them alone.
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `feedbackNotificationTypes` — the setting words of the
|
|
5
|
+
* `feedbackNotificationTypes` — the setting words of the feedback notification types
|
|
6
6
|
* (docs/feedback-harmonization.md §9.11). In the kit, not in each app, because two apps
|
|
7
7
|
* declare them (keksdose, Kurvenschmiede; kastlan `feedback_answered` alone). The mail and
|
|
8
8
|
* push texts stay the app's.
|
|
@@ -14,6 +14,12 @@ export interface FeedbackNotificationTypesLabels {
|
|
|
14
14
|
/** `feedback_comment`: a visible comment on such a report. */
|
|
15
15
|
comment: string;
|
|
16
16
|
commentDescription: string;
|
|
17
|
+
/** 0.46 (feedback §11): `feedback_received`, an admin's alert for a new report, a
|
|
18
|
+
* rework and a new crash. */
|
|
19
|
+
received: string;
|
|
20
|
+
receivedDescription: string;
|
|
21
|
+
/** A heading the apps may put over the admin's types ("Admin"). */
|
|
22
|
+
adminGroup: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export const DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS: FeedbackNotificationTypesLabels = {
|
|
@@ -21,4 +27,7 @@ export const DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS: FeedbackNotificationTyp
|
|
|
21
27
|
answeredDescription: "When a report you wrote or commented on gets an answer.",
|
|
22
28
|
comment: "Comments on reports you follow",
|
|
23
29
|
commentDescription: "When someone comments on a report you wrote or commented on.",
|
|
30
|
+
received: "New feedback and reworks",
|
|
31
|
+
receivedDescription: "When someone files a report or sends one back.",
|
|
32
|
+
adminGroup: "Admin",
|
|
24
33
|
};
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
export { DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS };
|
|
10
10
|
export type { FeedbackNotificationTypesLabels };
|
|
11
11
|
|
|
12
|
-
/** The
|
|
13
|
-
* `FEEDBACK_COMMENT` name them. */
|
|
14
|
-
export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment";
|
|
12
|
+
/** The feedback notification ids (§9.11, §11), as server-kit's `FEEDBACK_ANSWERED`,
|
|
13
|
+
* `FEEDBACK_COMMENT` and `FEEDBACK_RECEIVED` (0.46: an admin's alert) name them. */
|
|
14
|
+
export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment" | "feedback_received";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* The setting words of the two feedback notification types (round 0.38,
|
|
@@ -27,9 +27,16 @@ export type FeedbackNotificationTypeId = "feedback_answered" | "feedback_comment
|
|
|
27
27
|
* - **"Comments on reports you follow"** (`feedback_comment`): a visible comment, to the
|
|
28
28
|
* author and the earlier commenters.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
30
|
+
* - **"New feedback and reworks"** (`feedback_received`, 0.46, feedback §11): to the
|
|
31
|
+
* admins, when someone files a report or sends one back for rework. (A crash is mailed
|
|
32
|
+
* only inside the bundle where an app runs the queue, so the text names no crash.)
|
|
33
|
+
* `labels.adminGroup` ("Admin") is a heading an app may put over it — read it with
|
|
34
|
+
* {@link useFeedbackNotificationTypesLabels} (0.46.1) for the app's own `groups`.
|
|
35
|
+
*
|
|
36
|
+
* Every entry is always returned: the card draws a type only when the server answered
|
|
31
37
|
* it (notifications §2.1), so kastlan, which does not declare `feedback_comment` while its
|
|
32
|
-
* public parts are off, shows the first alone
|
|
38
|
+
* public parts are off, shows the first alone, and a person who is no admin is never
|
|
39
|
+
* answered `feedback_received`. Words: `<UiKitProvider labels={{
|
|
33
40
|
* feedbackNotificationTypes }}>` over English, `labels` over both.
|
|
34
41
|
*/
|
|
35
42
|
export function useFeedbackNotificationTypes(
|
|
@@ -40,7 +47,25 @@ export function useFeedbackNotificationTypes(
|
|
|
40
47
|
() => [
|
|
41
48
|
{ id: "feedback_answered", label: words.answered, description: words.answeredDescription },
|
|
42
49
|
{ id: "feedback_comment", label: words.comment, description: words.commentDescription },
|
|
50
|
+
{ id: "feedback_received", label: words.received, description: words.receivedDescription },
|
|
43
51
|
],
|
|
44
52
|
[words],
|
|
45
53
|
);
|
|
46
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `feedbackNotificationTypes` words, resolved — English, then the provider, then
|
|
58
|
+
* `labels` (0.46.1, keksdose N9): what an app reads to head its own `groups` with
|
|
59
|
+
* `adminGroup` ("Admin") in the provider's locale, as {@link useFeedbackNotificationTypes}
|
|
60
|
+
* labels the entries.
|
|
61
|
+
*
|
|
62
|
+
* ```tsx
|
|
63
|
+
* const { adminGroup } = useFeedbackNotificationTypesLabels();
|
|
64
|
+
* groups={[...appGroups, { id: "admin", label: adminGroup, types: ["feedback_received"] }]}
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function useFeedbackNotificationTypesLabels(
|
|
68
|
+
labels?: LabelOverride<FeedbackNotificationTypesLabels>,
|
|
69
|
+
): FeedbackNotificationTypesLabels {
|
|
70
|
+
return useKitLabels("feedbackNotificationTypes", DEFAULT_FEEDBACK_NOTIFICATION_TYPES_LABELS, labels);
|
|
71
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Bell } from "lucide-react";
|
|
2
|
+
import type { LucideIcon } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { useKitFormat } from "../lib/format";
|
|
6
|
+
import { DEFAULT_LIST_LABELS } from "../components/list-labels";
|
|
7
|
+
import { StatusDot } from "../components/status-dot";
|
|
8
|
+
import { TopBarActionMenu } from "../shell/topbar-action-menu";
|
|
9
|
+
import type { TopBarMenuEntry } from "../shell/topbar-action-menu";
|
|
10
|
+
import { useNotificationsLabels } from "./notification-parts";
|
|
11
|
+
import type { NotificationsLabels } from "./notifications-labels";
|
|
12
|
+
import type { NotificationInboxItem } from "./notification-inbox";
|
|
13
|
+
|
|
14
|
+
/** How many rows the bell's panel shows: the newest 20 (§10.1). */
|
|
15
|
+
const BELL_ROWS = 20;
|
|
16
|
+
|
|
17
|
+
export interface NotificationBellProps<Id extends string | number = string | number> {
|
|
18
|
+
/** The inbox, newest first (the bell shows the first 20); `undefined` while it loads. */
|
|
19
|
+
items: readonly NotificationInboxItem<Id>[] | undefined;
|
|
20
|
+
/** Every unread row of the account (`unread_count`): the badge. */
|
|
21
|
+
unreadCount: number;
|
|
22
|
+
/** Open a row: the app marks it read and routes to `item.url` (kastlan switches the
|
|
23
|
+
* acting company first, to `item.company_id`). The panel closes. */
|
|
24
|
+
onOpen: (item: NotificationInboxItem<Id>) => void;
|
|
25
|
+
/** The app's inbox page or card, for "All notifications". */
|
|
26
|
+
href: string;
|
|
27
|
+
/** The row's glyph for a type, as {@link NotificationInbox}'s; `Bell` by default. */
|
|
28
|
+
icon?: (type: string) => LucideIcon | undefined;
|
|
29
|
+
/** A row's time. Default: the provider's locale and `timeZone`, date and time. */
|
|
30
|
+
formatDate?: (iso: string) => string;
|
|
31
|
+
labels?: LabelOverride<NotificationsLabels>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The badge's text: "99+" past 99. */
|
|
35
|
+
const badgeText = (count: number) => (count > 99 ? "99+" : String(Math.floor(count)));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The top bar's bell (0.46, docs/notifications-harmonization.md §10.1), for
|
|
39
|
+
* `TopBarActions`' `notifications` slot, right before the account menu: an icon button
|
|
40
|
+
* with the unread count as a badge ("99+" past 99), opening the newest 20 rows in the
|
|
41
|
+
* kit's top-bar panel (`TopBarActionMenu`) with "All notifications" under them.
|
|
42
|
+
*
|
|
43
|
+
* **Optional per app.** "One badge per signal": an app shows the bell or an unread dot on
|
|
44
|
+
* the avatar, never both — keksdose keeps its avatar dot and no bell; kastlan's inbox
|
|
45
|
+
* lives in the bell. Shown to every signed-in account in an app that keeps an inbox, the
|
|
46
|
+
* demo included. Props only, as the inbox list: poll the count every
|
|
47
|
+
* {@link INBOX_BADGE_INTERVAL}.
|
|
48
|
+
*/
|
|
49
|
+
export function NotificationBell<Id extends string | number = string | number>({
|
|
50
|
+
items,
|
|
51
|
+
unreadCount,
|
|
52
|
+
onOpen,
|
|
53
|
+
href,
|
|
54
|
+
icon,
|
|
55
|
+
formatDate: formatDateProp,
|
|
56
|
+
labels: labelsProp,
|
|
57
|
+
}: NotificationBellProps<Id>) {
|
|
58
|
+
const labels = useNotificationsLabels(labelsProp);
|
|
59
|
+
const list = useKitLabels("list", DEFAULT_LIST_LABELS);
|
|
60
|
+
const { formatDate } = useKitFormat();
|
|
61
|
+
const timeOf = (iso: string) => (formatDateProp ? formatDateProp(iso) : formatDate(iso, "dateTime"));
|
|
62
|
+
const unread = unreadCount > 0;
|
|
63
|
+
const shown = (items ?? []).slice(0, BELL_ROWS);
|
|
64
|
+
|
|
65
|
+
const entries: TopBarMenuEntry[] = shown.map((item) => {
|
|
66
|
+
const Icon = icon?.(item.notification_type) ?? Bell;
|
|
67
|
+
const isUnread = item.read_at === null;
|
|
68
|
+
return {
|
|
69
|
+
key: `item-${String(item.id)}`,
|
|
70
|
+
icon: <Icon aria-hidden className="size-4" />,
|
|
71
|
+
label: (
|
|
72
|
+
<span className="block min-w-0" data-bell-item={String(item.id)}>
|
|
73
|
+
{item.company && <span className="block truncate text-caption text-muted">{item.company}</span>}
|
|
74
|
+
<span className={isUnread ? "block font-semibold" : "block"}>{item.title}</span>
|
|
75
|
+
<span className="block line-clamp-2 text-xs text-secondary">{item.body}</span>
|
|
76
|
+
<span className="block text-caption text-muted">{timeOf(item.created_at)}</span>
|
|
77
|
+
</span>
|
|
78
|
+
),
|
|
79
|
+
trailing: isUnread ? <StatusDot tone="brand" aria-label={list.unread} /> : undefined,
|
|
80
|
+
onSelect: () => onOpen(item),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
if (entries.length > 0) entries.push({ kind: "divider", key: "divider" });
|
|
84
|
+
entries.push({ kind: "link", key: "all", to: href, label: labels.allNotifications });
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<TopBarActionMenu
|
|
88
|
+
aria-label={labels.bellLabel}
|
|
89
|
+
triggerAttributes={{ "data-notification-bell": "" }}
|
|
90
|
+
trigger={() => (
|
|
91
|
+
<span className="relative inline-flex" data-unread={unread ? unreadCount : undefined}>
|
|
92
|
+
<Bell aria-hidden className="size-5" />
|
|
93
|
+
{unread && (
|
|
94
|
+
<>
|
|
95
|
+
<span
|
|
96
|
+
aria-hidden
|
|
97
|
+
data-badge=""
|
|
98
|
+
className="pointer-events-none absolute -end-2 -top-1.5 h-4 min-w-4 rounded-full bg-danger px-1 text-center text-micro font-semibold leading-4 text-danger-contrast"
|
|
99
|
+
>
|
|
100
|
+
{badgeText(unreadCount)}
|
|
101
|
+
</span>
|
|
102
|
+
<span className="sr-only">{labels.bellUnread(unreadCount)}</span>
|
|
103
|
+
</>
|
|
104
|
+
)}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
header={
|
|
108
|
+
shown.length === 0
|
|
109
|
+
? { title: labels.inboxTitle, subtitle: items === undefined ? labels.inboxLoading : labels.inboxEmpty }
|
|
110
|
+
: undefined
|
|
111
|
+
}
|
|
112
|
+
entries={entries}
|
|
113
|
+
panelClassName="w-80 max-w-[calc(100vw-2rem)]"
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
}
|