@flamingo-stack/openframe-frontend-core 0.0.685 → 0.0.686-2463.7461.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/dist/{chunk-T6KWNJDW.js → chunk-22RSJ57Z.js} +470 -465
- package/dist/chunk-22RSJ57Z.js.map +1 -0
- package/dist/{chunk-65KTXRBY.js → chunk-2G2CB7ZZ.js} +6 -6
- package/dist/{chunk-K2HZG4RM.cjs → chunk-2OD2PPNO.cjs} +32 -32
- package/dist/{chunk-K2HZG4RM.cjs.map → chunk-2OD2PPNO.cjs.map} +1 -1
- package/dist/{chunk-HMXUOLZR.cjs → chunk-3DO76UAB.cjs} +20 -20
- package/dist/{chunk-HMXUOLZR.cjs.map → chunk-3DO76UAB.cjs.map} +1 -1
- package/dist/{chunk-XW7MUIW4.js → chunk-3NIZ2VLQ.js} +3 -3
- package/dist/{chunk-5LJZPJHX.cjs → chunk-4LBCUBVL.cjs} +59 -59
- package/dist/{chunk-5LJZPJHX.cjs.map → chunk-4LBCUBVL.cjs.map} +1 -1
- package/dist/{chunk-ZF4Z36G4.js → chunk-4RJQMOQW.js} +7 -7
- package/dist/{chunk-TJI5V5BR.cjs → chunk-AF4XOURN.cjs} +7 -7
- package/dist/{chunk-TJI5V5BR.cjs.map → chunk-AF4XOURN.cjs.map} +1 -1
- package/dist/{chunk-7XF63THA.js → chunk-ASBBGP36.js} +6 -6
- package/dist/{chunk-O7LOZW4R.js → chunk-C5TFPMOM.js} +5 -5
- package/dist/{chunk-CMQXPD5C.cjs → chunk-GETQTNRV.cjs} +28 -28
- package/dist/{chunk-CMQXPD5C.cjs.map → chunk-GETQTNRV.cjs.map} +1 -1
- package/dist/{chunk-TUG7IT6H.js → chunk-HLEXYTOE.js} +2 -2
- package/dist/{chunk-M4HYIXP6.cjs → chunk-IBUFFKAK.cjs} +399 -383
- package/dist/chunk-IBUFFKAK.cjs.map +1 -0
- package/dist/{chunk-GXV6UEVY.js → chunk-IELOPDTA.js} +3 -3
- package/dist/{chunk-T4DUTP5C.cjs → chunk-II5GVLTN.cjs} +5 -5
- package/dist/{chunk-T4DUTP5C.cjs.map → chunk-II5GVLTN.cjs.map} +1 -1
- package/dist/{chunk-B32YLOYQ.js → chunk-JBJZL5PI.js} +4 -4
- package/dist/{chunk-UT333EOT.js → chunk-LCOMNQLX.js} +4 -4
- package/dist/{chunk-VDPCK437.js → chunk-MHTB67ZH.js} +21 -5
- package/dist/{chunk-VDPCK437.js.map → chunk-MHTB67ZH.js.map} +1 -1
- package/dist/{chunk-EFBMNVSH.cjs → chunk-MZAZUSPQ.cjs} +15 -15
- package/dist/{chunk-EFBMNVSH.cjs.map → chunk-MZAZUSPQ.cjs.map} +1 -1
- package/dist/{chunk-C3B36OYZ.cjs → chunk-N3I53B5W.cjs} +3 -3
- package/dist/{chunk-C3B36OYZ.cjs.map → chunk-N3I53B5W.cjs.map} +1 -1
- package/dist/{chunk-WJK2EETW.js → chunk-NINO3PI7.js} +2 -2
- package/dist/{chunk-YCTVLN47.cjs → chunk-P4SBHO7U.cjs} +10 -10
- package/dist/{chunk-YCTVLN47.cjs.map → chunk-P4SBHO7U.cjs.map} +1 -1
- package/dist/{chunk-PSQKD6ZN.js → chunk-PG65AE3I.js} +3 -3
- package/dist/{chunk-DQOQ3KTP.cjs → chunk-QNWQQ4RT.cjs} +78 -73
- package/dist/chunk-QNWQQ4RT.cjs.map +1 -0
- package/dist/{chunk-BZ2XOEHW.js → chunk-QOH45QSE.js} +3 -3
- package/dist/{chunk-DWXWEGGO.cjs → chunk-QTXDXHMD.cjs} +22 -22
- package/dist/{chunk-DWXWEGGO.cjs.map → chunk-QTXDXHMD.cjs.map} +1 -1
- package/dist/{chunk-B25CF3PO.cjs → chunk-RBCMBRI7.cjs} +30 -30
- package/dist/{chunk-B25CF3PO.cjs.map → chunk-RBCMBRI7.cjs.map} +1 -1
- package/dist/{chunk-QHLJSB2J.cjs → chunk-RKH7DEDF.cjs} +12 -12
- package/dist/{chunk-QHLJSB2J.cjs.map → chunk-RKH7DEDF.cjs.map} +1 -1
- package/dist/{chunk-Y7ZYOQNB.js → chunk-RSMAVO2Z.js} +3 -3
- package/dist/{chunk-A3CSQB4A.cjs → chunk-SI77HF4U.cjs} +1247 -805
- package/dist/chunk-SI77HF4U.cjs.map +1 -0
- package/dist/{chunk-GHLGXFNU.js → chunk-U6JC3RS2.js} +3 -3
- package/dist/{chunk-B5N3AHPG.cjs → chunk-UBXEETWQ.cjs} +81 -81
- package/dist/{chunk-B5N3AHPG.cjs.map → chunk-UBXEETWQ.cjs.map} +1 -1
- package/dist/{chunk-N4Z6X5BV.js → chunk-WFH52OVQ.js} +5 -5
- package/dist/{chunk-U3IXG7TR.cjs → chunk-WJX3ZSOE.cjs} +33 -33
- package/dist/{chunk-U3IXG7TR.cjs.map → chunk-WJX3ZSOE.cjs.map} +1 -1
- package/dist/{chunk-XGX6JSEU.js → chunk-YPXXXX6R.js} +2086 -1644
- package/dist/chunk-YPXXXX6R.js.map +1 -0
- package/dist/{chunk-P2ASEPA2.cjs → chunk-ZS3YCJYY.cjs} +6 -6
- package/dist/{chunk-P2ASEPA2.cjs.map → chunk-ZS3YCJYY.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +16 -16
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/ai-assistant-info.d.ts.map +1 -1
- package/dist/components/chat/approval-request-message.d.ts.map +1 -1
- package/dist/components/chat/chat-appearance-context.d.ts +9 -0
- package/dist/components/chat/chat-appearance-context.d.ts.map +1 -0
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/chat-nav-sidebar.d.ts +50 -0
- package/dist/components/chat/chat-nav-sidebar.d.ts.map +1 -0
- package/dist/components/chat/chat-top-navigation.d.ts +29 -0
- package/dist/components/chat/chat-top-navigation.d.ts.map +1 -0
- package/dist/components/chat/error-message-display.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +21 -3
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +6 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +20 -2
- package/dist/components/chat/msp-organization-card.d.ts +7 -1
- package/dist/components/chat/msp-organization-card.d.ts.map +1 -1
- package/dist/components/chat/onboarding-carousel.d.ts +29 -0
- package/dist/components/chat/onboarding-carousel.d.ts.map +1 -0
- package/dist/components/chat/system-event-message.d.ts +14 -0
- package/dist/components/chat/system-event-message.d.ts.map +1 -0
- package/dist/components/chat/types/chat.types.d.ts +13 -0
- package/dist/components/chat/types/chat.types.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +20 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +91 -91
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +73 -55
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +33 -15
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +41 -41
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/avatar-stack.d.ts +5 -1
- package/dist/components/ui/avatar-stack.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +22 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +21 -3
- package/dist/components/ui/reopen-ticket-request-modal.d.ts +4 -1
- package/dist/components/ui/reopen-ticket-request-modal.d.ts.map +1 -1
- package/dist/components/ui/tab-navigation.d.ts +7 -0
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/index.cjs +27 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +25 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-appearance.test.tsx +79 -0
- package/src/components/chat/__tests__/onboarding-carousel.test.tsx +56 -0
- package/src/components/chat/ai-assistant-info.tsx +7 -1
- package/src/components/chat/approval-request-message.tsx +6 -1
- package/src/components/chat/chat-appearance-context.tsx +15 -0
- package/src/components/chat/chat-input.tsx +26 -1
- package/src/components/chat/chat-message-enhanced.tsx +180 -142
- package/src/components/chat/chat-message-list.tsx +169 -164
- package/src/components/chat/chat-nav-sidebar.tsx +163 -0
- package/src/components/chat/chat-top-navigation.tsx +109 -0
- package/src/components/chat/error-message-display.tsx +21 -3
- package/src/components/chat/index.ts +6 -0
- package/src/components/chat/msp-organization-card.tsx +35 -0
- package/src/components/chat/onboarding-carousel.tsx +162 -0
- package/src/components/chat/system-event-message.tsx +54 -0
- package/src/components/chat/types/chat.types.ts +15 -0
- package/src/components/chat/types/component.types.ts +20 -0
- package/src/components/ui/__tests__/avatar-stack.test.tsx +25 -0
- package/src/components/ui/__tests__/tab-navigation.test.tsx +56 -2
- package/src/components/ui/avatar-stack.tsx +15 -5
- package/src/components/ui/reopen-ticket-request-modal.tsx +6 -2
- package/src/components/ui/tab-navigation.tsx +23 -0
- package/src/stories/FaeLayoutV2.stories.tsx +150 -0
- package/src/stories/FaeOnboardingV2.stories.tsx +68 -0
- package/src/stories/FaeThreadV2.stories.tsx +192 -0
- package/src/stories/fae-brand-decorator.tsx +28 -0
- package/src/styles/ods-colors.css +10 -0
- package/src/styles/ods-effects.css +13 -0
- package/dist/chunk-A3CSQB4A.cjs.map +0 -1
- package/dist/chunk-DQOQ3KTP.cjs.map +0 -1
- package/dist/chunk-M4HYIXP6.cjs.map +0 -1
- package/dist/chunk-T6KWNJDW.js.map +0 -1
- package/dist/chunk-XGX6JSEU.js.map +0 -1
- /package/dist/{chunk-65KTXRBY.js.map → chunk-2G2CB7ZZ.js.map} +0 -0
- /package/dist/{chunk-XW7MUIW4.js.map → chunk-3NIZ2VLQ.js.map} +0 -0
- /package/dist/{chunk-ZF4Z36G4.js.map → chunk-4RJQMOQW.js.map} +0 -0
- /package/dist/{chunk-7XF63THA.js.map → chunk-ASBBGP36.js.map} +0 -0
- /package/dist/{chunk-O7LOZW4R.js.map → chunk-C5TFPMOM.js.map} +0 -0
- /package/dist/{chunk-TUG7IT6H.js.map → chunk-HLEXYTOE.js.map} +0 -0
- /package/dist/{chunk-GXV6UEVY.js.map → chunk-IELOPDTA.js.map} +0 -0
- /package/dist/{chunk-B32YLOYQ.js.map → chunk-JBJZL5PI.js.map} +0 -0
- /package/dist/{chunk-UT333EOT.js.map → chunk-LCOMNQLX.js.map} +0 -0
- /package/dist/{chunk-WJK2EETW.js.map → chunk-NINO3PI7.js.map} +0 -0
- /package/dist/{chunk-PSQKD6ZN.js.map → chunk-PG65AE3I.js.map} +0 -0
- /package/dist/{chunk-BZ2XOEHW.js.map → chunk-QOH45QSE.js.map} +0 -0
- /package/dist/{chunk-Y7ZYOQNB.js.map → chunk-RSMAVO2Z.js.map} +0 -0
- /package/dist/{chunk-GHLGXFNU.js.map → chunk-U6JC3RS2.js.map} +0 -0
- /package/dist/{chunk-N4Z6X5BV.js.map → chunk-WFH52OVQ.js.map} +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { Settings01Icon, SidebarIcon } from '../icons-v2-generated';
|
|
6
|
+
import { Skeleton } from '../ui/skeleton';
|
|
7
|
+
import { SquareAvatar } from '../ui/square-avatar';
|
|
8
|
+
|
|
9
|
+
export interface ChatTopNavigationProps extends HTMLAttributes<HTMLElement> {
|
|
10
|
+
/** Who the user is talking to. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Line under the name (e.g. "Your AI Assistant", "IT Technician"). */
|
|
13
|
+
subtitle?: string;
|
|
14
|
+
avatarUrl?: string | null;
|
|
15
|
+
/** Actions after the identity (e.g. "Request a Technician"). */
|
|
16
|
+
actions?: ReactNode;
|
|
17
|
+
/** Shows the sidebar toggle at the start of the bar. */
|
|
18
|
+
onToggleSidebar?: () => void;
|
|
19
|
+
/** Whether the sidebar is currently shown; drives the toggle's state. */
|
|
20
|
+
sidebarOpen?: boolean;
|
|
21
|
+
/** Shows the settings button at the end of the bar. */
|
|
22
|
+
onOpenSettings?: () => void;
|
|
23
|
+
/** Content above the bar, full width (e.g. a "Back to ..." strip). */
|
|
24
|
+
before?: ReactNode;
|
|
25
|
+
/** Identity still resolving: placeholders instead of a default name that
|
|
26
|
+
* would flash before the tenant's branding lands. */
|
|
27
|
+
isLoading?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const edgeButtonClass = cn(
|
|
31
|
+
'flex size-14 shrink-0 items-center justify-center border-ods-border text-ods-text-secondary',
|
|
32
|
+
'hover:text-ods-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus',
|
|
33
|
+
'[&_svg]:size-6',
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Top bar of the fae chat v2 layout (Figma `chat-top-navigation`): an optional
|
|
38
|
+
* sidebar toggle, the counterpart's avatar, name and role, the host's actions
|
|
39
|
+
* and an optional settings button, split by vertical dividers.
|
|
40
|
+
*/
|
|
41
|
+
const ChatTopNavigation = forwardRef<HTMLElement, ChatTopNavigationProps>(
|
|
42
|
+
(
|
|
43
|
+
{
|
|
44
|
+
className,
|
|
45
|
+
name,
|
|
46
|
+
subtitle,
|
|
47
|
+
avatarUrl,
|
|
48
|
+
actions,
|
|
49
|
+
onToggleSidebar,
|
|
50
|
+
sidebarOpen,
|
|
51
|
+
onOpenSettings,
|
|
52
|
+
before,
|
|
53
|
+
isLoading,
|
|
54
|
+
...props
|
|
55
|
+
},
|
|
56
|
+
ref,
|
|
57
|
+
) => (
|
|
58
|
+
<header ref={ref} className={cn('flex shrink-0 flex-col bg-ods-bg', className)} {...props}>
|
|
59
|
+
{before}
|
|
60
|
+
<div className="flex h-14 items-stretch border-b border-ods-border">
|
|
61
|
+
{onToggleSidebar && (
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={onToggleSidebar}
|
|
65
|
+
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
|
66
|
+
aria-expanded={sidebarOpen}
|
|
67
|
+
className={cn(edgeButtonClass, 'border-r')}
|
|
68
|
+
>
|
|
69
|
+
<SidebarIcon />
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
<div className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-m)] px-[var(--spacing-system-m)]">
|
|
73
|
+
{isLoading ? (
|
|
74
|
+
<>
|
|
75
|
+
<Skeleton className="size-10 shrink-0 rounded-full" />
|
|
76
|
+
<div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-xxs)]">
|
|
77
|
+
<Skeleton className="h-5 w-24" />
|
|
78
|
+
<Skeleton className="h-4 w-32" />
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
) : (
|
|
82
|
+
<>
|
|
83
|
+
<SquareAvatar src={avatarUrl ?? undefined} alt={name} fallback={name} size="md" variant="round" />
|
|
84
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
85
|
+
<p className="truncate text-ods-text-primary text-h3">{name}</p>
|
|
86
|
+
{subtitle && <p className="truncate text-ods-text-secondary text-h6">{subtitle}</p>}
|
|
87
|
+
</div>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
{actions && <div className="flex shrink-0 items-center gap-[var(--spacing-system-xs)]">{actions}</div>}
|
|
91
|
+
</div>
|
|
92
|
+
{onOpenSettings && (
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={onOpenSettings}
|
|
96
|
+
aria-label="Settings"
|
|
97
|
+
className={cn(edgeButtonClass, 'border-l')}
|
|
98
|
+
>
|
|
99
|
+
<Settings01Icon />
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
</header>
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
ChatTopNavigation.displayName = 'ChatTopNavigation';
|
|
108
|
+
|
|
109
|
+
export { ChatTopNavigation };
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
import { forwardRef, useState } from 'react';
|
|
4
4
|
import { cn } from '../../utils/cn';
|
|
5
5
|
import { AlertCircleIcon } from '../icons-v2-generated';
|
|
6
|
+
import { useChatAppearance } from './chat-appearance-context';
|
|
6
7
|
import { ExpandChevron } from './expand-chevron';
|
|
7
8
|
import { useCollapsible } from './hooks/use-collapsible';
|
|
8
9
|
import type { ErrorMessageDisplayProps } from './types';
|
|
10
|
+
import { CHAT_APPEARANCE } from './types/chat.types';
|
|
9
11
|
|
|
10
12
|
const iconTint = {
|
|
11
13
|
error: 'text-ods-error',
|
|
@@ -18,11 +20,20 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
|
|
|
18
20
|
const [expanded, setExpanded] = useState(false);
|
|
19
21
|
const { innerRef, containerStyle } = useCollapsible({ expanded });
|
|
20
22
|
const hasDetails = Boolean(details);
|
|
23
|
+
// v2 (fae chat `chat-info-block`): outlined on the page surface, the title
|
|
24
|
+
// always reads as a heading, and the row spacing comes from the thread.
|
|
25
|
+
const isV2 = useChatAppearance() === CHAT_APPEARANCE.V2;
|
|
21
26
|
|
|
22
27
|
return (
|
|
23
28
|
<div
|
|
24
29
|
ref={ref}
|
|
25
|
-
className={cn(
|
|
30
|
+
className={cn(
|
|
31
|
+
'rounded-md',
|
|
32
|
+
isV2
|
|
33
|
+
? 'border border-ods-border bg-ods-bg p-[var(--spacing-system-xs)]'
|
|
34
|
+
: 'mb-[var(--spacing-system-xsf)] bg-ods-card p-[var(--spacing-system-xsf)]',
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
26
37
|
{...props}
|
|
27
38
|
>
|
|
28
39
|
<button
|
|
@@ -40,7 +51,8 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
|
|
|
40
51
|
<span
|
|
41
52
|
className={cn(
|
|
42
53
|
'min-w-0 flex-1 text-h5',
|
|
43
|
-
expanded ? 'text-ods-text-primary' : '
|
|
54
|
+
expanded || isV2 ? 'text-ods-text-primary' : 'text-ods-text-secondary',
|
|
55
|
+
!expanded && 'truncate',
|
|
44
56
|
)}
|
|
45
57
|
>
|
|
46
58
|
{title}
|
|
@@ -50,7 +62,13 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
|
|
|
50
62
|
|
|
51
63
|
{hasDetails && (
|
|
52
64
|
<div style={containerStyle}>
|
|
53
|
-
<div
|
|
65
|
+
<div
|
|
66
|
+
ref={innerRef}
|
|
67
|
+
className={cn(
|
|
68
|
+
'px-[var(--spacing-system-lf)]',
|
|
69
|
+
isV2 ? 'pt-[var(--spacing-system-xs)]' : 'pt-[var(--spacing-system-xsf)]',
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
54
72
|
<p className="text-ods-text-primary text-h6">{details}</p>
|
|
55
73
|
</div>
|
|
56
74
|
</div>
|
|
@@ -6,7 +6,9 @@ export * from './approval-batch-message';
|
|
|
6
6
|
export * from './escalation-offer-message';
|
|
7
7
|
export * from './ticket-escalated-message';
|
|
8
8
|
export * from './ticket-event-message';
|
|
9
|
+
export * from './system-event-message';
|
|
9
10
|
export * from './ai-assistant-info';
|
|
11
|
+
export * from './chat-appearance-context';
|
|
10
12
|
export * from './context-compaction-display';
|
|
11
13
|
export * from './expand-chevron';
|
|
12
14
|
export * from './thinking-display';
|
|
@@ -47,6 +49,10 @@ export * from './context-items-list';
|
|
|
47
49
|
export * from './chat-archive-page';
|
|
48
50
|
export * from './model-display';
|
|
49
51
|
export * from './chat-sidebar';
|
|
52
|
+
export * from './chat-nav-sidebar';
|
|
53
|
+
export * from './chat-list-empty-state';
|
|
54
|
+
export * from './chat-top-navigation';
|
|
55
|
+
export * from './onboarding-carousel';
|
|
50
56
|
export type { ChatRef } from './chat-ref.types';
|
|
51
57
|
export { remarkCardLinks } from './remark-card-links';
|
|
52
58
|
export { remarkMentionChips } from './remark-mention-chips';
|
|
@@ -22,6 +22,12 @@ export interface MspOrganizationCardProps {
|
|
|
22
22
|
href?: string;
|
|
23
23
|
/** Click handler for the trailing action (used when `href` is not set). */
|
|
24
24
|
onOpenWebsite?: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* `card` (default): the welcome-screen card, title with the website beneath.
|
|
27
|
+
* `footer`: the compact sidebar footer of the fae chat v2 layout, a caption
|
|
28
|
+
* "Your IT is managed by" over the name, no website line, no card chrome.
|
|
29
|
+
*/
|
|
30
|
+
variant?: 'card' | 'footer';
|
|
25
31
|
/** Appended to the root element. */
|
|
26
32
|
className?: string;
|
|
27
33
|
}
|
|
@@ -44,10 +50,39 @@ export function MspOrganizationCard({
|
|
|
44
50
|
title,
|
|
45
51
|
href,
|
|
46
52
|
onOpenWebsite,
|
|
53
|
+
variant = 'card',
|
|
47
54
|
className,
|
|
48
55
|
}: MspOrganizationCardProps) {
|
|
49
56
|
const actionLabel = website ? `Open ${website}` : 'Open organization website';
|
|
50
57
|
|
|
58
|
+
if (variant === 'footer') {
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={cn(
|
|
62
|
+
'flex items-center gap-[var(--spacing-system-s)] px-[var(--spacing-system-m)] py-[var(--spacing-system-s)]',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<SquareAvatar src={logoUrl} alt={name} fallback={name} size="sm" variant="square" />
|
|
67
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
68
|
+
<span className="truncate text-ods-text-secondary text-h6">Your IT is managed by</span>
|
|
69
|
+
<span className="truncate text-ods-text-primary text-h4">{title ?? name}</span>
|
|
70
|
+
</div>
|
|
71
|
+
{href ? (
|
|
72
|
+
<Button asChild variant="outline" size="icon-sm" aria-label={actionLabel}>
|
|
73
|
+
<a href={href} target="_blank" rel="noreferrer noopener">
|
|
74
|
+
<ExternalLinkIcon />
|
|
75
|
+
</a>
|
|
76
|
+
</Button>
|
|
77
|
+
) : onOpenWebsite ? (
|
|
78
|
+
<Button variant="outline" size="icon-sm" aria-label={actionLabel} onClick={onOpenWebsite}>
|
|
79
|
+
<ExternalLinkIcon />
|
|
80
|
+
</Button>
|
|
81
|
+
) : null}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
51
86
|
return (
|
|
52
87
|
<div
|
|
53
88
|
className={cn(
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
|
|
7
|
+
export interface OnboardingCarouselStep {
|
|
8
|
+
id: string;
|
|
9
|
+
/** Glyph in the 64px tile above the title. Pass it sized to 28px. */
|
|
10
|
+
icon: ReactNode;
|
|
11
|
+
title: ReactNode;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OnboardingCarouselProps {
|
|
16
|
+
steps: OnboardingCarouselStep[];
|
|
17
|
+
/** The last step's primary action. */
|
|
18
|
+
onComplete: () => void;
|
|
19
|
+
/** Label of the last step's primary action. */
|
|
20
|
+
completeLabel?: string;
|
|
21
|
+
previousLabel?: string;
|
|
22
|
+
nextLabel?: string;
|
|
23
|
+
/** Accessible name of the carousel region. */
|
|
24
|
+
label?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Step-by-step introduction: one card at a time in the middle, its neighbours
|
|
30
|
+
* peeking in from the sides under an edge fade, dots below, Previous / Next
|
|
31
|
+
* underneath. The last step swaps Next for `completeLabel`. There is no skip:
|
|
32
|
+
* the design walks every step. Fills the height its parent gives it: the cards
|
|
33
|
+
* stretch, the dots and buttons keep their size.
|
|
34
|
+
*/
|
|
35
|
+
export function OnboardingCarousel({
|
|
36
|
+
steps,
|
|
37
|
+
onComplete,
|
|
38
|
+
completeLabel = 'Start Chat',
|
|
39
|
+
previousLabel = 'Previous Step',
|
|
40
|
+
nextLabel = 'Next Step',
|
|
41
|
+
label = 'Introduction',
|
|
42
|
+
className,
|
|
43
|
+
}: OnboardingCarouselProps) {
|
|
44
|
+
const [index, setIndex] = useState(0);
|
|
45
|
+
const lastIndex = steps.length - 1;
|
|
46
|
+
const isFirst = index === 0;
|
|
47
|
+
const isLast = index === lastIndex;
|
|
48
|
+
|
|
49
|
+
const goTo = (next: number) => setIndex(Math.min(Math.max(next, 0), lastIndex));
|
|
50
|
+
|
|
51
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
52
|
+
if (event.key === 'ArrowRight') goTo(index + 1);
|
|
53
|
+
else if (event.key === 'ArrowLeft') goTo(index - 1);
|
|
54
|
+
else return;
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Card width and the gap between cards drive both the card size and the
|
|
59
|
+
// track offset, so they live in one place. Percentages resolve against the
|
|
60
|
+
// viewport width in both uses (the track is as wide as the viewport).
|
|
61
|
+
const trackStyle = {
|
|
62
|
+
'--onboarding-card-w': 'min(600px, calc(100% - 2 * var(--spacing-system-xl)))',
|
|
63
|
+
'--onboarding-gap': 'var(--spacing-system-lf)',
|
|
64
|
+
transform: `translateX(calc(${-index} * (var(--onboarding-card-w) + var(--onboarding-gap))))`,
|
|
65
|
+
} as CSSProperties;
|
|
66
|
+
|
|
67
|
+
// The design's large steps (card padding, dots to buttons) are 80px at the
|
|
68
|
+
// app's 1024px default width, where `--spacing-system-xxl` is still 48px:
|
|
69
|
+
// spelled as twice the fixed 40px step.
|
|
70
|
+
return (
|
|
71
|
+
<section
|
|
72
|
+
aria-roledescription="carousel"
|
|
73
|
+
aria-label={label}
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex min-h-0 w-full flex-1 flex-col items-center gap-[calc(2*var(--spacing-system-xlf))]',
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<div className="flex min-h-0 w-full flex-1 flex-col items-center gap-[var(--spacing-system-xs)]">
|
|
81
|
+
<div className="relative flex min-h-0 w-full flex-1 overflow-hidden">
|
|
82
|
+
<div
|
|
83
|
+
className="flex w-full gap-[var(--onboarding-gap)] transition-transform duration-300 ease-out motion-reduce:transition-none"
|
|
84
|
+
style={trackStyle}
|
|
85
|
+
>
|
|
86
|
+
{steps.map((step, i) => {
|
|
87
|
+
const isCurrent = i === index;
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
key={step.id}
|
|
91
|
+
role="group"
|
|
92
|
+
aria-roledescription="slide"
|
|
93
|
+
aria-label={`${i + 1} of ${steps.length}`}
|
|
94
|
+
aria-hidden={!isCurrent}
|
|
95
|
+
inert={!isCurrent || undefined}
|
|
96
|
+
className={cn(
|
|
97
|
+
'flex w-[var(--onboarding-card-w)] shrink-0 flex-col items-center justify-center gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card px-[var(--spacing-system-xl)] py-[calc(2*var(--spacing-system-xlf))] text-center',
|
|
98
|
+
i === 0 && 'ml-[calc((100%-var(--onboarding-card-w))/2)]',
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<span className="flex size-16 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg text-ods-accent">
|
|
102
|
+
{step.icon}
|
|
103
|
+
</span>
|
|
104
|
+
<div className="flex flex-col gap-[var(--spacing-system-l)] text-ods-text-primary [word-break:break-word]">
|
|
105
|
+
<h2 className="text-h2">{step.title}</h2>
|
|
106
|
+
{step.description && <p className="whitespace-pre-line text-h6">{step.description}</p>}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</div>
|
|
112
|
+
<div className="pointer-events-none absolute inset-y-0 left-0 w-[120px] bg-gradient-to-r from-ods-bg to-transparent" />
|
|
113
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 w-[120px] bg-gradient-to-l from-ods-bg to-transparent" />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* The slides only toggle visibility, which is not announced. */}
|
|
117
|
+
<p className="sr-only" aria-live="polite">
|
|
118
|
+
Step {index + 1} of {steps.length}: {steps[index]?.title}
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
<div className="flex items-center" role="group" aria-label="Choose step">
|
|
122
|
+
{steps.map((step, i) => {
|
|
123
|
+
const isCurrent = i === index;
|
|
124
|
+
return (
|
|
125
|
+
<Button
|
|
126
|
+
key={step.id}
|
|
127
|
+
variant="transparent"
|
|
128
|
+
aria-current={isCurrent ? 'step' : undefined}
|
|
129
|
+
aria-label={`Go to step ${i + 1}`}
|
|
130
|
+
onClick={() => goTo(i)}
|
|
131
|
+
className="flex size-6 shrink-0 items-center justify-center rounded-full p-0 md:h-6"
|
|
132
|
+
>
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
'size-1.5 rounded-full transition-colors duration-150',
|
|
136
|
+
isCurrent ? 'bg-ods-accent' : 'bg-ods-text-secondary',
|
|
137
|
+
)}
|
|
138
|
+
/>
|
|
139
|
+
</Button>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="flex flex-wrap items-center justify-center gap-[var(--spacing-system-m)]">
|
|
146
|
+
{/* aria-disabled, not disabled: stepping back onto the first card must
|
|
147
|
+
not drop keyboard focus from the button the user just pressed. */}
|
|
148
|
+
<Button
|
|
149
|
+
variant="outline"
|
|
150
|
+
className="w-[200px]"
|
|
151
|
+
aria-disabled={isFirst || undefined}
|
|
152
|
+
onClick={() => goTo(index - 1)}
|
|
153
|
+
>
|
|
154
|
+
{previousLabel}
|
|
155
|
+
</Button>
|
|
156
|
+
<Button variant="accent" className="w-[200px]" onClick={isLast ? onComplete : () => goTo(index + 1)}>
|
|
157
|
+
{isLast ? completeLabel : nextLabel}
|
|
158
|
+
</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</section>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
4
|
+
import { InfoCircleIcon } from '../icons-v2-generated';
|
|
5
|
+
import { SquareAvatar } from '../ui/square-avatar';
|
|
6
|
+
import { AiAssistantInfo } from './ai-assistant-info';
|
|
7
|
+
|
|
8
|
+
// The chat backend announces a technician taking the chat over (or handing it
|
|
9
|
+
// back) as a plain system line: "{name} joined the chat" / "{name} left the
|
|
10
|
+
// chat". The copy is fixed server-side, so these two shapes are the contract.
|
|
11
|
+
const JOINED = /^(.+) joined the chat$/;
|
|
12
|
+
const LEFT = /^(.+) left the chat$/;
|
|
13
|
+
|
|
14
|
+
export interface SystemEventMessageProps extends HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
/** The system line as the backend sent it. */
|
|
16
|
+
text: string;
|
|
17
|
+
timestamp?: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A system line in the fae chat v2 thread (`ai-assistant-info`,
|
|
22
|
+
* type=direct-chat): a technician joining becomes "Technician Joined" with
|
|
23
|
+
* their initials; any other line keeps its own wording under an info icon.
|
|
24
|
+
*/
|
|
25
|
+
const SystemEventMessage = forwardRef<HTMLDivElement, SystemEventMessageProps>(({ text, timestamp, ...props }, ref) => {
|
|
26
|
+
const joined = JOINED.exec(text)?.[1];
|
|
27
|
+
if (joined) {
|
|
28
|
+
return (
|
|
29
|
+
<AiAssistantInfo
|
|
30
|
+
ref={ref}
|
|
31
|
+
leading={<SquareAvatar alt={joined} fallback={joined} size="lg" variant="round" className="shrink-0" />}
|
|
32
|
+
title="Technician Joined"
|
|
33
|
+
body={`You're now chatting with ${joined}.`}
|
|
34
|
+
timestamp={timestamp}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const left = LEFT.exec(text)?.[1];
|
|
40
|
+
return (
|
|
41
|
+
<AiAssistantInfo
|
|
42
|
+
ref={ref}
|
|
43
|
+
icon={<InfoCircleIcon className="size-6 text-ods-accent" />}
|
|
44
|
+
title={left ? 'Technician Left' : text}
|
|
45
|
+
body={left ? `${left} left the conversation.` : undefined}
|
|
46
|
+
timestamp={timestamp}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
SystemEventMessage.displayName = 'SystemEventMessage';
|
|
53
|
+
|
|
54
|
+
export { SystemEventMessage };
|
|
@@ -81,6 +81,21 @@ export const APPROVAL_BLOCK_VARIANT = {
|
|
|
81
81
|
|
|
82
82
|
export type ApprovalBlockVariant = (typeof APPROVAL_BLOCK_VARIANT)[keyof typeof APPROVAL_BLOCK_VARIANT];
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Visual generation of a chat thread. `v2` is the fae chat v2 design: author
|
|
86
|
+
* names without the trailing colon, a 24px assistant avatar, 16px between
|
|
87
|
+
* messages, and in-thread info/error blocks on the page surface. `classic`
|
|
88
|
+
* (default) is everything that predates it. The Fae client opts in first;
|
|
89
|
+
* Mingo and the admin views stay classic until their own redesign lands, then
|
|
90
|
+
* `classic` goes away.
|
|
91
|
+
*/
|
|
92
|
+
export const CHAT_APPEARANCE = {
|
|
93
|
+
CLASSIC: 'classic',
|
|
94
|
+
V2: 'v2',
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
export type ChatAppearance = (typeof CHAT_APPEARANCE)[keyof typeof CHAT_APPEARANCE];
|
|
98
|
+
|
|
84
99
|
// ========== Connection Status Definitions ==========
|
|
85
100
|
|
|
86
101
|
export const CONNECTION_STATUS = {
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ComponentType, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react';
|
|
6
|
+
import type { AvatarStackPerson } from '../../ui/avatar-stack';
|
|
6
7
|
import type { ChatRef } from '../chat-ref.types';
|
|
7
8
|
import type { MspOrganizationCardProps } from '../msp-organization-card';
|
|
8
9
|
import type {
|
|
9
10
|
ApprovalBlockVariant,
|
|
10
11
|
AssistantType,
|
|
11
12
|
AuthorType,
|
|
13
|
+
ChatAppearance,
|
|
12
14
|
ChatApprovalStatus,
|
|
13
15
|
ConnectionStatus,
|
|
14
16
|
} from './chat.types';
|
|
@@ -141,6 +143,9 @@ export interface ChatMessageEnhancedProps extends Omit<HTMLAttributes<HTMLDivEle
|
|
|
141
143
|
* (Fae desktop app) title-only card. Forwarded to
|
|
142
144
|
* ApprovalRequestMessage / ApprovalBatchMessage. */
|
|
143
145
|
approvalVariant?: ApprovalBlockVariant;
|
|
146
|
+
/** Visual generation of the row. Omit to inherit the thread's
|
|
147
|
+
* (`ChatMessageList.appearance`); `classic` outside a list. */
|
|
148
|
+
appearance?: ChatAppearance;
|
|
144
149
|
assistantIcon?: ReactNode;
|
|
145
150
|
avatar?: string | null;
|
|
146
151
|
timestamp?: Date;
|
|
@@ -288,6 +293,9 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
288
293
|
* `'client'` ONLY on true end-client surfaces — admin views of a Fae
|
|
289
294
|
* dialog (tickets dialog client tab) keep the default. */
|
|
290
295
|
approvalVariant?: ApprovalBlockVariant;
|
|
296
|
+
/** Visual generation of the whole thread, blocks included. Default
|
|
297
|
+
* `classic`; the Fae client passes `v2`. See `ChatAppearance`. */
|
|
298
|
+
appearance?: ChatAppearance;
|
|
291
299
|
assistantIcon?: ReactNode;
|
|
292
300
|
pendingApprovals?: MessageSegment[];
|
|
293
301
|
onApprove?: ApprovalResolutionHandler;
|
|
@@ -442,7 +450,19 @@ export interface ChatInputProps extends Omit<TextareaHTMLAttributes<HTMLTextArea
|
|
|
442
450
|
onSend?: (message: string) => void | boolean | Promise<boolean | void>;
|
|
443
451
|
onStop?: () => void | Promise<void>;
|
|
444
452
|
sending?: boolean;
|
|
453
|
+
/** Replaces the editor with the hand-off bar: the conversation is with the
|
|
454
|
+
* technicians now, and the client waits for one of them to reply. */
|
|
445
455
|
awaitingResponse?: boolean;
|
|
456
|
+
/** Faces shown in the v2 hand-off bar (the tenant's technicians). Omit or
|
|
457
|
+
* pass an empty list for a text-only bar. */
|
|
458
|
+
awaitingTeam?: AvatarStackPerson[];
|
|
459
|
+
/** Size of the whole team when `awaitingTeam` is only a sample of it: the
|
|
460
|
+
* bar shows those faces and "+N" for the rest. */
|
|
461
|
+
awaitingTeamCount?: number;
|
|
462
|
+
/** `v2`: the fae chat v2 hand-off bar ("Handed off to your technical
|
|
463
|
+
* support team" + faces). `classic` (default): "Waiting for Technician
|
|
464
|
+
* Response". Only the awaiting state differs. */
|
|
465
|
+
appearance?: ChatAppearance;
|
|
446
466
|
/** Same `fullWidth` semantics as `ChatHeaderProps.fullWidth` — drops
|
|
447
467
|
* the default `max-w-ods-content-narrow` so the input fills the
|
|
448
468
|
* parent. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { AvatarStack } from '../avatar-stack';
|
|
4
|
+
|
|
5
|
+
const people = [{ name: 'Ada Lin' }, { name: 'Roman Smith' }, { name: 'Michael Johnson' }];
|
|
6
|
+
|
|
7
|
+
describe('AvatarStack', () => {
|
|
8
|
+
it('counts the overflow from the people it gets', () => {
|
|
9
|
+
render(<AvatarStack people={[...people, { name: 'Kim Park' }]} max={3} label="Technicians" />);
|
|
10
|
+
expect(screen.getByText('+1')).toHaveAttribute('title', 'Kim Park');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('counts everyone not shown when the people are a sample of a larger group', () => {
|
|
14
|
+
render(<AvatarStack people={people} total={7} max={3} label="Technicians" />);
|
|
15
|
+
expect(screen.getByText('+4')).toHaveAttribute('title', '4 more');
|
|
16
|
+
expect(screen.getByRole('group')).toHaveAccessibleName(
|
|
17
|
+
'Technicians: Ada Lin, Roman Smith, Michael Johnson and 4 more',
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('shows no overflow when the sample is the whole group', () => {
|
|
22
|
+
render(<AvatarStack people={people} total={3} max={3} label="Technicians" />);
|
|
23
|
+
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { render, fireEvent, screen } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
3
|
-
import { mockReplace, setMockSearchParams } from '../../../../vitest.setup';
|
|
4
|
-
import { TabNavigation, type TabItem } from '../tab-navigation';
|
|
3
|
+
import { mockPush, mockReplace, setMockSearchParams } from '../../../../vitest.setup';
|
|
4
|
+
import { TabNavigation, type TabItem, type TabNavigationUrlSyncOptions } from '../tab-navigation';
|
|
5
5
|
|
|
6
6
|
beforeAll(() => {
|
|
7
7
|
// jsdom ships no ResizeObserver; the tab strip observes itself on mount to
|
|
@@ -156,6 +156,60 @@ describe('TabNavigation with urlSync', () => {
|
|
|
156
156
|
expect(nav.active).toBe('general');
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
|
+
|
|
160
|
+
describe('with sync options', () => {
|
|
161
|
+
function renderWith(options: TabNavigationUrlSyncOptions) {
|
|
162
|
+
let seen = '__never-rendered__';
|
|
163
|
+
render(
|
|
164
|
+
<TabNavigation tabs={TABS} urlSync={options}>
|
|
165
|
+
{active => {
|
|
166
|
+
seen = active;
|
|
167
|
+
return null;
|
|
168
|
+
}}
|
|
169
|
+
</TabNavigation>,
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
get active() {
|
|
173
|
+
return seen;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it('reads and writes the param it was given, leaving `tab` alone', () => {
|
|
179
|
+
setMockSearchParams(new URLSearchParams('tab=general&view=software'));
|
|
180
|
+
const nav = renderWith({ paramName: 'view' });
|
|
181
|
+
expect(nav.active).toBe('software');
|
|
182
|
+
|
|
183
|
+
fireEvent.click(screen.getByText('General'));
|
|
184
|
+
expect(mockReplace).toHaveBeenCalledWith('/?tab=general&view=general', { scroll: false });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('pushes a history entry when `replaceState` is off', () => {
|
|
188
|
+
setMockSearchParams(new URLSearchParams('tab=general'));
|
|
189
|
+
renderWith({ replaceState: false });
|
|
190
|
+
fireEvent.click(screen.getByText('Software'));
|
|
191
|
+
|
|
192
|
+
expect(mockPush).toHaveBeenCalledWith('/?tab=software', { scroll: false });
|
|
193
|
+
expect(mockReplace).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('a tab with a badge', () => {
|
|
198
|
+
it('draws the stamp after the label and names the tab with it', () => {
|
|
199
|
+
setMockSearchParams(new URLSearchParams('tab=general'));
|
|
200
|
+
render(
|
|
201
|
+
<TabNavigation tabs={[...TABS, { id: 'logs', label: 'Device Logs', badge: 'Beta' }]} urlSync>
|
|
202
|
+
{() => null}
|
|
203
|
+
</TabNavigation>,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const tab = screen.getByRole('button', { name: 'Device Logs (Beta)' });
|
|
207
|
+
expect(tab.textContent).toBe('Device LogsBeta');
|
|
208
|
+
// Tabs without a badge keep their label as the accessible name.
|
|
209
|
+
expect(screen.getByRole('button', { name: 'General' }).textContent).toBe('General');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
159
213
|
describe('the underline', () => {
|
|
160
214
|
it('is sized in real pixels rather than a scaled 1px bar', () => {
|
|
161
215
|
// Regression guard. The underline used to be `w-px` stretched with
|