panelui-native 0.101.1 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/module/components/sankey-chart/index.js +801 -165
- package/lib/module/components/sankey-chart/index.js.map +1 -1
- package/lib/module/components/sankey-chart/sankey-layout.js +226 -0
- package/lib/module/components/sankey-chart/sankey-layout.js.map +1 -1
- package/lib/module/components/spinner/index.js +13 -3
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/support/index.js +842 -0
- package/lib/module/components/support/index.js.map +1 -0
- package/lib/module/icons/index.js +40 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/chart.js +24 -0
- package/lib/module/utils/chart.js.map +1 -1
- package/lib/typescript/src/components/sankey-chart/index.d.ts +131 -5
- package/lib/typescript/src/components/sankey-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts +49 -6
- package/lib/typescript/src/components/sankey-chart/sankey-layout.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +18 -1
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/support/index.d.ts +376 -0
- package/lib/typescript/src/components/support/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +8 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/chart.d.ts +11 -0
- package/lib/typescript/src/utils/chart.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/sankey-chart/index.tsx +935 -172
- package/src/components/sankey-chart/sankey-layout.ts +276 -2
- package/src/components/spinner/index.tsx +27 -1
- package/src/components/support/index.tsx +877 -0
- package/src/icons/index.tsx +16 -0
- package/src/index.ts +35 -0
- package/src/utils/chart.ts +40 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support — the screen a person arrives on when they need help.
|
|
3
|
+
*
|
|
4
|
+
* It is the front of a support section, not the conversation itself. What a
|
|
5
|
+
* reader needs here is four answers, in this order: is anyone there, how do I
|
|
6
|
+
* reach them, what is this about, and what happened to the thing I already
|
|
7
|
+
* asked. Each is a part, because an app that has no help centre should not
|
|
8
|
+
* have to pass `articles={null}` to say so.
|
|
9
|
+
*
|
|
10
|
+
* The transcript is deliberately somewhere else. A support thread is a chat
|
|
11
|
+
* transcript with a name on it, and this library already has the pieces —
|
|
12
|
+
* compose `MessageScroller`, `Message` and `AIInput` behind whichever channel
|
|
13
|
+
* the reader picks, rather than growing a second set of bubbles in here that
|
|
14
|
+
* would drift away from the first.
|
|
15
|
+
*
|
|
16
|
+
* `availability` is the one thing the root decides, and everything downstream
|
|
17
|
+
* reads it from context: the dot beside the status line, the wording it falls
|
|
18
|
+
* back to, and whether a channel that needs someone at the other end presents
|
|
19
|
+
* itself as open.
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <Support availability="online">
|
|
23
|
+
* <Support.Header title="Support" />
|
|
24
|
+
* <Support.Status replyTime="Typically replies in under an hour" />
|
|
25
|
+
* <Support.Channels>
|
|
26
|
+
* <Support.Channel icon={<MessageCircleIcon />} label="Message us" />
|
|
27
|
+
* </Support.Channels>
|
|
28
|
+
* </Support>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
createContext,
|
|
33
|
+
forwardRef,
|
|
34
|
+
useContext,
|
|
35
|
+
useMemo,
|
|
36
|
+
useState,
|
|
37
|
+
type ReactNode,
|
|
38
|
+
} from 'react';
|
|
39
|
+
import { View, type ViewProps } from 'react-native';
|
|
40
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
41
|
+
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
42
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
43
|
+
import { cn } from '../../utils/cn';
|
|
44
|
+
|
|
45
|
+
/** Whether anyone is at the other end. */
|
|
46
|
+
export type SupportAvailability = 'online' | 'away' | 'offline';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where a request has got to, in the words the person who made it sees.
|
|
50
|
+
*
|
|
51
|
+
* Shorter than the set a support desk runs internally, and deliberately: the
|
|
52
|
+
* states a helpdesk distinguishes for its own routing are not distinctions the
|
|
53
|
+
* person waiting can act on, and offering them invites a question about the
|
|
54
|
+
* difference that nobody wants to answer.
|
|
55
|
+
*/
|
|
56
|
+
export type SupportConversationStatus = 'open' | 'pending' | 'solved' | 'closed';
|
|
57
|
+
|
|
58
|
+
const supportVariants = tv({
|
|
59
|
+
slots: {
|
|
60
|
+
root: 'w-full gap-5',
|
|
61
|
+
header: 'w-full flex-row items-center justify-between gap-3',
|
|
62
|
+
headerText: 'shrink gap-0.5',
|
|
63
|
+
status: 'w-full flex-row items-center gap-2 rounded-xl bg-muted px-3 py-2.5',
|
|
64
|
+
statusDot: 'size-2 rounded-full',
|
|
65
|
+
group: 'w-full gap-2',
|
|
66
|
+
groupTitle: 'text-xs font-medium text-muted-foreground',
|
|
67
|
+
channel:
|
|
68
|
+
'w-full flex-row items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-3',
|
|
69
|
+
channelIcon: 'size-9 items-center justify-center rounded-lg bg-muted',
|
|
70
|
+
channelText: 'shrink grow gap-0.5',
|
|
71
|
+
topic: 'w-full flex-row items-center gap-3 rounded-xl border px-3.5 py-3',
|
|
72
|
+
topicText: 'shrink grow gap-0.5',
|
|
73
|
+
conversation:
|
|
74
|
+
'w-full flex-row items-start gap-3 rounded-xl border border-border bg-card px-3.5 py-3',
|
|
75
|
+
article: 'w-full flex-row items-center gap-3 rounded-xl bg-muted px-3.5 py-3',
|
|
76
|
+
badge: 'rounded-full px-2 py-0.5',
|
|
77
|
+
agent: 'w-full flex-row items-center gap-3',
|
|
78
|
+
ticket: 'w-full flex-row items-center justify-between gap-2 rounded-lg bg-muted px-3 py-2',
|
|
79
|
+
note: 'w-full text-center',
|
|
80
|
+
handoff: 'w-full flex-row items-center gap-2 rounded-xl bg-muted px-3 py-2.5',
|
|
81
|
+
replies: 'w-full flex-row flex-wrap items-center gap-2',
|
|
82
|
+
reply: 'rounded-full border border-border bg-card px-3 py-1.5',
|
|
83
|
+
resolution: 'w-full items-center gap-2 rounded-xl border border-border bg-card px-3.5 py-3.5',
|
|
84
|
+
},
|
|
85
|
+
variants: {
|
|
86
|
+
availability: {
|
|
87
|
+
online: { statusDot: 'bg-success' },
|
|
88
|
+
away: { statusDot: 'bg-warning' },
|
|
89
|
+
offline: { statusDot: 'bg-muted-foreground' },
|
|
90
|
+
},
|
|
91
|
+
selected: {
|
|
92
|
+
true: { topic: 'border-primary bg-primary/10' },
|
|
93
|
+
false: { topic: 'border-border bg-card' },
|
|
94
|
+
},
|
|
95
|
+
disabled: {
|
|
96
|
+
true: { channel: 'opacity-50', topic: 'opacity-50' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
defaultVariants: {
|
|
100
|
+
availability: 'online',
|
|
101
|
+
selected: false,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** What a status badge says and how it is coloured, per state. */
|
|
106
|
+
const STATUS_STYLE: Record<
|
|
107
|
+
SupportConversationStatus,
|
|
108
|
+
{ label: string; className: string; textClassName: string }
|
|
109
|
+
> = {
|
|
110
|
+
open: { label: 'Open', className: 'bg-info/15', textClassName: 'text-info' },
|
|
111
|
+
pending: { label: 'Waiting on you', className: 'bg-warning/15', textClassName: 'text-warning' },
|
|
112
|
+
solved: { label: 'Solved', className: 'bg-success/15', textClassName: 'text-success' },
|
|
113
|
+
closed: { label: 'Closed', className: 'bg-muted', textClassName: 'text-muted-foreground' },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
interface SupportContextValue {
|
|
117
|
+
availability: SupportAvailability;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SupportContext = createContext<SupportContextValue>({ availability: 'online' });
|
|
121
|
+
|
|
122
|
+
/** The availability set by the nearest Support. */
|
|
123
|
+
export function useSupport(): SupportContextValue {
|
|
124
|
+
return useContext(SupportContext);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface TopicsContextValue {
|
|
128
|
+
value: string | null;
|
|
129
|
+
select: (value: string) => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const TopicsContext = createContext<TopicsContextValue | null>(null);
|
|
133
|
+
|
|
134
|
+
export interface SupportProps extends ViewProps {
|
|
135
|
+
className?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Whether anyone is at the other end. Read by every part below rather than
|
|
138
|
+
* passed to each — a screen that is half "we're here" and half "we're not"
|
|
139
|
+
* is worse than either.
|
|
140
|
+
*/
|
|
141
|
+
availability?: SupportAvailability;
|
|
142
|
+
children?: ReactNode;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const SupportRoot = forwardRef<View, SupportProps>(function SupportRoot(
|
|
146
|
+
{ className, availability = 'online', children, ...props },
|
|
147
|
+
ref
|
|
148
|
+
) {
|
|
149
|
+
const { root } = supportVariants();
|
|
150
|
+
const context = useMemo(() => ({ availability }), [availability]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<SupportContext.Provider value={context}>
|
|
154
|
+
<View ref={ref} className={root({ className })} {...props}>
|
|
155
|
+
{textChildren(children)}
|
|
156
|
+
</View>
|
|
157
|
+
</SupportContext.Provider>
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
SupportRoot.displayName = 'Support';
|
|
161
|
+
|
|
162
|
+
export interface SupportHeaderProps extends ViewProps {
|
|
163
|
+
className?: string;
|
|
164
|
+
/** The screen's own name. */
|
|
165
|
+
title?: string;
|
|
166
|
+
/** One line under it — who answers, or what this section covers. */
|
|
167
|
+
description?: string;
|
|
168
|
+
/** Trailing slot: the faces of the people who answer, or a close button. */
|
|
169
|
+
children?: ReactNode;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The screen's title, and room beside it for whoever is answering. */
|
|
173
|
+
const SupportHeader = forwardRef<View, SupportHeaderProps>(function SupportHeader(
|
|
174
|
+
{ className, title, description, children, ...props },
|
|
175
|
+
ref
|
|
176
|
+
) {
|
|
177
|
+
const { header, headerText } = supportVariants();
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<View ref={ref} className={header({ className })} {...props}>
|
|
181
|
+
<View className={headerText()}>
|
|
182
|
+
{title ? (
|
|
183
|
+
<Text size="xl" weight="bold" numberOfLines={1}>
|
|
184
|
+
{title}
|
|
185
|
+
</Text>
|
|
186
|
+
) : null}
|
|
187
|
+
{description ? (
|
|
188
|
+
<Text size="sm" muted numberOfLines={2}>
|
|
189
|
+
{description}
|
|
190
|
+
</Text>
|
|
191
|
+
) : null}
|
|
192
|
+
</View>
|
|
193
|
+
{children ? <View className="shrink-0">{textChildren(children)}</View> : null}
|
|
194
|
+
</View>
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
SupportHeader.displayName = 'Support.Header';
|
|
198
|
+
|
|
199
|
+
export interface SupportStatusProps extends ViewProps {
|
|
200
|
+
className?: string;
|
|
201
|
+
/** Overrides the availability the root published. */
|
|
202
|
+
availability?: SupportAvailability;
|
|
203
|
+
/**
|
|
204
|
+
* How long a reply takes, in your own words — "Typically replies in under an
|
|
205
|
+
* hour", "Back on Monday". Left unset, the availability alone is stated.
|
|
206
|
+
*
|
|
207
|
+
* A string rather than a number of minutes, because the honest version of
|
|
208
|
+
* this is a median somebody measured and the rounding is a judgement about
|
|
209
|
+
* tone: "a few minutes" and "4 minutes" promise different things.
|
|
210
|
+
*/
|
|
211
|
+
replyTime?: string;
|
|
212
|
+
children?: ReactNode;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Whether anyone is there, and how long an answer takes. */
|
|
216
|
+
const SupportStatus = forwardRef<View, SupportStatusProps>(function SupportStatus(
|
|
217
|
+
{ className, availability: availabilityProp, replyTime, children, ...props },
|
|
218
|
+
ref
|
|
219
|
+
) {
|
|
220
|
+
const { availability: inherited } = useSupport();
|
|
221
|
+
const availability = availabilityProp ?? inherited;
|
|
222
|
+
const { status, statusDot } = supportVariants({ availability });
|
|
223
|
+
|
|
224
|
+
const fallback =
|
|
225
|
+
availability === 'online'
|
|
226
|
+
? 'Someone is here now'
|
|
227
|
+
: availability === 'away'
|
|
228
|
+
? 'Away at the moment'
|
|
229
|
+
: 'Nobody is here right now';
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<View ref={ref} className={status({ className })} {...props}>
|
|
233
|
+
<View className={statusDot()} />
|
|
234
|
+
{children ? (
|
|
235
|
+
textChildren(children)
|
|
236
|
+
) : (
|
|
237
|
+
<Text size="sm" numberOfLines={2} className="shrink">
|
|
238
|
+
{replyTime ?? fallback}
|
|
239
|
+
</Text>
|
|
240
|
+
)}
|
|
241
|
+
</View>
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
SupportStatus.displayName = 'Support.Status';
|
|
245
|
+
|
|
246
|
+
export interface SupportGroupProps extends ViewProps {
|
|
247
|
+
className?: string;
|
|
248
|
+
/** Small heading above the rows. */
|
|
249
|
+
title?: string;
|
|
250
|
+
children?: ReactNode;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A titled run of rows. Shared by Channels, Conversations and Articles. */
|
|
254
|
+
function group(name: string) {
|
|
255
|
+
const Group = forwardRef<View, SupportGroupProps>(function Group(
|
|
256
|
+
{ className, title, children, ...props },
|
|
257
|
+
ref
|
|
258
|
+
) {
|
|
259
|
+
const { group: groupClass, groupTitle } = supportVariants();
|
|
260
|
+
return (
|
|
261
|
+
<View ref={ref} className={groupClass({ className })} {...props}>
|
|
262
|
+
{title ? <Text className={groupTitle()}>{title}</Text> : null}
|
|
263
|
+
{textChildren(children)}
|
|
264
|
+
</View>
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
Group.displayName = name;
|
|
268
|
+
return Group;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SupportChannels = group('Support.Channels');
|
|
272
|
+
const SupportConversations = group('Support.Conversations');
|
|
273
|
+
const SupportArticles = group('Support.Articles');
|
|
274
|
+
|
|
275
|
+
export interface SupportChannelProps extends Omit<ViewProps, 'children'> {
|
|
276
|
+
className?: string;
|
|
277
|
+
/** The glyph on the left. Tinted to match the row. */
|
|
278
|
+
icon?: ReactNode;
|
|
279
|
+
/** What the channel is called — the thing pressed, so write it as an action. */
|
|
280
|
+
label: string;
|
|
281
|
+
/** One line under it: what it is for, or the address behind it. */
|
|
282
|
+
description?: string;
|
|
283
|
+
/** Trailing note — a wait time, an opening hour. */
|
|
284
|
+
detail?: string;
|
|
285
|
+
disabled?: boolean;
|
|
286
|
+
onPress?: () => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One way of reaching somebody.
|
|
291
|
+
*
|
|
292
|
+
* A row rather than a tile. Each channel is a sentence — what it is, and what
|
|
293
|
+
* it costs you in waiting — and a grid of icons with one word under each puts
|
|
294
|
+
* the reader in front of a quiz about which one is right.
|
|
295
|
+
*/
|
|
296
|
+
const SupportChannel = forwardRef<View, SupportChannelProps>(function SupportChannel(
|
|
297
|
+
{ className, icon, label, description, detail, disabled = false, onPress, ...props },
|
|
298
|
+
ref
|
|
299
|
+
) {
|
|
300
|
+
const { channel, channelIcon, channelText } = supportVariants({ disabled });
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<AnimatedPressable
|
|
304
|
+
ref={ref}
|
|
305
|
+
accessibilityRole="button"
|
|
306
|
+
accessibilityState={{ disabled }}
|
|
307
|
+
accessibilityLabel={detail ? `${label}, ${detail}` : label}
|
|
308
|
+
accessibilityHint={description}
|
|
309
|
+
disabled={disabled || !onPress}
|
|
310
|
+
onPress={onPress}
|
|
311
|
+
className={channel({ className })}
|
|
312
|
+
{...props}
|
|
313
|
+
>
|
|
314
|
+
{icon ? (
|
|
315
|
+
<View className={channelIcon()}>{icon}</View>
|
|
316
|
+
) : null}
|
|
317
|
+
<View className={channelText()}>
|
|
318
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
319
|
+
{label}
|
|
320
|
+
</Text>
|
|
321
|
+
{description ? (
|
|
322
|
+
<Text size="xs" muted numberOfLines={2}>
|
|
323
|
+
{description}
|
|
324
|
+
</Text>
|
|
325
|
+
) : null}
|
|
326
|
+
</View>
|
|
327
|
+
{detail ? (
|
|
328
|
+
<Text size="xs" muted numberOfLines={1} className="shrink-0">
|
|
329
|
+
{detail}
|
|
330
|
+
</Text>
|
|
331
|
+
) : null}
|
|
332
|
+
</AnimatedPressable>
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
SupportChannel.displayName = 'Support.Channel';
|
|
336
|
+
|
|
337
|
+
export interface SupportTopicsProps extends ViewProps {
|
|
338
|
+
className?: string;
|
|
339
|
+
/** Small heading above the list. */
|
|
340
|
+
title?: string;
|
|
341
|
+
/** The selected topic. Leave unset to let the group track it. */
|
|
342
|
+
value?: string | null;
|
|
343
|
+
/** The topic selected before anybody pressed anything. */
|
|
344
|
+
defaultValue?: string | null;
|
|
345
|
+
onValueChange?: (value: string) => void;
|
|
346
|
+
children?: ReactNode;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What the problem is about, as a list of one-line choices.
|
|
351
|
+
*
|
|
352
|
+
* Optional, and meant to stay that way — plenty of support screens are better
|
|
353
|
+
* without one, and the ones that need it are the ones routing to different
|
|
354
|
+
* teams. Single-select: a request goes to one queue.
|
|
355
|
+
*/
|
|
356
|
+
const SupportTopics = forwardRef<View, SupportTopicsProps>(function SupportTopics(
|
|
357
|
+
{ className, title, value: valueProp, defaultValue = null, onValueChange, children, ...props },
|
|
358
|
+
ref
|
|
359
|
+
) {
|
|
360
|
+
const { group: groupClass, groupTitle } = supportVariants();
|
|
361
|
+
const [internal, setInternal] = useState<string | null>(defaultValue);
|
|
362
|
+
const controlled = valueProp !== undefined;
|
|
363
|
+
const value = controlled ? valueProp : internal;
|
|
364
|
+
|
|
365
|
+
const context = useMemo<TopicsContextValue>(
|
|
366
|
+
() => ({
|
|
367
|
+
value: value ?? null,
|
|
368
|
+
select: (next: string) => {
|
|
369
|
+
if (!controlled) setInternal(next);
|
|
370
|
+
onValueChange?.(next);
|
|
371
|
+
},
|
|
372
|
+
}),
|
|
373
|
+
[value, controlled, onValueChange]
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<TopicsContext.Provider value={context}>
|
|
378
|
+
<View
|
|
379
|
+
ref={ref}
|
|
380
|
+
accessibilityRole="radiogroup"
|
|
381
|
+
className={groupClass({ className })}
|
|
382
|
+
{...props}
|
|
383
|
+
>
|
|
384
|
+
{title ? <Text className={groupTitle()}>{title}</Text> : null}
|
|
385
|
+
{textChildren(children)}
|
|
386
|
+
</View>
|
|
387
|
+
</TopicsContext.Provider>
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
SupportTopics.displayName = 'Support.Topics';
|
|
391
|
+
|
|
392
|
+
export interface SupportTopicProps extends Omit<ViewProps, 'children'> {
|
|
393
|
+
className?: string;
|
|
394
|
+
/** What this topic is worth to the group's `value`. */
|
|
395
|
+
value: string;
|
|
396
|
+
icon?: ReactNode;
|
|
397
|
+
label: string;
|
|
398
|
+
/** One line of what belongs under it, for the ones whose name is not enough. */
|
|
399
|
+
description?: string;
|
|
400
|
+
disabled?: boolean;
|
|
401
|
+
onPress?: () => void;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** One thing the problem could be about. */
|
|
405
|
+
const SupportTopic = forwardRef<View, SupportTopicProps>(function SupportTopic(
|
|
406
|
+
{ className, value, icon, label, description, disabled = false, onPress, ...props },
|
|
407
|
+
ref
|
|
408
|
+
) {
|
|
409
|
+
const group = useContext(TopicsContext);
|
|
410
|
+
const selected = group?.value === value;
|
|
411
|
+
const { topic, topicText, channelIcon } = supportVariants({ selected, disabled });
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<AnimatedPressable
|
|
415
|
+
ref={ref}
|
|
416
|
+
accessibilityRole="radio"
|
|
417
|
+
accessibilityState={{ selected, disabled }}
|
|
418
|
+
accessibilityLabel={label}
|
|
419
|
+
accessibilityHint={description}
|
|
420
|
+
disabled={disabled}
|
|
421
|
+
onPress={() => {
|
|
422
|
+
group?.select(value);
|
|
423
|
+
onPress?.();
|
|
424
|
+
}}
|
|
425
|
+
className={topic({ className })}
|
|
426
|
+
{...props}
|
|
427
|
+
>
|
|
428
|
+
{icon ? <View className={channelIcon()}>{icon}</View> : null}
|
|
429
|
+
<View className={topicText()}>
|
|
430
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
431
|
+
{label}
|
|
432
|
+
</Text>
|
|
433
|
+
{description ? (
|
|
434
|
+
<Text size="xs" muted numberOfLines={2}>
|
|
435
|
+
{description}
|
|
436
|
+
</Text>
|
|
437
|
+
) : null}
|
|
438
|
+
</View>
|
|
439
|
+
</AnimatedPressable>
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
SupportTopic.displayName = 'Support.Topic';
|
|
443
|
+
|
|
444
|
+
export interface SupportConversationProps extends Omit<ViewProps, 'children'> {
|
|
445
|
+
className?: string;
|
|
446
|
+
/** What it was about. */
|
|
447
|
+
title: string;
|
|
448
|
+
/** The last thing said in it. */
|
|
449
|
+
preview?: string;
|
|
450
|
+
/** Where it has got to. */
|
|
451
|
+
status?: SupportConversationStatus;
|
|
452
|
+
/** When it last moved, in your own words — "2h", "Yesterday". */
|
|
453
|
+
timestamp?: string;
|
|
454
|
+
/**
|
|
455
|
+
* There is a reply here the reader has not seen.
|
|
456
|
+
*
|
|
457
|
+
* Separate from `updated`, because they are different facts: a thread can
|
|
458
|
+
* move without anything being addressed to you, and a dot that means both
|
|
459
|
+
* means neither.
|
|
460
|
+
*/
|
|
461
|
+
unread?: boolean;
|
|
462
|
+
/** The thread moved since it was last opened, with nothing new to read. */
|
|
463
|
+
updated?: boolean;
|
|
464
|
+
/** Reference for the request, shown beside the status. */
|
|
465
|
+
reference?: string;
|
|
466
|
+
onPress?: () => void;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One request the reader already made, and what has happened to it. */
|
|
470
|
+
const SupportConversation = forwardRef<View, SupportConversationProps>(
|
|
471
|
+
function SupportConversation(
|
|
472
|
+
{
|
|
473
|
+
className,
|
|
474
|
+
title,
|
|
475
|
+
preview,
|
|
476
|
+
status = 'open',
|
|
477
|
+
timestamp,
|
|
478
|
+
unread = false,
|
|
479
|
+
updated = false,
|
|
480
|
+
reference,
|
|
481
|
+
onPress,
|
|
482
|
+
...props
|
|
483
|
+
},
|
|
484
|
+
ref
|
|
485
|
+
) {
|
|
486
|
+
const { conversation, badge } = supportVariants();
|
|
487
|
+
const tone = STATUS_STYLE[status];
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<AnimatedPressable
|
|
491
|
+
ref={ref}
|
|
492
|
+
accessibilityRole="button"
|
|
493
|
+
accessibilityLabel={[
|
|
494
|
+
title,
|
|
495
|
+
tone.label,
|
|
496
|
+
unread ? 'unread' : updated ? 'updated' : null,
|
|
497
|
+
timestamp,
|
|
498
|
+
]
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.join(', ')}
|
|
501
|
+
disabled={!onPress}
|
|
502
|
+
onPress={onPress}
|
|
503
|
+
className={conversation({ className })}
|
|
504
|
+
{...props}
|
|
505
|
+
>
|
|
506
|
+
{/*
|
|
507
|
+
* The dot is at the top rather than centred, so it stays level with
|
|
508
|
+
* the subject on a row whose preview runs to two lines.
|
|
509
|
+
*/}
|
|
510
|
+
{unread || updated ? (
|
|
511
|
+
<View
|
|
512
|
+
className={cn(
|
|
513
|
+
'mt-1.5 size-2 shrink-0 rounded-full',
|
|
514
|
+
unread ? 'bg-primary' : 'bg-muted-foreground'
|
|
515
|
+
)}
|
|
516
|
+
/>
|
|
517
|
+
) : null}
|
|
518
|
+
<View className="shrink grow gap-1">
|
|
519
|
+
<View className="flex-row items-baseline gap-2">
|
|
520
|
+
<Text size="sm" weight={unread ? 'semibold' : 'medium'} numberOfLines={1} className="shrink grow">
|
|
521
|
+
{title}
|
|
522
|
+
</Text>
|
|
523
|
+
{timestamp ? (
|
|
524
|
+
<Text size="xs" muted numberOfLines={1} className="shrink-0">
|
|
525
|
+
{timestamp}
|
|
526
|
+
</Text>
|
|
527
|
+
) : null}
|
|
528
|
+
</View>
|
|
529
|
+
{preview ? (
|
|
530
|
+
<Text size="xs" muted numberOfLines={2}>
|
|
531
|
+
{preview}
|
|
532
|
+
</Text>
|
|
533
|
+
) : null}
|
|
534
|
+
<View className="flex-row items-center gap-2 pt-0.5">
|
|
535
|
+
<View className={badge({ className: tone.className })}>
|
|
536
|
+
<Text size="xs" weight="medium" className={tone.textClassName}>
|
|
537
|
+
{tone.label}
|
|
538
|
+
</Text>
|
|
539
|
+
</View>
|
|
540
|
+
{reference ? (
|
|
541
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
542
|
+
{reference}
|
|
543
|
+
</Text>
|
|
544
|
+
) : null}
|
|
545
|
+
</View>
|
|
546
|
+
</View>
|
|
547
|
+
</AnimatedPressable>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
SupportConversation.displayName = 'Support.Conversation';
|
|
552
|
+
|
|
553
|
+
export interface SupportArticleProps extends Omit<ViewProps, 'children'> {
|
|
554
|
+
className?: string;
|
|
555
|
+
icon?: ReactNode;
|
|
556
|
+
/** The article's own title. */
|
|
557
|
+
title: string;
|
|
558
|
+
/** Which part of the help centre it came from. */
|
|
559
|
+
collection?: string;
|
|
560
|
+
onPress?: () => void;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** A page from the help centre, for the questions that have a written answer. */
|
|
564
|
+
const SupportArticle = forwardRef<View, SupportArticleProps>(function SupportArticle(
|
|
565
|
+
{ className, icon, title, collection, onPress, ...props },
|
|
566
|
+
ref
|
|
567
|
+
) {
|
|
568
|
+
const { article, channelText } = supportVariants();
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<AnimatedPressable
|
|
572
|
+
ref={ref}
|
|
573
|
+
accessibilityRole="link"
|
|
574
|
+
accessibilityLabel={collection ? `${title}, in ${collection}` : title}
|
|
575
|
+
disabled={!onPress}
|
|
576
|
+
onPress={onPress}
|
|
577
|
+
className={article({ className })}
|
|
578
|
+
{...props}
|
|
579
|
+
>
|
|
580
|
+
{icon ? <View className="shrink-0">{icon}</View> : null}
|
|
581
|
+
<View className={channelText()}>
|
|
582
|
+
<Text size="sm" numberOfLines={2}>
|
|
583
|
+
{title}
|
|
584
|
+
</Text>
|
|
585
|
+
{collection ? (
|
|
586
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
587
|
+
{collection}
|
|
588
|
+
</Text>
|
|
589
|
+
) : null}
|
|
590
|
+
</View>
|
|
591
|
+
</AnimatedPressable>
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
SupportArticle.displayName = 'Support.Article';
|
|
595
|
+
|
|
596
|
+
// `role` here is the person's job, not the accessibility role — the component
|
|
597
|
+
// sets that itself, so the prop it would collide with is taken off.
|
|
598
|
+
export interface SupportAgentProps extends Omit<ViewProps, 'children' | 'role'> {
|
|
599
|
+
className?: string;
|
|
600
|
+
/** Slot for the avatar. */
|
|
601
|
+
avatar?: ReactNode;
|
|
602
|
+
/** Who is answering. */
|
|
603
|
+
name: string;
|
|
604
|
+
/** What they are — "Support", "Billing team", "Assistant". */
|
|
605
|
+
role?: string;
|
|
606
|
+
/** Overrides the availability the root published. */
|
|
607
|
+
availability?: SupportAvailability;
|
|
608
|
+
/** They are writing a reply right now. */
|
|
609
|
+
typing?: boolean;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Who the reader is talking to, above the transcript.
|
|
614
|
+
*
|
|
615
|
+
* A support thread is the one conversation where the other end is a stranger,
|
|
616
|
+
* and "someone from Billing, who is here now" is a different thing to talk to
|
|
617
|
+
* than an unattributed queue. `typing` replaces the status line rather than
|
|
618
|
+
* sitting beside it, because the two answer the same question and the live one
|
|
619
|
+
* wins.
|
|
620
|
+
*/
|
|
621
|
+
const SupportAgent = forwardRef<View, SupportAgentProps>(function SupportAgent(
|
|
622
|
+
{ className, avatar, name, role, availability: availabilityProp, typing = false, ...props },
|
|
623
|
+
ref
|
|
624
|
+
) {
|
|
625
|
+
const { availability: inherited } = useSupport();
|
|
626
|
+
const availability = availabilityProp ?? inherited;
|
|
627
|
+
const { agent, statusDot } = supportVariants({ availability });
|
|
628
|
+
|
|
629
|
+
const state =
|
|
630
|
+
availability === 'online' ? 'Online' : availability === 'away' ? 'Away' : 'Offline';
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<View
|
|
634
|
+
ref={ref}
|
|
635
|
+
accessibilityRole="header"
|
|
636
|
+
accessibilityLabel={`${name}${role ? `, ${role}` : ''}, ${typing ? 'typing' : state}`}
|
|
637
|
+
className={agent({ className })}
|
|
638
|
+
{...props}
|
|
639
|
+
>
|
|
640
|
+
{avatar ? <View className="shrink-0">{avatar}</View> : null}
|
|
641
|
+
<View className="shrink grow gap-0.5">
|
|
642
|
+
<Text size="sm" weight="semibold" numberOfLines={1}>
|
|
643
|
+
{name}
|
|
644
|
+
</Text>
|
|
645
|
+
<View className="flex-row items-center gap-1.5">
|
|
646
|
+
{typing ? null : <View className={statusDot()} />}
|
|
647
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
648
|
+
{typing ? 'Typing…' : role ? `${role} · ${state}` : state}
|
|
649
|
+
</Text>
|
|
650
|
+
</View>
|
|
651
|
+
</View>
|
|
652
|
+
</View>
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
SupportAgent.displayName = 'Support.Agent';
|
|
656
|
+
|
|
657
|
+
export interface SupportTicketProps extends Omit<ViewProps, 'children'> {
|
|
658
|
+
className?: string;
|
|
659
|
+
/** Reference for the request, as the reader sees it. */
|
|
660
|
+
reference?: string;
|
|
661
|
+
/** What it was about. */
|
|
662
|
+
subject?: string;
|
|
663
|
+
status?: SupportConversationStatus;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The request this thread belongs to, pinned above it. */
|
|
667
|
+
const SupportTicket = forwardRef<View, SupportTicketProps>(function SupportTicket(
|
|
668
|
+
{ className, reference, subject, status = 'open', ...props },
|
|
669
|
+
ref
|
|
670
|
+
) {
|
|
671
|
+
const { ticket, badge } = supportVariants();
|
|
672
|
+
const tone = STATUS_STYLE[status];
|
|
673
|
+
|
|
674
|
+
return (
|
|
675
|
+
<View
|
|
676
|
+
ref={ref}
|
|
677
|
+
accessibilityLabel={[subject, reference, tone.label].filter(Boolean).join(', ')}
|
|
678
|
+
className={ticket({ className })}
|
|
679
|
+
{...props}
|
|
680
|
+
>
|
|
681
|
+
<View className="shrink grow gap-0.5">
|
|
682
|
+
{subject ? (
|
|
683
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
684
|
+
{subject}
|
|
685
|
+
</Text>
|
|
686
|
+
) : null}
|
|
687
|
+
{reference ? (
|
|
688
|
+
<Text size="xs" muted numberOfLines={1}>
|
|
689
|
+
{reference}
|
|
690
|
+
</Text>
|
|
691
|
+
) : null}
|
|
692
|
+
</View>
|
|
693
|
+
<View className={badge({ className: tone.className })}>
|
|
694
|
+
<Text size="xs" weight="medium" className={tone.textClassName}>
|
|
695
|
+
{tone.label}
|
|
696
|
+
</Text>
|
|
697
|
+
</View>
|
|
698
|
+
</View>
|
|
699
|
+
);
|
|
700
|
+
});
|
|
701
|
+
SupportTicket.displayName = 'Support.Ticket';
|
|
702
|
+
|
|
703
|
+
export interface SupportHandoffProps extends ViewProps {
|
|
704
|
+
className?: string;
|
|
705
|
+
icon?: ReactNode;
|
|
706
|
+
children?: ReactNode;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* The moment the thread changes hands.
|
|
711
|
+
*
|
|
712
|
+
* Between two turns rather than inside either, because it is not something
|
|
713
|
+
* anybody said — an assistant's answer followed by a person's, with nothing
|
|
714
|
+
* between them, leaves the reader working out who they are talking to from the
|
|
715
|
+
* writing style.
|
|
716
|
+
*/
|
|
717
|
+
const SupportHandoff = forwardRef<View, SupportHandoffProps>(function SupportHandoff(
|
|
718
|
+
{ className, icon, children, ...props },
|
|
719
|
+
ref
|
|
720
|
+
) {
|
|
721
|
+
const { handoff } = supportVariants();
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<View ref={ref} accessibilityRole="alert" className={handoff({ className })} {...props}>
|
|
725
|
+
{icon ? <View className="shrink-0">{icon}</View> : null}
|
|
726
|
+
{/*
|
|
727
|
+
* Children straight into the Text rather than through textChildren,
|
|
728
|
+
* which would wrap a bare string in a second Text of the default size
|
|
729
|
+
* and colour — and the inner one wins.
|
|
730
|
+
*/}
|
|
731
|
+
<Text size="xs" muted className="shrink">
|
|
732
|
+
{children}
|
|
733
|
+
</Text>
|
|
734
|
+
</View>
|
|
735
|
+
);
|
|
736
|
+
});
|
|
737
|
+
SupportHandoff.displayName = 'Support.Handoff';
|
|
738
|
+
|
|
739
|
+
export interface SupportNoteProps extends ViewProps {
|
|
740
|
+
className?: string;
|
|
741
|
+
children?: ReactNode;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* One quiet line above the composer — what the wait is, or when they are back.
|
|
746
|
+
*
|
|
747
|
+
* It belongs here rather than at the top of the screen because this is where
|
|
748
|
+
* somebody is deciding whether it is worth typing, and an expectation set four
|
|
749
|
+
* screens ago is not one they still have in mind.
|
|
750
|
+
*/
|
|
751
|
+
const SupportNote = forwardRef<View, SupportNoteProps>(function SupportNote(
|
|
752
|
+
{ className, children, ...props },
|
|
753
|
+
ref
|
|
754
|
+
) {
|
|
755
|
+
const { note } = supportVariants();
|
|
756
|
+
return (
|
|
757
|
+
<View ref={ref} className="w-full" {...props}>
|
|
758
|
+
{/*
|
|
759
|
+
* Children straight into the Text rather than through textChildren,
|
|
760
|
+
* which would wrap a bare string in a second Text of the default size
|
|
761
|
+
* and colour — and the inner one wins.
|
|
762
|
+
*/}
|
|
763
|
+
<Text size="xs" muted className={note({ className })}>
|
|
764
|
+
{children}
|
|
765
|
+
</Text>
|
|
766
|
+
</View>
|
|
767
|
+
);
|
|
768
|
+
});
|
|
769
|
+
SupportNote.displayName = 'Support.Note';
|
|
770
|
+
|
|
771
|
+
export interface SupportRepliesProps extends ViewProps {
|
|
772
|
+
className?: string;
|
|
773
|
+
children?: ReactNode;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** The suggested answers under a turn. */
|
|
777
|
+
const SupportReplies = forwardRef<View, SupportRepliesProps>(function SupportReplies(
|
|
778
|
+
{ className, children, ...props },
|
|
779
|
+
ref
|
|
780
|
+
) {
|
|
781
|
+
const { replies } = supportVariants();
|
|
782
|
+
return (
|
|
783
|
+
<View ref={ref} className={replies({ className })} {...props}>
|
|
784
|
+
{textChildren(children)}
|
|
785
|
+
</View>
|
|
786
|
+
);
|
|
787
|
+
});
|
|
788
|
+
SupportReplies.displayName = 'Support.Replies';
|
|
789
|
+
|
|
790
|
+
export interface SupportReplyProps extends Omit<ViewProps, 'children'> {
|
|
791
|
+
className?: string;
|
|
792
|
+
/** What pressing it sends. Defaults to the label. */
|
|
793
|
+
value?: string;
|
|
794
|
+
label: string;
|
|
795
|
+
disabled?: boolean;
|
|
796
|
+
onPress?: (value: string) => void;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* One suggested answer.
|
|
801
|
+
*
|
|
802
|
+
* Worth having as a part rather than leaving to a row of buttons because of
|
|
803
|
+
* what goes in it: the way out to a person is the reply that has to be on the
|
|
804
|
+
* screen from the first turn, and a component that makes it easy to put there
|
|
805
|
+
* is a component that gets it put there.
|
|
806
|
+
*/
|
|
807
|
+
const SupportReply = forwardRef<View, SupportReplyProps>(function SupportReply(
|
|
808
|
+
{ className, value, label, disabled = false, onPress, ...props },
|
|
809
|
+
ref
|
|
810
|
+
) {
|
|
811
|
+
const { reply } = supportVariants();
|
|
812
|
+
|
|
813
|
+
return (
|
|
814
|
+
<AnimatedPressable
|
|
815
|
+
ref={ref}
|
|
816
|
+
accessibilityRole="button"
|
|
817
|
+
accessibilityState={{ disabled }}
|
|
818
|
+
accessibilityLabel={label}
|
|
819
|
+
disabled={disabled}
|
|
820
|
+
onPress={() => onPress?.(value ?? label)}
|
|
821
|
+
className={reply({ className })}
|
|
822
|
+
{...props}
|
|
823
|
+
>
|
|
824
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
825
|
+
{label}
|
|
826
|
+
</Text>
|
|
827
|
+
</AnimatedPressable>
|
|
828
|
+
);
|
|
829
|
+
});
|
|
830
|
+
SupportReply.displayName = 'Support.Reply';
|
|
831
|
+
|
|
832
|
+
export interface SupportResolutionProps extends ViewProps {
|
|
833
|
+
className?: string;
|
|
834
|
+
/** The question asked. */
|
|
835
|
+
title?: string;
|
|
836
|
+
/** Slot for the control that takes the answer — a Rating, two buttons. */
|
|
837
|
+
children?: ReactNode;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Whether it actually helped, asked at the end of a thread. */
|
|
841
|
+
const SupportResolution = forwardRef<View, SupportResolutionProps>(
|
|
842
|
+
function SupportResolution({ className, title = 'Did this solve it?', children, ...props }, ref) {
|
|
843
|
+
const { resolution } = supportVariants();
|
|
844
|
+
|
|
845
|
+
return (
|
|
846
|
+
<View ref={ref} className={resolution({ className })} {...props}>
|
|
847
|
+
<Text size="sm" weight="medium">
|
|
848
|
+
{title}
|
|
849
|
+
</Text>
|
|
850
|
+
{children ? <View className="w-full items-center">{textChildren(children)}</View> : null}
|
|
851
|
+
</View>
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
SupportResolution.displayName = 'Support.Resolution';
|
|
856
|
+
|
|
857
|
+
export const Support = Object.assign(SupportRoot, {
|
|
858
|
+
Header: SupportHeader,
|
|
859
|
+
Status: SupportStatus,
|
|
860
|
+
Channels: SupportChannels,
|
|
861
|
+
Channel: SupportChannel,
|
|
862
|
+
Topics: SupportTopics,
|
|
863
|
+
Topic: SupportTopic,
|
|
864
|
+
Conversations: SupportConversations,
|
|
865
|
+
Conversation: SupportConversation,
|
|
866
|
+
Articles: SupportArticles,
|
|
867
|
+
Article: SupportArticle,
|
|
868
|
+
Agent: SupportAgent,
|
|
869
|
+
Ticket: SupportTicket,
|
|
870
|
+
Handoff: SupportHandoff,
|
|
871
|
+
Note: SupportNote,
|
|
872
|
+
Replies: SupportReplies,
|
|
873
|
+
Reply: SupportReply,
|
|
874
|
+
Resolution: SupportResolution,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
export type SupportVariants = VariantProps<typeof supportVariants>;
|