@nim.zone/ui 0.6.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/LICENSE +21 -0
- package/README.md +681 -0
- package/dist/components/admin-shell.d.ts +121 -0
- package/dist/components/app-shell.d.ts +23 -0
- package/dist/components/auth-screen.d.ts +33 -0
- package/dist/components/avatar.d.ts +10 -0
- package/dist/components/badge.d.ts +14 -0
- package/dist/components/banner.d.ts +11 -0
- package/dist/components/breadcrumb.d.ts +12 -0
- package/dist/components/button.d.ts +21 -0
- package/dist/components/card.d.ts +15 -0
- package/dist/components/chat-composer.d.ts +52 -0
- package/dist/components/chat.d.ts +77 -0
- package/dist/components/checkout.d.ts +79 -0
- package/dist/components/choice.d.ts +15 -0
- package/dist/components/combobox.d.ts +31 -0
- package/dist/components/date-field.d.ts +58 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +9 -0
- package/dist/components/feedback.d.ts +18 -0
- package/dist/components/field.d.ts +47 -0
- package/dist/components/icon-button.d.ts +12 -0
- package/dist/components/icon.d.ts +72 -0
- package/dist/components/layout.d.ts +13 -0
- package/dist/components/list.d.ts +16 -0
- package/dist/components/menu.d.ts +56 -0
- package/dist/components/onboarding.d.ts +50 -0
- package/dist/components/otp-input.d.ts +29 -0
- package/dist/components/pagination.d.ts +12 -0
- package/dist/components/password-field.d.ts +33 -0
- package/dist/components/phone-field.d.ts +44 -0
- package/dist/components/plan-card.d.ts +45 -0
- package/dist/components/plan-picker.d.ts +52 -0
- package/dist/components/profile-header.d.ts +47 -0
- package/dist/components/profile-screen.d.ts +44 -0
- package/dist/components/resource-meter.d.ts +22 -0
- package/dist/components/section-header.d.ts +8 -0
- package/dist/components/segmented.d.ts +18 -0
- package/dist/components/sheet.d.ts +18 -0
- package/dist/components/sign-in-flow.d.ts +60 -0
- package/dist/components/slider.d.ts +10 -0
- package/dist/components/stat.d.ts +10 -0
- package/dist/components/stepper.d.ts +17 -0
- package/dist/components/tab-bar.d.ts +40 -0
- package/dist/components/table.d.ts +33 -0
- package/dist/components/tabs.d.ts +23 -0
- package/dist/components/task-progress.d.ts +38 -0
- package/dist/components/theme.d.ts +49 -0
- package/dist/components/toast.d.ts +17 -0
- package/dist/components/tooltip.d.ts +18 -0
- package/dist/components/typography.d.ts +18 -0
- package/dist/components/wizard.d.ts +70 -0
- package/dist/index.d.ts +104 -0
- package/dist/lib/calendars.d.ts +81 -0
- package/dist/lib/cn.d.ts +7 -0
- package/dist/lib/countries.d.ts +33 -0
- package/dist/lib/use-anchor.d.ts +22 -0
- package/dist/nim.css +1 -0
- package/dist/nim.js +3266 -0
- package/package.json +83 -0
- package/src/components/admin-shell.tsx +338 -0
- package/src/components/app-shell.tsx +36 -0
- package/src/components/auth-screen.tsx +71 -0
- package/src/components/avatar.tsx +32 -0
- package/src/components/badge.tsx +44 -0
- package/src/components/banner.tsx +47 -0
- package/src/components/breadcrumb.tsx +45 -0
- package/src/components/button.tsx +64 -0
- package/src/components/card.tsx +46 -0
- package/src/components/chat-composer.tsx +331 -0
- package/src/components/chat.tsx +352 -0
- package/src/components/checkout.tsx +173 -0
- package/src/components/choice.tsx +46 -0
- package/src/components/combobox.tsx +157 -0
- package/src/components/date-field.tsx +394 -0
- package/src/components/dialog.tsx +77 -0
- package/src/components/empty-state.tsx +23 -0
- package/src/components/feedback.tsx +61 -0
- package/src/components/field.tsx +177 -0
- package/src/components/icon-button.tsx +35 -0
- package/src/components/icon.tsx +167 -0
- package/src/components/layout.tsx +38 -0
- package/src/components/list.tsx +78 -0
- package/src/components/menu.tsx +172 -0
- package/src/components/onboarding.tsx +157 -0
- package/src/components/otp-input.tsx +144 -0
- package/src/components/pagination.tsx +86 -0
- package/src/components/password-field.tsx +112 -0
- package/src/components/phone-field.tsx +242 -0
- package/src/components/plan-card.tsx +124 -0
- package/src/components/plan-picker.tsx +144 -0
- package/src/components/profile-header.tsx +134 -0
- package/src/components/profile-screen.tsx +104 -0
- package/src/components/resource-meter.tsx +60 -0
- package/src/components/section-header.tsx +31 -0
- package/src/components/segmented.tsx +53 -0
- package/src/components/sheet.tsx +74 -0
- package/src/components/sign-in-flow.tsx +322 -0
- package/src/components/slider.tsx +50 -0
- package/src/components/stat.tsx +30 -0
- package/src/components/stepper.tsx +77 -0
- package/src/components/tab-bar.tsx +82 -0
- package/src/components/table.tsx +81 -0
- package/src/components/tabs.tsx +72 -0
- package/src/components/task-progress.tsx +111 -0
- package/src/components/theme.tsx +112 -0
- package/src/components/toast.tsx +87 -0
- package/src/components/tooltip.tsx +30 -0
- package/src/components/typography.tsx +65 -0
- package/src/components/wizard.tsx +192 -0
- package/src/index.ts +216 -0
- package/src/lib/calendars.ts +227 -0
- package/src/lib/cn.ts +8 -0
- package/src/lib/countries.ts +110 -0
- package/src/lib/use-anchor.ts +92 -0
- package/src/theme/colorways/coral.css +62 -0
- package/src/theme/colorways/oxblood.css +23 -0
- package/src/theme/colorways/paper.css +68 -0
- package/src/theme/colorways/teal.css +56 -0
- package/src/theme/colorways/vermilion.css +21 -0
- package/src/theme/components.css +4606 -0
- package/src/theme/contract.css +204 -0
- package/src/theme/fonts.css +67 -0
- package/src/theme/index.css +33 -0
- package/src/theme/persian.css +47 -0
- package/src/theme/reset.css +118 -0
- package/src/theme/styles/ledger.css +106 -0
- package/src/theme/styles/vlora.css +91 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Avatar } from '@/components/avatar'
|
|
4
|
+
import { Icon } from '@/components/icon'
|
|
5
|
+
import { IconButton } from '@/components/icon-button'
|
|
6
|
+
import { Spinner } from '@/components/feedback'
|
|
7
|
+
import { cn } from '@/lib/cn'
|
|
8
|
+
|
|
9
|
+
/** What a message carries. Text is a string; the rest is an attachment. */
|
|
10
|
+
export type ChatMediaKind = 'file' | 'image' | 'text' | 'video' | 'voice'
|
|
11
|
+
|
|
12
|
+
export interface ChatAttachment {
|
|
13
|
+
/** Seconds. Voice and video only — a duration the viewer sees before playing. */
|
|
14
|
+
duration?: number
|
|
15
|
+
kind: Exclude<ChatMediaKind, 'text'>
|
|
16
|
+
name?: string
|
|
17
|
+
/** Still frame for a video. Without one the browser draws its own first frame. */
|
|
18
|
+
poster?: string
|
|
19
|
+
/** Bytes. Shown on files so nobody taps a 40MB download on mobile data. */
|
|
20
|
+
size?: number
|
|
21
|
+
/** Object URL or remote URL. The kit never uploads anything itself. */
|
|
22
|
+
url: string
|
|
23
|
+
/** Normalised 0–1 samples for a voice message. Absent draws a flat track. */
|
|
24
|
+
waveform?: number[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatMessage {
|
|
28
|
+
attachments?: ChatAttachment[]
|
|
29
|
+
/** Who wrote it. `own` messages are the viewer's and sit on the trailing edge. */
|
|
30
|
+
author?: { avatar?: string; name: string }
|
|
31
|
+
id: string
|
|
32
|
+
own?: boolean
|
|
33
|
+
/** Delivery state, shown on own messages only — the other side's receipts
|
|
34
|
+
are not the viewer's business. */
|
|
35
|
+
status?: 'failed' | 'read' | 'sending' | 'sent'
|
|
36
|
+
text?: string
|
|
37
|
+
/** ISO timestamp. Formatted in the viewer's locale, never by the caller. */
|
|
38
|
+
at?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChatProps {
|
|
42
|
+
className?: string
|
|
43
|
+
/** Someone is typing. A name renders "Sara is typing", bare `true` renders
|
|
44
|
+
the dots alone. */
|
|
45
|
+
typing?: boolean | string
|
|
46
|
+
/** Rendered under the last message — a date divider, a system notice. */
|
|
47
|
+
footer?: ReactNode
|
|
48
|
+
/** Accessible names and the few words the transcript itself says. */
|
|
49
|
+
labels?: Partial<typeof DEFAULT_LABELS>
|
|
50
|
+
/** The composer. Passing none makes the transcript read-only, which is what
|
|
51
|
+
an archive or a shared thread wants. */
|
|
52
|
+
composer?: ReactNode
|
|
53
|
+
locale?: string
|
|
54
|
+
messages: ChatMessage[]
|
|
55
|
+
/** Header row: who this conversation is with, and its actions. */
|
|
56
|
+
header?: ReactNode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_LABELS = {
|
|
60
|
+
download: 'Download',
|
|
61
|
+
failed: 'Not delivered',
|
|
62
|
+
pause: 'Pause',
|
|
63
|
+
play: 'Play',
|
|
64
|
+
read: 'Read',
|
|
65
|
+
sending: 'Sending',
|
|
66
|
+
sent: 'Sent',
|
|
67
|
+
today: 'Today',
|
|
68
|
+
typing: 'is typing',
|
|
69
|
+
yesterday: 'Yesterday',
|
|
70
|
+
voiceMessage: 'Voice message',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const KB = 1024
|
|
74
|
+
|
|
75
|
+
/** `1.4 MB`. Sizes are read at a glance, so one decimal is enough. */
|
|
76
|
+
function formatSize(bytes: number, locale: string | undefined): string {
|
|
77
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
78
|
+
let value = bytes
|
|
79
|
+
let unit = 0
|
|
80
|
+
while (value >= KB && unit < units.length - 1) {
|
|
81
|
+
value /= KB
|
|
82
|
+
unit += 1
|
|
83
|
+
}
|
|
84
|
+
const digits = new Intl.NumberFormat(locale, { maximumFractionDigits: unit === 0 ? 0 : 1 })
|
|
85
|
+
return `${digits.format(value)} ${units[unit]}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `1:07`. Clock time, not a duration sentence — it sits inside a bubble. */
|
|
89
|
+
function formatDuration(seconds: number, locale: string | undefined): string {
|
|
90
|
+
const digits = new Intl.NumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false })
|
|
91
|
+
const whole = Math.max(0, Math.round(seconds))
|
|
92
|
+
const minutes = new Intl.NumberFormat(locale).format(Math.floor(whole / 60))
|
|
93
|
+
return `${minutes}:${digits.format(whole % 60)}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A voice message: one control, a scrubbable waveform, and the time left.
|
|
98
|
+
*
|
|
99
|
+
* The `<audio>` element is real and hidden rather than reimplemented — it is
|
|
100
|
+
* what gives the message a decoder, the OS media keys, and playback that keeps
|
|
101
|
+
* going when the tab is backgrounded. Only the transport is drawn here.
|
|
102
|
+
*/
|
|
103
|
+
function VoiceBubble({
|
|
104
|
+
attachment,
|
|
105
|
+
labels,
|
|
106
|
+
locale,
|
|
107
|
+
}: {
|
|
108
|
+
attachment: ChatAttachment
|
|
109
|
+
labels: typeof DEFAULT_LABELS
|
|
110
|
+
locale: string | undefined
|
|
111
|
+
}) {
|
|
112
|
+
const audio = useRef<HTMLAudioElement>(null)
|
|
113
|
+
const [playing, setPlaying] = useState(false)
|
|
114
|
+
const [position, setPosition] = useState(0)
|
|
115
|
+
const total = attachment.duration ?? 0
|
|
116
|
+
const bars = useMemo(
|
|
117
|
+
() => attachment.waveform ?? Array.from({ length: 32 }, (_, index) => 0.35 + ((index * 7) % 11) / 18),
|
|
118
|
+
[attachment.waveform],
|
|
119
|
+
)
|
|
120
|
+
const progress = total > 0 ? Math.min(1, position / total) : 0
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="nim-chat-voice">
|
|
124
|
+
<IconButton
|
|
125
|
+
label={playing ? labels.pause : labels.play}
|
|
126
|
+
name={playing ? 'pause' : 'play'}
|
|
127
|
+
onClick={() => {
|
|
128
|
+
const element = audio.current
|
|
129
|
+
if (!element) return
|
|
130
|
+
if (element.paused) void element.play()
|
|
131
|
+
else element.pause()
|
|
132
|
+
}}
|
|
133
|
+
size="sm"
|
|
134
|
+
variant="solid"
|
|
135
|
+
/>
|
|
136
|
+
<div
|
|
137
|
+
aria-label={labels.voiceMessage}
|
|
138
|
+
className="nim-chat-voice__wave"
|
|
139
|
+
// The waveform is decoration over a real control; it is the button and
|
|
140
|
+
// the time that carry the message for anyone not looking at it.
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
>
|
|
143
|
+
{bars.map((bar, index) => (
|
|
144
|
+
<span
|
|
145
|
+
className="nim-chat-voice__bar"
|
|
146
|
+
data-played={index / bars.length <= progress ? 'true' : undefined}
|
|
147
|
+
key={index}
|
|
148
|
+
style={{ blockSize: `${Math.round(bar * 100)}%` }}
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
<span className="nim-chat-voice__time">
|
|
153
|
+
{formatDuration(playing || position ? Math.max(0, total - position) : total, locale)}
|
|
154
|
+
</span>
|
|
155
|
+
<audio
|
|
156
|
+
onEnded={() => {
|
|
157
|
+
setPlaying(false)
|
|
158
|
+
setPosition(0)
|
|
159
|
+
}}
|
|
160
|
+
onPause={() => setPlaying(false)}
|
|
161
|
+
onPlay={() => setPlaying(true)}
|
|
162
|
+
onTimeUpdate={(event) => setPosition(event.currentTarget.currentTime)}
|
|
163
|
+
preload="metadata"
|
|
164
|
+
ref={audio}
|
|
165
|
+
src={attachment.url}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function AttachmentView({
|
|
172
|
+
attachment,
|
|
173
|
+
labels,
|
|
174
|
+
locale,
|
|
175
|
+
}: {
|
|
176
|
+
attachment: ChatAttachment
|
|
177
|
+
labels: typeof DEFAULT_LABELS
|
|
178
|
+
locale: string | undefined
|
|
179
|
+
}) {
|
|
180
|
+
if (attachment.kind === 'voice') {
|
|
181
|
+
return <VoiceBubble attachment={attachment} labels={labels} locale={locale} />
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (attachment.kind === 'video') {
|
|
185
|
+
return (
|
|
186
|
+
<figure className="nim-chat-media">
|
|
187
|
+
{/* Controls come from the platform: picture-in-picture, captions,
|
|
188
|
+
AirPlay and the scrubber are all things a custom player loses. */}
|
|
189
|
+
<video controls playsInline poster={attachment.poster} preload="metadata" src={attachment.url} />
|
|
190
|
+
{attachment.duration ? (
|
|
191
|
+
<figcaption className="nim-chat-media__meta">
|
|
192
|
+
{formatDuration(attachment.duration, locale)}
|
|
193
|
+
</figcaption>
|
|
194
|
+
) : null}
|
|
195
|
+
</figure>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (attachment.kind === 'image') {
|
|
200
|
+
return (
|
|
201
|
+
<figure className="nim-chat-media">
|
|
202
|
+
<img alt={attachment.name ?? ''} loading="lazy" src={attachment.url} />
|
|
203
|
+
</figure>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<a
|
|
209
|
+
className="nim-chat-file"
|
|
210
|
+
download={attachment.name}
|
|
211
|
+
href={attachment.url}
|
|
212
|
+
rel="noreferrer"
|
|
213
|
+
target="_blank"
|
|
214
|
+
>
|
|
215
|
+
<span className="nim-chat-file__icon">
|
|
216
|
+
<Icon name="document" size="md" />
|
|
217
|
+
</span>
|
|
218
|
+
<span className="nim-chat-file__text">
|
|
219
|
+
<span className="nim-chat-file__name">{attachment.name ?? labels.download}</span>
|
|
220
|
+
{attachment.size !== undefined ? (
|
|
221
|
+
<span className="nim-chat-file__size">{formatSize(attachment.size, locale)}</span>
|
|
222
|
+
) : null}
|
|
223
|
+
</span>
|
|
224
|
+
<Icon className="nim-chat-file__action" name="download" size="sm" />
|
|
225
|
+
</a>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* One conversation: a scrolling transcript with a composer under it.
|
|
231
|
+
*
|
|
232
|
+
* The transcript is a `<ol>` in a live region, so a message arriving is
|
|
233
|
+
* announced without stealing focus from whatever the viewer is typing. It
|
|
234
|
+
* follows the newest message only when the viewer is already at the bottom —
|
|
235
|
+
* yanking someone back down while they are reading history is the single most
|
|
236
|
+
* common chat bug, and it is a scroll check, not a scroll call.
|
|
237
|
+
*
|
|
238
|
+
* Media is played by the platform's own elements. The kit renders transports
|
|
239
|
+
* and bubbles; it never uploads, transcodes, or holds a socket.
|
|
240
|
+
*/
|
|
241
|
+
export function Chat({
|
|
242
|
+
className,
|
|
243
|
+
composer,
|
|
244
|
+
footer,
|
|
245
|
+
header,
|
|
246
|
+
labels,
|
|
247
|
+
locale,
|
|
248
|
+
messages,
|
|
249
|
+
typing,
|
|
250
|
+
}: ChatProps) {
|
|
251
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
252
|
+
const scroller = useRef<HTMLDivElement>(null)
|
|
253
|
+
const pinned = useRef(true)
|
|
254
|
+
|
|
255
|
+
const timeFormat = useMemo(
|
|
256
|
+
() => new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }),
|
|
257
|
+
[locale],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
const element = scroller.current
|
|
262
|
+
if (!element || !pinned.current) return
|
|
263
|
+
element.scrollTop = element.scrollHeight
|
|
264
|
+
}, [messages, typing])
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<section className={cn('nim-chat', className)}>
|
|
268
|
+
{header ? <header className="nim-chat__header">{header}</header> : null}
|
|
269
|
+
|
|
270
|
+
<div
|
|
271
|
+
className="nim-chat__scroll"
|
|
272
|
+
onScroll={(event) => {
|
|
273
|
+
const element = event.currentTarget
|
|
274
|
+
// 48px of slack: "near the bottom" is what a reader means by "at the
|
|
275
|
+
// bottom", and an exact comparison fails on fractional zoom.
|
|
276
|
+
pinned.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48
|
|
277
|
+
}}
|
|
278
|
+
ref={scroller}
|
|
279
|
+
>
|
|
280
|
+
<ol aria-live="polite" className="nim-chat__list">
|
|
281
|
+
{messages.map((message) => (
|
|
282
|
+
<li
|
|
283
|
+
className={cn('nim-chat-message', message.own && 'nim-chat-message--own')}
|
|
284
|
+
key={message.id}
|
|
285
|
+
>
|
|
286
|
+
{!message.own && message.author ? (
|
|
287
|
+
<Avatar
|
|
288
|
+
className="nim-chat-message__avatar"
|
|
289
|
+
name={message.author.name}
|
|
290
|
+
size="sm"
|
|
291
|
+
src={message.author.avatar}
|
|
292
|
+
/>
|
|
293
|
+
) : null}
|
|
294
|
+
|
|
295
|
+
<div className="nim-chat-message__stack">
|
|
296
|
+
{!message.own && message.author ? (
|
|
297
|
+
<span className="nim-chat-message__author">{message.author.name}</span>
|
|
298
|
+
) : null}
|
|
299
|
+
|
|
300
|
+
<div className="nim-chat-message__bubble">
|
|
301
|
+
{message.attachments?.map((attachment, index) => (
|
|
302
|
+
<AttachmentView
|
|
303
|
+
attachment={attachment}
|
|
304
|
+
key={`${message.id}-${index}`}
|
|
305
|
+
labels={text}
|
|
306
|
+
locale={locale}
|
|
307
|
+
/>
|
|
308
|
+
))}
|
|
309
|
+
{message.text ? <p className="nim-chat-message__text">{message.text}</p> : null}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<span className="nim-chat-message__meta">
|
|
313
|
+
{message.at ? (
|
|
314
|
+
<time dateTime={message.at}>{timeFormat.format(new Date(message.at))}</time>
|
|
315
|
+
) : null}
|
|
316
|
+
{message.own && message.status ? (
|
|
317
|
+
<span className="nim-chat-message__status" data-status={message.status}>
|
|
318
|
+
{message.status === 'sending' ? (
|
|
319
|
+
<Spinner size="sm" />
|
|
320
|
+
) : (
|
|
321
|
+
<Icon
|
|
322
|
+
label={text[message.status]}
|
|
323
|
+
name={message.status === 'failed' ? 'danger' : 'check-circle'}
|
|
324
|
+
size="xs"
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
</span>
|
|
328
|
+
) : null}
|
|
329
|
+
</span>
|
|
330
|
+
</div>
|
|
331
|
+
</li>
|
|
332
|
+
))}
|
|
333
|
+
</ol>
|
|
334
|
+
|
|
335
|
+
{typing ? (
|
|
336
|
+
<p className="nim-chat__typing">
|
|
337
|
+
{typeof typing === 'string' ? `${typing} ${text.typing}` : text.typing}
|
|
338
|
+
<span aria-hidden="true" className="nim-chat__dots">
|
|
339
|
+
<i />
|
|
340
|
+
<i />
|
|
341
|
+
<i />
|
|
342
|
+
</span>
|
|
343
|
+
</p>
|
|
344
|
+
) : null}
|
|
345
|
+
|
|
346
|
+
{footer ? <div className="nim-chat__footer">{footer}</div> : null}
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{composer ? <div className="nim-chat__composer">{composer}</div> : null}
|
|
350
|
+
</section>
|
|
351
|
+
)
|
|
352
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface OptionCardProps {
|
|
6
|
+
/** A short word on the trailing edge: "manual review", "default". */
|
|
7
|
+
badge?: ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
description?: ReactNode
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
icon?: IconName
|
|
12
|
+
/** Rendered under the description when chosen — an account number, a
|
|
13
|
+
delivery window. Hidden otherwise, because it is only true of the
|
|
14
|
+
selected option. */
|
|
15
|
+
detail?: ReactNode
|
|
16
|
+
name?: string
|
|
17
|
+
onSelect: () => void
|
|
18
|
+
selected: boolean
|
|
19
|
+
title: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One choice in a list of choices: a payment method, a saved address, a
|
|
24
|
+
* delivery window.
|
|
25
|
+
*
|
|
26
|
+
* A real `<input type="radio">` inside the card, visually hidden, is what makes
|
|
27
|
+
* a set of these behave like a radio group — arrow keys move between them, the
|
|
28
|
+
* name groups them, and a form submits the chosen one. The card is the label,
|
|
29
|
+
* so the whole plate is the target rather than a 20px dot beside it.
|
|
30
|
+
*/
|
|
31
|
+
export function OptionCard({
|
|
32
|
+
badge,
|
|
33
|
+
className,
|
|
34
|
+
description,
|
|
35
|
+
detail,
|
|
36
|
+
disabled = false,
|
|
37
|
+
icon,
|
|
38
|
+
name,
|
|
39
|
+
onSelect,
|
|
40
|
+
selected,
|
|
41
|
+
title,
|
|
42
|
+
}: OptionCardProps) {
|
|
43
|
+
return (
|
|
44
|
+
<label className={cn('nim-option-card', selected && 'nim-option-card--selected', className)}>
|
|
45
|
+
<input
|
|
46
|
+
checked={selected}
|
|
47
|
+
className="nim-option-card__input"
|
|
48
|
+
disabled={disabled}
|
|
49
|
+
name={name}
|
|
50
|
+
onChange={onSelect}
|
|
51
|
+
type="radio"
|
|
52
|
+
/>
|
|
53
|
+
{icon ? (
|
|
54
|
+
<span className="nim-option-card__icon">
|
|
55
|
+
<Icon name={icon} size="md" />
|
|
56
|
+
</span>
|
|
57
|
+
) : null}
|
|
58
|
+
<span className="nim-option-card__text">
|
|
59
|
+
<span className="nim-option-card__title">{title}</span>
|
|
60
|
+
{description ? <span className="nim-option-card__description">{description}</span> : null}
|
|
61
|
+
{selected && detail ? <span className="nim-option-card__detail">{detail}</span> : null}
|
|
62
|
+
</span>
|
|
63
|
+
{badge ? <span className="nim-option-card__badge">{badge}</span> : null}
|
|
64
|
+
<span aria-hidden="true" className="nim-option-card__dot" />
|
|
65
|
+
</label>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SummaryLine {
|
|
70
|
+
/** Set on the line the eye should land on — the total. At most one. */
|
|
71
|
+
emphasis?: boolean
|
|
72
|
+
key: string
|
|
73
|
+
label: ReactNode
|
|
74
|
+
/** A qualifier under the label: "6 months · renews 1405/12/01". */
|
|
75
|
+
meta?: ReactNode
|
|
76
|
+
/** Already formatted. Currency and digits are the product's locale call. */
|
|
77
|
+
value: ReactNode
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface OrderSummaryProps {
|
|
81
|
+
className?: string
|
|
82
|
+
/** Deductions. Rendered in the success tone with the sign the caller gives
|
|
83
|
+
them — the kit does not do arithmetic on money it cannot see. */
|
|
84
|
+
items: SummaryLine[]
|
|
85
|
+
/** Subtotal, tax, fees, total: the arithmetic, under a rule. */
|
|
86
|
+
totals?: SummaryLine[]
|
|
87
|
+
title?: ReactNode
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What is being bought, and what it comes to.
|
|
92
|
+
*
|
|
93
|
+
* Every figure is a `ReactNode` the caller has already formatted. That is
|
|
94
|
+
* deliberate: money is the last thing a UI kit should be computing or
|
|
95
|
+
* rounding, and a component that took numbers would have to guess a currency,
|
|
96
|
+
* a tax rule and a digit shape — three decisions that belong to the product
|
|
97
|
+
* and are wrong in Persian first.
|
|
98
|
+
*/
|
|
99
|
+
export function OrderSummary({ className, items, title, totals = [] }: OrderSummaryProps) {
|
|
100
|
+
return (
|
|
101
|
+
<section className={cn('nim-summary', className)}>
|
|
102
|
+
{title ? <h2 className="nim-summary__title">{title}</h2> : null}
|
|
103
|
+
|
|
104
|
+
<dl className="nim-summary__lines">
|
|
105
|
+
{items.map((line) => (
|
|
106
|
+
<div className="nim-summary__line" key={line.key}>
|
|
107
|
+
<dt>
|
|
108
|
+
<span className="nim-summary__label">{line.label}</span>
|
|
109
|
+
{line.meta ? <span className="nim-summary__meta">{line.meta}</span> : null}
|
|
110
|
+
</dt>
|
|
111
|
+
<dd className="nim-summary__value">{line.value}</dd>
|
|
112
|
+
</div>
|
|
113
|
+
))}
|
|
114
|
+
</dl>
|
|
115
|
+
|
|
116
|
+
{totals.length ? (
|
|
117
|
+
<>
|
|
118
|
+
<hr className="nim-summary__rule" />
|
|
119
|
+
<dl className="nim-summary__lines nim-summary__lines--totals">
|
|
120
|
+
{totals.map((line) => (
|
|
121
|
+
<div
|
|
122
|
+
className="nim-summary__line"
|
|
123
|
+
data-emphasis={line.emphasis ? 'true' : undefined}
|
|
124
|
+
key={line.key}
|
|
125
|
+
>
|
|
126
|
+
<dt>
|
|
127
|
+
<span className="nim-summary__label">{line.label}</span>
|
|
128
|
+
</dt>
|
|
129
|
+
<dd className="nim-summary__value">{line.value}</dd>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</dl>
|
|
133
|
+
</>
|
|
134
|
+
) : null}
|
|
135
|
+
</section>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ActionBarProps {
|
|
140
|
+
/** The primary control. One only: a bar with two equal buttons has none. */
|
|
141
|
+
action: ReactNode
|
|
142
|
+
className?: string
|
|
143
|
+
/** Fine print under the row — renewal terms, delivery estimate. */
|
|
144
|
+
note?: ReactNode
|
|
145
|
+
/** The figure the action commits to, beside it rather than above it, so it
|
|
146
|
+
is read in the same glance as the button. */
|
|
147
|
+
total?: { label: ReactNode; value: ReactNode }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The bar a purchase screen ends with: what it costs, and the button.
|
|
152
|
+
*
|
|
153
|
+
* It sticks to the bottom of the scroll container rather than the viewport, so
|
|
154
|
+
* it belongs to the screen it is part of and cannot end up floating over an
|
|
155
|
+
* unrelated one. It sits above the safe-area inset, and the content above owes
|
|
156
|
+
* it room the way it does the tab bar.
|
|
157
|
+
*/
|
|
158
|
+
export function ActionBar({ action, className, note, total }: ActionBarProps) {
|
|
159
|
+
return (
|
|
160
|
+
<div className={cn('nim-action-bar', className)}>
|
|
161
|
+
<div className="nim-action-bar__row">
|
|
162
|
+
{total ? (
|
|
163
|
+
<div className="nim-action-bar__total">
|
|
164
|
+
<span className="nim-action-bar__total-label">{total.label}</span>
|
|
165
|
+
<strong className="nim-action-bar__total-value">{total.value}</strong>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
<div className="nim-action-bar__action">{action}</div>
|
|
169
|
+
</div>
|
|
170
|
+
{note ? <p className="nim-action-bar__note">{note}</p> : null}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
interface ChoiceBaseProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type CheckboxProps = ChoiceBaseProps
|
|
11
|
+
export type SwitchProps = ChoiceBaseProps
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Both controls keep a real `<input>` in the tree — visually hidden but
|
|
15
|
+
* focusable — so keyboard behaviour, form participation, and assistive tech
|
|
16
|
+
* come from the platform rather than from re-implemented ARIA.
|
|
17
|
+
*/
|
|
18
|
+
export function Checkbox({ children, className, description, ...props }: CheckboxProps) {
|
|
19
|
+
return (
|
|
20
|
+
<label className={cn('nim-choice nim-choice--checkbox', className)}>
|
|
21
|
+
<input className="nim-choice__input" type="checkbox" {...props} />
|
|
22
|
+
<span aria-hidden="true" className="nim-checkbox__box">
|
|
23
|
+
<Icon name="check" size="xs" />
|
|
24
|
+
</span>
|
|
25
|
+
<span className="nim-choice__text">
|
|
26
|
+
{children}
|
|
27
|
+
{description ? <span className="nim-choice__description">{description}</span> : null}
|
|
28
|
+
</span>
|
|
29
|
+
</label>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Switch({ children, className, description, ...props }: SwitchProps) {
|
|
34
|
+
return (
|
|
35
|
+
<label className={cn('nim-choice nim-choice--switch', className)}>
|
|
36
|
+
<input className="nim-choice__input" role="switch" type="checkbox" {...props} />
|
|
37
|
+
<span aria-hidden="true" className="nim-switch__track">
|
|
38
|
+
<span className="nim-switch__thumb" />
|
|
39
|
+
</span>
|
|
40
|
+
<span className="nim-choice__text">
|
|
41
|
+
{children}
|
|
42
|
+
{description ? <span className="nim-choice__description">{description}</span> : null}
|
|
43
|
+
</span>
|
|
44
|
+
</label>
|
|
45
|
+
)
|
|
46
|
+
}
|