@flamingo-stack/openframe-frontend-core 0.0.263 → 0.0.264-snapshot.20260612115719
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/{chunk-YC357BPF.js → chunk-266DUZ6D.js} +3 -3
- package/dist/{chunk-36HLBLDK.cjs → chunk-2P6V2EWZ.cjs} +498 -455
- package/dist/chunk-2P6V2EWZ.cjs.map +1 -0
- package/dist/{chunk-ALEKX56R.js → chunk-6A2YQ5CY.js} +2 -2
- package/dist/{chunk-C3FAOQAH.cjs → chunk-D4TWJNO5.cjs} +28 -28
- package/dist/{chunk-C3FAOQAH.cjs.map → chunk-D4TWJNO5.cjs.map} +1 -1
- package/dist/{chunk-6AIXHYBZ.js → chunk-EXU5N5RM.js} +100 -57
- package/dist/chunk-EXU5N5RM.js.map +1 -0
- package/dist/{chunk-2XUWR7AH.cjs → chunk-HMHMK6U6.cjs} +11 -11
- package/dist/{chunk-2XUWR7AH.cjs.map → chunk-HMHMK6U6.cjs.map} +1 -1
- package/dist/{chunk-XKA7UFED.cjs → chunk-J5BBBDNA.cjs} +30 -30
- package/dist/{chunk-XKA7UFED.cjs.map → chunk-J5BBBDNA.cjs.map} +1 -1
- package/dist/{chunk-3DC6MHW6.cjs → chunk-QLWBBCI5.cjs} +491 -391
- package/dist/chunk-QLWBBCI5.cjs.map +1 -0
- package/dist/{chunk-U7UDESAU.js → chunk-Z5YEQWLC.js} +2 -2
- package/dist/{chunk-6GBA6YJN.js → chunk-ZMFT5XOB.js} +576 -476
- package/dist/chunk-ZMFT5XOB.js.map +1 -0
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/features/board/ticket-card.d.ts.map +1 -1
- package/dist/components/features/board/types.d.ts +1 -0
- package/dist/components/features/board/types.d.ts.map +1 -1
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/features/notifications/notification-drawer.d.ts.map +1 -1
- package/dist/components/features/notifications/notifications-context.d.ts +9 -1
- package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
- package/dist/components/index.cjs +50 -50
- package/dist/components/index.js +4 -4
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/navigation/sliding-sidebar.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +4 -4
- package/dist/components/related-content/index.js +3 -3
- package/dist/components/tickets/index.cjs +58 -58
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/drawer.d.ts +5 -0
- package/dist/components/ui/drawer.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +6 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +5 -1
- package/dist/hooks/ui/index.d.ts +6 -4
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/use-header-height.d.ts +8 -0
- package/dist/hooks/ui/use-header-height.d.ts.map +1 -0
- package/dist/hooks/ui/use-horizontal-scrollbar.d.ts.map +1 -1
- package/dist/hooks/ui/use-notification-permission.d.ts +14 -0
- package/dist/hooks/ui/use-notification-permission.d.ts.map +1 -0
- package/dist/index.cjs +11 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -2
- package/dist/utils/date-utils.d.ts +9 -0
- package/dist/utils/date-utils.d.ts.map +1 -1
- package/dist/utils/index.cjs +29 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +28 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/features/board/ticket-card.tsx +15 -0
- package/src/components/features/board/types.ts +1 -0
- package/src/components/features/notifications/notification-drawer.tsx +51 -2
- package/src/components/features/notifications/notifications-context.tsx +26 -0
- package/src/components/navigation/sliding-sidebar.tsx +3 -82
- package/src/components/ui/drawer.tsx +17 -6
- package/src/hooks/ui/index.ts +6 -4
- package/src/hooks/ui/use-header-height.ts +55 -0
- package/src/hooks/ui/use-horizontal-scrollbar.ts +19 -2
- package/src/hooks/ui/use-notification-permission.ts +64 -0
- package/src/utils/date-utils.ts +36 -0
- package/src/utils/index.ts +2 -0
- package/dist/chunk-36HLBLDK.cjs.map +0 -1
- package/dist/chunk-3DC6MHW6.cjs.map +0 -1
- package/dist/chunk-6AIXHYBZ.js.map +0 -1
- package/dist/chunk-6GBA6YJN.js.map +0 -1
- /package/dist/{chunk-YC357BPF.js.map → chunk-266DUZ6D.js.map} +0 -0
- /package/dist/{chunk-ALEKX56R.js.map → chunk-6A2YQ5CY.js.map} +0 -0
- /package/dist/{chunk-U7UDESAU.js.map → chunk-Z5YEQWLC.js.map} +0 -0
package/package.json
CHANGED
|
@@ -7,7 +7,9 @@ import * as React from 'react'
|
|
|
7
7
|
import { LaptopIcon, Flag02Icon } from '../../icons-v2-generated'
|
|
8
8
|
import { SquareAvatar } from '../../ui/square-avatar'
|
|
9
9
|
import { Tag } from '../../ui/tag'
|
|
10
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
|
|
10
11
|
import { cn } from '../../../utils/cn'
|
|
12
|
+
import { formatTicketRelativeTime, formatTicketFullTimestamp } from '../../../utils/date-utils'
|
|
11
13
|
import type { BoardPriority, BoardTicket } from './types'
|
|
12
14
|
|
|
13
15
|
const PRIORITY_COLOR_CLASS: Record<BoardPriority, string> = {
|
|
@@ -96,6 +98,9 @@ export function TicketCard({
|
|
|
96
98
|
</div>
|
|
97
99
|
) : null
|
|
98
100
|
|
|
101
|
+
const timestampLabel = ticket.createdAt ? formatTicketRelativeTime(ticket.createdAt) : null
|
|
102
|
+
const tooltipLabel = ticket.createdAt ? formatTicketFullTimestamp(ticket.createdAt) : null
|
|
103
|
+
|
|
99
104
|
const body = (
|
|
100
105
|
<>
|
|
101
106
|
<div className="flex items-start gap-[var(--spacing-system-sf)]">
|
|
@@ -111,6 +116,16 @@ export function TicketCard({
|
|
|
111
116
|
{rightSection}
|
|
112
117
|
</div>
|
|
113
118
|
{ticket.tags?.length ? <TicketTagRow tags={ticket.tags} /> : null}
|
|
119
|
+
{timestampLabel && tooltipLabel && (
|
|
120
|
+
<TooltipProvider>
|
|
121
|
+
<Tooltip>
|
|
122
|
+
<TooltipTrigger asChild>
|
|
123
|
+
<p className="pointer-events-auto text-h6 truncate text-ods-text-secondary">{timestampLabel}</p>
|
|
124
|
+
</TooltipTrigger>
|
|
125
|
+
<TooltipContent>{tooltipLabel}</TooltipContent>
|
|
126
|
+
</Tooltip>
|
|
127
|
+
</TooltipProvider>
|
|
128
|
+
)}
|
|
114
129
|
</>
|
|
115
130
|
)
|
|
116
131
|
|
|
@@ -7,6 +7,7 @@ import { Button } from '../../ui/button/button'
|
|
|
7
7
|
import { Drawer, DrawerContent, DrawerTitle } from '../../ui/drawer'
|
|
8
8
|
import { Switch } from '../../ui/switch'
|
|
9
9
|
import { cn } from '../../../utils/cn'
|
|
10
|
+
import { useNotificationPermission } from '../../../hooks/ui'
|
|
10
11
|
import { useOptionalNotifications } from './notifications-context'
|
|
11
12
|
import { NotificationTile } from './notification-tile'
|
|
12
13
|
import type { Notification, RenderNotificationTile } from './types'
|
|
@@ -26,6 +27,7 @@ export function NotificationDrawer({
|
|
|
26
27
|
renderTile: renderTileProp,
|
|
27
28
|
}: NotificationDrawerProps) {
|
|
28
29
|
const ctx = useOptionalNotifications()
|
|
30
|
+
|
|
29
31
|
if (!ctx) return null
|
|
30
32
|
|
|
31
33
|
const {
|
|
@@ -39,6 +41,9 @@ export function NotificationDrawer({
|
|
|
39
41
|
markAllRead,
|
|
40
42
|
markSettled,
|
|
41
43
|
setShowPopups,
|
|
44
|
+
showDesktopPopups,
|
|
45
|
+
setShowDesktopPopups,
|
|
46
|
+
desktopPopupsConfigured,
|
|
42
47
|
onHistoryClick,
|
|
43
48
|
hasMore,
|
|
44
49
|
isLoadingMore,
|
|
@@ -55,6 +60,7 @@ export function NotificationDrawer({
|
|
|
55
60
|
<DrawerContent
|
|
56
61
|
side="right"
|
|
57
62
|
aria-describedby={undefined}
|
|
63
|
+
offsetHeader
|
|
58
64
|
className={cn(
|
|
59
65
|
'w-[calc(100vw-2rem)] sm:w-[26rem] sm:max-w-[calc(100vw-2rem)] gap-[var(--spacing-system-m)] p-[var(--spacing-system-zero)]',
|
|
60
66
|
className,
|
|
@@ -62,6 +68,7 @@ export function NotificationDrawer({
|
|
|
62
68
|
>
|
|
63
69
|
<div className="px-[var(--spacing-system-m)] pt-[var(--spacing-system-m)]">
|
|
64
70
|
<DrawerTitle
|
|
71
|
+
hideClose
|
|
65
72
|
className="truncate"
|
|
66
73
|
actions={
|
|
67
74
|
<button
|
|
@@ -91,7 +98,12 @@ export function NotificationDrawer({
|
|
|
91
98
|
/>
|
|
92
99
|
|
|
93
100
|
<div className="flex flex-col gap-[var(--spacing-system-xs)] px-[var(--spacing-system-m)] pb-[var(--spacing-system-m)]">
|
|
94
|
-
<
|
|
101
|
+
<div className="overflow-hidden rounded-md border border-ods-border bg-ods-card">
|
|
102
|
+
<ShowNotificationsToggleRow checked={showPopups} onChange={setShowPopups} />
|
|
103
|
+
{desktopPopupsConfigured && (
|
|
104
|
+
<DesktopNotificationsToggleRow checked={showDesktopPopups} onChange={setShowDesktopPopups} />
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
95
107
|
<NotificationsHistoryButton
|
|
96
108
|
onClick={onHistoryClick ? () => { onHistoryClick(); close() } : undefined}
|
|
97
109
|
/>
|
|
@@ -206,7 +218,7 @@ interface ToggleRowProps {
|
|
|
206
218
|
|
|
207
219
|
function ShowNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
|
|
208
220
|
return (
|
|
209
|
-
<div className="flex items-center gap-[var(--spacing-system-s)]
|
|
221
|
+
<div className="flex items-center gap-[var(--spacing-system-s)] border-b border-ods-border p-[var(--spacing-system-sf)]">
|
|
210
222
|
<Switch
|
|
211
223
|
checked={checked}
|
|
212
224
|
onCheckedChange={onChange}
|
|
@@ -220,6 +232,43 @@ function ShowNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
|
|
|
220
232
|
)
|
|
221
233
|
}
|
|
222
234
|
|
|
235
|
+
/** Switching on prompts for browser permission and commits only when granted; denied renders disabled (can't re-prompt programmatically); unsupported (SSR, iOS Safari) hides the row. */
|
|
236
|
+
function DesktopNotificationsToggleRow({ checked, onChange }: ToggleRowProps) {
|
|
237
|
+
const { supported, permission, request } = useNotificationPermission()
|
|
238
|
+
if (!supported) return null
|
|
239
|
+
|
|
240
|
+
const blocked = permission === 'denied'
|
|
241
|
+
|
|
242
|
+
const handleChange = async (value: boolean) => {
|
|
243
|
+
if (!value || permission === 'granted') {
|
|
244
|
+
onChange(value)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if ((await request()) === 'granted') onChange(true)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="flex items-center gap-[var(--spacing-system-s)] border-b border-ods-border p-[var(--spacing-system-sf)]">
|
|
252
|
+
<Switch
|
|
253
|
+
checked={checked && permission === 'granted'}
|
|
254
|
+
disabled={blocked}
|
|
255
|
+
onCheckedChange={handleChange}
|
|
256
|
+
aria-label="Show desktop notifications"
|
|
257
|
+
/>
|
|
258
|
+
<div className="min-w-0 flex-1">
|
|
259
|
+
<p className={cn('text-h4', blocked ? 'text-ods-text-secondary' : 'text-ods-text-primary')}>
|
|
260
|
+
Desktop Notifications
|
|
261
|
+
</p>
|
|
262
|
+
<p className="text-h6 text-ods-text-secondary">
|
|
263
|
+
{blocked
|
|
264
|
+
? 'Enable notifications in your browser settings'
|
|
265
|
+
: 'Notify when this tab is in the background'}
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
223
272
|
interface HistoryButtonProps {
|
|
224
273
|
onClick?: () => void
|
|
225
274
|
}
|
|
@@ -20,6 +20,11 @@ interface NotificationsContextValue {
|
|
|
20
20
|
close: () => void
|
|
21
21
|
toggle: () => void
|
|
22
22
|
setShowPopups: (value: boolean) => void
|
|
23
|
+
/** OS-level notification preference; only meaningful when `desktopPopupsConfigured`. */
|
|
24
|
+
showDesktopPopups: boolean
|
|
25
|
+
setShowDesktopPopups: (value: boolean) => void
|
|
26
|
+
/** True when the host app wired desktop notifications (passed `onShowDesktopPopupsChange`). */
|
|
27
|
+
desktopPopupsConfigured: boolean
|
|
23
28
|
onHistoryClick?: () => void
|
|
24
29
|
hasMore: boolean
|
|
25
30
|
isLoadingMore: boolean
|
|
@@ -46,6 +51,9 @@ export interface NotificationsProviderProps {
|
|
|
46
51
|
maxNotifications?: number
|
|
47
52
|
defaultShowPopups?: boolean
|
|
48
53
|
onShowPopupsChange?: (value: boolean) => void
|
|
54
|
+
/** Desktop (OS-level) notification preference; providing `onShowDesktopPopupsChange` opts into the drawer toggle. */
|
|
55
|
+
defaultShowDesktopPopups?: boolean
|
|
56
|
+
onShowDesktopPopupsChange?: (value: boolean) => void
|
|
49
57
|
onHistoryClick?: () => void
|
|
50
58
|
actions?: NotificationsActions
|
|
51
59
|
/** Pagination — when omitted, the drawer hides its load-more sentinel. */
|
|
@@ -138,6 +146,8 @@ export function NotificationsProvider({
|
|
|
138
146
|
maxNotifications = 50,
|
|
139
147
|
defaultShowPopups = true,
|
|
140
148
|
onShowPopupsChange,
|
|
149
|
+
defaultShowDesktopPopups = false,
|
|
150
|
+
onShowDesktopPopupsChange,
|
|
141
151
|
onHistoryClick,
|
|
142
152
|
actions,
|
|
143
153
|
hasMore = false,
|
|
@@ -148,6 +158,8 @@ export function NotificationsProvider({
|
|
|
148
158
|
const [notifications, dispatch] = React.useReducer(reducer, initialNotifications)
|
|
149
159
|
const [isOpen, setIsOpen] = React.useState(false)
|
|
150
160
|
const [showPopups, setShowPopupsState] = React.useState(defaultShowPopups)
|
|
161
|
+
const [showDesktopPopups, setShowDesktopPopupsState] = React.useState(defaultShowDesktopPopups)
|
|
162
|
+
const desktopPopupsConfigured = onShowDesktopPopupsChange !== undefined
|
|
151
163
|
|
|
152
164
|
const actionsRef = React.useRef(actions)
|
|
153
165
|
React.useEffect(() => {
|
|
@@ -221,6 +233,14 @@ export function NotificationsProvider({
|
|
|
221
233
|
[onShowPopupsChange],
|
|
222
234
|
)
|
|
223
235
|
|
|
236
|
+
const setShowDesktopPopups = React.useCallback(
|
|
237
|
+
(value: boolean) => {
|
|
238
|
+
setShowDesktopPopupsState(value)
|
|
239
|
+
onShowDesktopPopupsChange?.(value)
|
|
240
|
+
},
|
|
241
|
+
[onShowDesktopPopupsChange],
|
|
242
|
+
)
|
|
243
|
+
|
|
224
244
|
const unreadCount = React.useMemo(
|
|
225
245
|
() => notifications.reduce((acc, n) => (n.read ? acc : acc + 1), 0),
|
|
226
246
|
[notifications],
|
|
@@ -244,6 +264,9 @@ export function NotificationsProvider({
|
|
|
244
264
|
close,
|
|
245
265
|
toggle,
|
|
246
266
|
setShowPopups,
|
|
267
|
+
showDesktopPopups,
|
|
268
|
+
setShowDesktopPopups,
|
|
269
|
+
desktopPopupsConfigured,
|
|
247
270
|
onHistoryClick,
|
|
248
271
|
hasMore,
|
|
249
272
|
isLoadingMore,
|
|
@@ -255,6 +278,9 @@ export function NotificationsProvider({
|
|
|
255
278
|
unreadCount,
|
|
256
279
|
isOpen,
|
|
257
280
|
showPopups,
|
|
281
|
+
showDesktopPopups,
|
|
282
|
+
setShowDesktopPopups,
|
|
283
|
+
desktopPopupsConfigured,
|
|
258
284
|
addNotification,
|
|
259
285
|
upsertNotification,
|
|
260
286
|
setNotifications,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useEffect } from 'react'
|
|
4
4
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
5
5
|
import { cn } from '../../utils'
|
|
6
|
+
import { useHeaderHeight } from '../../hooks/ui'
|
|
6
7
|
import { SlidingSidebarConfig, NavigationItem } from '../../types/navigation'
|
|
7
8
|
import { Button } from '../ui/button'
|
|
8
9
|
|
|
@@ -13,89 +14,9 @@ export interface SlidingSidebarProps {
|
|
|
13
14
|
export function SlidingSidebar({ config }: SlidingSidebarProps) {
|
|
14
15
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
|
|
15
16
|
const [mounted, setMounted] = useState(false)
|
|
16
|
-
const
|
|
17
|
+
const headerHeight = useHeaderHeight()
|
|
17
18
|
|
|
18
|
-
useEffect(() => {
|
|
19
|
-
setMounted(true)
|
|
20
|
-
|
|
21
|
-
// Function to calculate total header height
|
|
22
|
-
const calculateHeaderHeight = () => {
|
|
23
|
-
let totalHeight = 0
|
|
24
|
-
|
|
25
|
-
// Find and measure the header
|
|
26
|
-
const header = document.querySelector('header')
|
|
27
|
-
if (header) {
|
|
28
|
-
totalHeight += header.offsetHeight
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Find and measure the announcement bar (if exists)
|
|
32
|
-
const announcementBar = document.querySelector('[data-announcement-bar]')
|
|
33
|
-
if (announcementBar && announcementBar instanceof HTMLElement) {
|
|
34
|
-
totalHeight += announcementBar.offsetHeight
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Update the header height if we found elements
|
|
38
|
-
if (totalHeight > 0) {
|
|
39
|
-
setHeaderHeight(totalHeight)
|
|
40
|
-
} else {
|
|
41
|
-
// If no header found, default to 64px
|
|
42
|
-
setHeaderHeight(64)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Calculate initial height with a small delay to ensure elements are rendered
|
|
47
|
-
setTimeout(calculateHeaderHeight, 100)
|
|
48
|
-
|
|
49
|
-
// Also calculate immediately in case elements are already there
|
|
50
|
-
calculateHeaderHeight()
|
|
51
|
-
|
|
52
|
-
// Recalculate on window resize
|
|
53
|
-
window.addEventListener('resize', calculateHeaderHeight)
|
|
54
|
-
|
|
55
|
-
// Use ResizeObserver to detect header size changes
|
|
56
|
-
const resizeObserver = new ResizeObserver(calculateHeaderHeight)
|
|
57
|
-
|
|
58
|
-
// Observe header
|
|
59
|
-
const header = document.querySelector('header')
|
|
60
|
-
if (header) {
|
|
61
|
-
resizeObserver.observe(header)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Observe announcement bar
|
|
65
|
-
const announcementBar = document.querySelector('[data-announcement-bar]')
|
|
66
|
-
if (announcementBar) {
|
|
67
|
-
resizeObserver.observe(announcementBar)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Use MutationObserver to detect when announcement bar is added/removed
|
|
71
|
-
const mutationObserver = new MutationObserver((mutations) => {
|
|
72
|
-
// Check if announcement bar was added or removed
|
|
73
|
-
for (const mutation of mutations) {
|
|
74
|
-
if (mutation.type === 'childList') {
|
|
75
|
-
// Recalculate height when DOM changes
|
|
76
|
-
calculateHeaderHeight()
|
|
77
|
-
|
|
78
|
-
// If announcement bar was added, start observing it
|
|
79
|
-
const newAnnouncementBar = document.querySelector('[data-announcement-bar]')
|
|
80
|
-
if (newAnnouncementBar) {
|
|
81
|
-
resizeObserver.observe(newAnnouncementBar)
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
// Observe the body for changes (announcement bar might be added/removed)
|
|
88
|
-
mutationObserver.observe(document.body, {
|
|
89
|
-
childList: true,
|
|
90
|
-
subtree: true
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
return () => {
|
|
94
|
-
window.removeEventListener('resize', calculateHeaderHeight)
|
|
95
|
-
resizeObserver.disconnect()
|
|
96
|
-
mutationObserver.disconnect()
|
|
97
|
-
}
|
|
98
|
-
}, [])
|
|
19
|
+
useEffect(() => { setMounted(true) }, [])
|
|
99
20
|
|
|
100
21
|
const toggleExpanded = (itemId: string) => {
|
|
101
22
|
const newExpanded = new Set(expandedItems)
|
|
@@ -6,6 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
6
6
|
import { X } from "lucide-react"
|
|
7
7
|
|
|
8
8
|
import { cn } from "../../utils/cn"
|
|
9
|
+
import { useHeaderHeight } from "../../hooks/ui/use-header-height"
|
|
9
10
|
|
|
10
11
|
const Drawer = DialogPrimitive.Root
|
|
11
12
|
|
|
@@ -354,6 +355,9 @@ interface DrawerContentProps
|
|
|
354
355
|
panelStyle?: React.CSSProperties
|
|
355
356
|
/** Optional className applied to the inner panel. (Alias for `className`.) */
|
|
356
357
|
panelClassName?: string
|
|
358
|
+
/** Offset the drawer's top edge below the app header + announcement bar.
|
|
359
|
+
* Measured live via ResizeObserver so it tracks height changes. */
|
|
360
|
+
offsetHeader?: boolean
|
|
357
361
|
}
|
|
358
362
|
|
|
359
363
|
const DrawerContent = React.forwardRef<
|
|
@@ -376,6 +380,7 @@ const DrawerContent = React.forwardRef<
|
|
|
376
380
|
style,
|
|
377
381
|
panelStyle,
|
|
378
382
|
panelClassName,
|
|
383
|
+
offsetHeader = false,
|
|
379
384
|
children,
|
|
380
385
|
...props
|
|
381
386
|
},
|
|
@@ -385,6 +390,8 @@ const DrawerContent = React.forwardRef<
|
|
|
385
390
|
const isHorizontal = HORIZONTAL_SIDES.has(resolvedSide)
|
|
386
391
|
const initialSize = defaultSize ?? (isHorizontal ? 560 : 480)
|
|
387
392
|
|
|
393
|
+
const headerHeight = useHeaderHeight()
|
|
394
|
+
|
|
388
395
|
const [isMobile, setIsMobile] = React.useState(false)
|
|
389
396
|
React.useEffect(() => {
|
|
390
397
|
if (typeof window === "undefined") return
|
|
@@ -417,7 +424,7 @@ const DrawerContent = React.forwardRef<
|
|
|
417
424
|
<DialogPrimitive.Content
|
|
418
425
|
ref={ref}
|
|
419
426
|
className={cn(drawerVariants({ side, flush }))}
|
|
420
|
-
style={style}
|
|
427
|
+
style={{ ...(offsetHeader ? { top: headerHeight } : {}), ...style }}
|
|
421
428
|
{...props}
|
|
422
429
|
>
|
|
423
430
|
{/* Resize handle is a sibling of the panel — outside the panel's
|
|
@@ -468,12 +475,14 @@ interface DrawerTitleProps
|
|
|
468
475
|
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {
|
|
469
476
|
/** Optional header actions rendered between the title and the close button. */
|
|
470
477
|
actions?: React.ReactNode
|
|
478
|
+
/** Hide the close (X) button. */
|
|
479
|
+
hideClose?: boolean
|
|
471
480
|
}
|
|
472
481
|
|
|
473
482
|
const DrawerTitle = React.forwardRef<
|
|
474
483
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
|
475
484
|
DrawerTitleProps
|
|
476
|
-
>(({ className, children, actions, ...props }, ref) => (
|
|
485
|
+
>(({ className, children, actions, hideClose, ...props }, ref) => (
|
|
477
486
|
<div className="flex items-start gap-4">
|
|
478
487
|
<DialogPrimitive.Title
|
|
479
488
|
ref={ref}
|
|
@@ -486,10 +495,12 @@ const DrawerTitle = React.forwardRef<
|
|
|
486
495
|
{children}
|
|
487
496
|
</DialogPrimitive.Title>
|
|
488
497
|
{actions}
|
|
489
|
-
|
|
490
|
-
<
|
|
491
|
-
|
|
492
|
-
|
|
498
|
+
{!hideClose && (
|
|
499
|
+
<DialogPrimitive.Close className="shrink-0 rounded-sm text-ods-text-secondary transition-colors hover:text-ods-text-primary outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0">
|
|
500
|
+
<X className="size-6" />
|
|
501
|
+
<span className="sr-only">Close</span>
|
|
502
|
+
</DialogPrimitive.Close>
|
|
503
|
+
)}
|
|
493
504
|
</div>
|
|
494
505
|
))
|
|
495
506
|
DrawerTitle.displayName = "DrawerTitle"
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// UI Hooks exports
|
|
2
|
+
export * from './use-auto-limit-tags'
|
|
2
3
|
export * from './use-debounce'
|
|
4
|
+
export * from './use-header-height'
|
|
5
|
+
export * from './use-horizontal-scrollbar'
|
|
6
|
+
export * from './use-image-edge-color'
|
|
3
7
|
export * from './use-local-storage'
|
|
4
8
|
export * from './use-media-query'
|
|
5
9
|
export * from './use-memoized-callback'
|
|
10
|
+
export * from './use-notification-permission'
|
|
6
11
|
export * from './use-onboarding-state'
|
|
12
|
+
export * from './use-search'
|
|
7
13
|
export * from './use-table-pagination'
|
|
8
14
|
export * from './use-throttle'
|
|
9
15
|
export * from './use-window-size'
|
|
10
|
-
export * from './use-horizontal-scrollbar'
|
|
11
|
-
export * from './use-image-edge-color'
|
|
12
|
-
export * from './use-search'
|
|
13
|
-
export * from './use-auto-limit-tags'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the combined height (px) of the sticky page header and announcement
|
|
7
|
+
* bar (if present), updated live via ResizeObserver / MutationObserver.
|
|
8
|
+
* Useful for offsetting fixed/absolute-positioned panels so they don't
|
|
9
|
+
* overlap the header.
|
|
10
|
+
*/
|
|
11
|
+
export function useHeaderHeight(defaultHeight = 64): number {
|
|
12
|
+
const [height, setHeight] = useState(defaultHeight)
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const measure = () => {
|
|
16
|
+
let total = 0
|
|
17
|
+
const header = document.querySelector('header')
|
|
18
|
+
if (header) total += header.offsetHeight
|
|
19
|
+
const bar = document.querySelector('[data-announcement-bar]')
|
|
20
|
+
if (bar instanceof HTMLElement) total += bar.offsetHeight
|
|
21
|
+
setHeight(total > 0 ? total : defaultHeight)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
measure()
|
|
25
|
+
|
|
26
|
+
const resizeObserver = new ResizeObserver(measure)
|
|
27
|
+
const header = document.querySelector('header')
|
|
28
|
+
if (header) resizeObserver.observe(header)
|
|
29
|
+
const bar = document.querySelector('[data-announcement-bar]')
|
|
30
|
+
if (bar) resizeObserver.observe(bar)
|
|
31
|
+
|
|
32
|
+
const mutationObserver = new MutationObserver((mutations) => {
|
|
33
|
+
for (const mutation of mutations) {
|
|
34
|
+
if (mutation.type === 'childList' || mutation.type === 'attributes') {
|
|
35
|
+
measure()
|
|
36
|
+
const newBar = document.querySelector('[data-announcement-bar]')
|
|
37
|
+
if (newBar) resizeObserver.observe(newBar)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
mutationObserver.observe(document.body, {
|
|
42
|
+
childList: true,
|
|
43
|
+
subtree: true,
|
|
44
|
+
attributes: true,
|
|
45
|
+
attributeFilter: ['data-announcement-bar'],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
resizeObserver.disconnect()
|
|
50
|
+
mutationObserver.disconnect()
|
|
51
|
+
}
|
|
52
|
+
}, [defaultHeight])
|
|
53
|
+
|
|
54
|
+
return height
|
|
55
|
+
}
|
|
@@ -17,6 +17,7 @@ export function useHorizontalScrollbar() {
|
|
|
17
17
|
const trackRef = useRef<HTMLDivElement>(null)
|
|
18
18
|
const thumbRef = useRef<HTMLDivElement>(null)
|
|
19
19
|
const roRef = useRef<ResizeObserver | null>(null)
|
|
20
|
+
const moRef = useRef<MutationObserver | null>(null)
|
|
20
21
|
const [thumbRatio, setThumbRatio] = useState(0)
|
|
21
22
|
|
|
22
23
|
// Edge-fade state
|
|
@@ -94,6 +95,10 @@ export function useHorizontalScrollbar() {
|
|
|
94
95
|
roRef.current.disconnect()
|
|
95
96
|
roRef.current = null
|
|
96
97
|
}
|
|
98
|
+
if (moRef.current) {
|
|
99
|
+
moRef.current.disconnect()
|
|
100
|
+
moRef.current = null
|
|
101
|
+
}
|
|
97
102
|
if (rafIdRef.current) {
|
|
98
103
|
cancelAnimationFrame(rafIdRef.current)
|
|
99
104
|
rafIdRef.current = 0
|
|
@@ -105,8 +110,20 @@ export function useHorizontalScrollbar() {
|
|
|
105
110
|
const ratio = node.clientWidth / node.scrollWidth
|
|
106
111
|
setThumbRatio(ratio >= 1 ? 0 : ratio)
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
roRef.current
|
|
113
|
+
const ro = new ResizeObserver(measure)
|
|
114
|
+
roRef.current = ro
|
|
115
|
+
const observeAll = () => {
|
|
116
|
+
ro.disconnect()
|
|
117
|
+
ro.observe(node)
|
|
118
|
+
for (const child of Array.from(node.children)) ro.observe(child)
|
|
119
|
+
}
|
|
120
|
+
observeAll()
|
|
121
|
+
|
|
122
|
+
moRef.current = new MutationObserver(() => {
|
|
123
|
+
observeAll()
|
|
124
|
+
measure()
|
|
125
|
+
})
|
|
126
|
+
moRef.current.observe(node, { childList: true })
|
|
110
127
|
|
|
111
128
|
requestAnimationFrame(() => {
|
|
112
129
|
syncThumbToDOM()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface UseNotificationPermissionResult {
|
|
6
|
+
/** Whether the page-context Notification API exists in this browser (false during SSR and on iOS Safari). */
|
|
7
|
+
supported: boolean
|
|
8
|
+
permission: NotificationPermission
|
|
9
|
+
/** Prompt the user for permission. Must be called from a user gesture or browsers will auto-deny. */
|
|
10
|
+
request: () => Promise<NotificationPermission>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tracks the browser's desktop-notification permission, staying in sync when
|
|
15
|
+
* the user changes the site setting externally (Permissions API change event
|
|
16
|
+
* where available, visibility/focus re-read as the Safari fallback).
|
|
17
|
+
*/
|
|
18
|
+
export function useNotificationPermission(): UseNotificationPermissionResult {
|
|
19
|
+
const [supported, setSupported] = useState(false)
|
|
20
|
+
const [permission, setPermission] = useState<NotificationPermission>('default')
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (typeof window === 'undefined' || !('Notification' in window)) return
|
|
24
|
+
setSupported(true)
|
|
25
|
+
const sync = () => setPermission(Notification.permission)
|
|
26
|
+
sync()
|
|
27
|
+
|
|
28
|
+
// The query resolves async; without the flag it would attach the listener after unmount.
|
|
29
|
+
let cancelled = false
|
|
30
|
+
let status: PermissionStatus | undefined
|
|
31
|
+
navigator.permissions
|
|
32
|
+
?.query({ name: 'notifications' as PermissionName })
|
|
33
|
+
.then((s) => {
|
|
34
|
+
if (cancelled) return
|
|
35
|
+
status = s
|
|
36
|
+
s.addEventListener('change', sync)
|
|
37
|
+
})
|
|
38
|
+
.catch(() => undefined)
|
|
39
|
+
|
|
40
|
+
document.addEventListener('visibilitychange', sync)
|
|
41
|
+
window.addEventListener('focus', sync)
|
|
42
|
+
return () => {
|
|
43
|
+
cancelled = true
|
|
44
|
+
status?.removeEventListener('change', sync)
|
|
45
|
+
document.removeEventListener('visibilitychange', sync)
|
|
46
|
+
window.removeEventListener('focus', sync)
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const request = useCallback(async () => {
|
|
51
|
+
if (typeof window === 'undefined' || !('Notification' in window)) {
|
|
52
|
+
return 'denied' as NotificationPermission
|
|
53
|
+
}
|
|
54
|
+
// Older Safari only implements the callback form; resolving from both is harmless.
|
|
55
|
+
const result = await new Promise<NotificationPermission>((resolve) => {
|
|
56
|
+
const maybePromise = Notification.requestPermission(resolve)
|
|
57
|
+
if (maybePromise && typeof maybePromise.then === 'function') void maybePromise.then(resolve)
|
|
58
|
+
})
|
|
59
|
+
setPermission(Notification.permission)
|
|
60
|
+
return result
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
return { supported, permission, request }
|
|
64
|
+
}
|
package/src/utils/date-utils.ts
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
* Provides consistent date formatting across all components
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Relative display label for a ticket timestamp (ISO string).
|
|
8
|
+
* < 1 min → "Just now" | 1-59 min → "X min ago" | 1-23 h → "X hour(s) ago" | 24+ h → "MM/DD/YYYY"
|
|
9
|
+
*/
|
|
10
|
+
export function formatTicketRelativeTime(iso: string): string {
|
|
11
|
+
const now = new Date()
|
|
12
|
+
const date = new Date(iso)
|
|
13
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
14
|
+
const diffMs = now.getTime() - date.getTime()
|
|
15
|
+
const diffMin = Math.floor(diffMs / 60_000)
|
|
16
|
+
if (diffMin < 1) return 'Just now'
|
|
17
|
+
if (diffMin < 60) return `${diffMin} min ago`
|
|
18
|
+
const diffHours = Math.floor(diffMin / 60)
|
|
19
|
+
if (diffHours < 24) return diffHours === 1 ? '1 hour ago' : `${diffHours} hours ago`
|
|
20
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
|
21
|
+
const dd = String(date.getDate()).padStart(2, '0')
|
|
22
|
+
const yyyy = date.getFullYear()
|
|
23
|
+
return `${mm}/${dd}/${yyyy}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Full tooltip timestamp in "MM/DD/YYYY, H:MM AM/PM" format.
|
|
28
|
+
*/
|
|
29
|
+
export function formatTicketFullTimestamp(iso: string): string {
|
|
30
|
+
const date = new Date(iso)
|
|
31
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
32
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
|
33
|
+
const dd = String(date.getDate()).padStart(2, '0')
|
|
34
|
+
const yyyy = date.getFullYear()
|
|
35
|
+
let hours = date.getHours()
|
|
36
|
+
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
37
|
+
const ampm = hours >= 12 ? 'PM' : 'AM'
|
|
38
|
+
hours = hours % 12 || 12
|
|
39
|
+
return `${mm}/${dd}/${yyyy}, ${hours}:${minutes} ${ampm}`
|
|
40
|
+
}
|
|
41
|
+
|
|
6
42
|
/**
|
|
7
43
|
* Format relative time from ISO timestamp
|
|
8
44
|
* This is the single source of truth for all relative time formatting
|
package/src/utils/index.ts
CHANGED