@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,45 @@
|
|
|
1
|
+
import { Fragment } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface Crumb {
|
|
6
|
+
href?: string
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BreadcrumbProps {
|
|
11
|
+
className?: string
|
|
12
|
+
/** The trail, root first. The last entry is the current page and is never a
|
|
13
|
+
link — a link to where the reader already is has nothing to offer. */
|
|
14
|
+
items: Crumb[]
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Breadcrumb({ className, items, label = 'Breadcrumb' }: BreadcrumbProps) {
|
|
19
|
+
return (
|
|
20
|
+
<nav aria-label={label} className={cn('nim-breadcrumb', className)}>
|
|
21
|
+
{items.map((item, index) => {
|
|
22
|
+
const last = index === items.length - 1
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Fragment key={item.label}>
|
|
26
|
+
{index > 0 ? (
|
|
27
|
+
<span aria-hidden="true" className="nim-breadcrumb__separator">
|
|
28
|
+
<Icon name="chevron-forward" size="xs" />
|
|
29
|
+
</span>
|
|
30
|
+
) : null}
|
|
31
|
+
{last || !item.href ? (
|
|
32
|
+
<span aria-current={last ? 'page' : undefined} className="nim-breadcrumb__current">
|
|
33
|
+
{item.label}
|
|
34
|
+
</span>
|
|
35
|
+
) : (
|
|
36
|
+
<a className="nim-breadcrumb__link" href={item.href}>
|
|
37
|
+
{item.label}
|
|
38
|
+
</a>
|
|
39
|
+
)}
|
|
40
|
+
</Fragment>
|
|
41
|
+
)
|
|
42
|
+
})}
|
|
43
|
+
</nav>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
|
|
6
|
+
export type ButtonSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
export type ButtonVariant = 'accent' | 'danger' | 'ghost' | 'primary' | 'secondary'
|
|
8
|
+
|
|
9
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
fullWidth?: boolean
|
|
12
|
+
/** Icon on the trailing edge — travels with the reading direction. */
|
|
13
|
+
iconEnd?: IconName
|
|
14
|
+
iconStart?: IconName
|
|
15
|
+
/** Blocks interaction and announces busy; the label stays in place so the
|
|
16
|
+
button does not change width mid-request. */
|
|
17
|
+
loading?: boolean
|
|
18
|
+
size?: ButtonSize
|
|
19
|
+
variant?: ButtonVariant
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The ref is forwarded because overlays anchor to their trigger: Menu and
|
|
24
|
+
* Popover need the element, not a wrapper around it.
|
|
25
|
+
*/
|
|
26
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
27
|
+
{
|
|
28
|
+
children,
|
|
29
|
+
className,
|
|
30
|
+
disabled = false,
|
|
31
|
+
fullWidth = false,
|
|
32
|
+
iconEnd,
|
|
33
|
+
iconStart,
|
|
34
|
+
loading = false,
|
|
35
|
+
size = 'md',
|
|
36
|
+
type = 'button',
|
|
37
|
+
variant = 'primary',
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
ref,
|
|
41
|
+
) {
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
aria-busy={loading || undefined}
|
|
45
|
+
className={cn(
|
|
46
|
+
'nim-button',
|
|
47
|
+
`nim-button--${variant}`,
|
|
48
|
+
`nim-button--${size}`,
|
|
49
|
+
fullWidth && 'nim-button--full',
|
|
50
|
+
loading && 'nim-button--loading',
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
disabled={disabled || loading}
|
|
54
|
+
ref={ref}
|
|
55
|
+
type={type}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
{loading ? <span aria-hidden="true" className="nim-button__spinner" /> : null}
|
|
59
|
+
{!loading && iconStart ? <Icon name={iconStart} size="sm" /> : null}
|
|
60
|
+
<span className="nim-button__label">{children}</span>
|
|
61
|
+
{iconEnd ? <Icon name={iconEnd} size="sm" /> : null}
|
|
62
|
+
</button>
|
|
63
|
+
)
|
|
64
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export type CardPadding = 'lg' | 'md' | 'none' | 'sm'
|
|
5
|
+
export type CardVariant = 'accent' | 'default' | 'muted' | 'outline' | 'raised'
|
|
6
|
+
|
|
7
|
+
export interface CardProps extends HTMLAttributes<HTMLElement> {
|
|
8
|
+
as?: 'article' | 'div' | 'section'
|
|
9
|
+
children: ReactNode
|
|
10
|
+
footer?: ReactNode
|
|
11
|
+
header?: ReactNode
|
|
12
|
+
/** Visual affordance only. An interactive card must still be wrapped in, or
|
|
13
|
+
rendered as, a real button or link — this never adds a handler. */
|
|
14
|
+
interactive?: boolean
|
|
15
|
+
padding?: CardPadding
|
|
16
|
+
variant?: CardVariant
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Card({
|
|
20
|
+
as: Component = 'article',
|
|
21
|
+
children,
|
|
22
|
+
className,
|
|
23
|
+
footer,
|
|
24
|
+
header,
|
|
25
|
+
interactive = false,
|
|
26
|
+
padding = 'md',
|
|
27
|
+
variant = 'default',
|
|
28
|
+
...props
|
|
29
|
+
}: CardProps) {
|
|
30
|
+
return (
|
|
31
|
+
<Component
|
|
32
|
+
className={cn(
|
|
33
|
+
'nim-card',
|
|
34
|
+
`nim-card--${variant}`,
|
|
35
|
+
`nim-card--pad-${padding}`,
|
|
36
|
+
interactive && 'nim-card--interactive',
|
|
37
|
+
className,
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{header ? <div className="nim-card__header">{header}</div> : null}
|
|
42
|
+
{children}
|
|
43
|
+
{footer ? <div className="nim-card__footer">{footer}</div> : null}
|
|
44
|
+
</Component>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { IconButton } from '@/components/icon-button'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
import type { ChatAttachment } from '@/components/chat'
|
|
6
|
+
|
|
7
|
+
export interface ChatDraft {
|
|
8
|
+
attachments: ChatAttachment[]
|
|
9
|
+
text: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ChatComposerProps {
|
|
13
|
+
/** Accept list for the attach button. Narrow it to what the product takes. */
|
|
14
|
+
accept?: string
|
|
15
|
+
className?: string
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
labels?: Partial<typeof DEFAULT_LABELS>
|
|
18
|
+
/** Called with the draft. Uploading is the app's: the attachments carry
|
|
19
|
+
object URLs and their `File`s are handed over in `onFiles`. */
|
|
20
|
+
onSend: (draft: ChatDraft) => void
|
|
21
|
+
/** The raw `File`s behind the attachments in the same order, so the caller
|
|
22
|
+
can upload them without re-reading the object URLs. */
|
|
23
|
+
onFiles?: (files: File[]) => void
|
|
24
|
+
placeholder?: string
|
|
25
|
+
/** Turn off what the product does not support. Voice also switches itself
|
|
26
|
+
off where the browser has no recorder. */
|
|
27
|
+
allow?: { file?: boolean; video?: boolean; voice?: boolean }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_LABELS = {
|
|
31
|
+
attach: 'Attach a file',
|
|
32
|
+
cancel: 'Cancel recording',
|
|
33
|
+
discard: 'Remove attachment',
|
|
34
|
+
record: 'Record a voice message',
|
|
35
|
+
recording: 'Recording',
|
|
36
|
+
send: 'Send',
|
|
37
|
+
stop: 'Stop and attach',
|
|
38
|
+
video: 'Attach a video',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Recording is a permissioned browser capability, not something to assume. */
|
|
42
|
+
const canRecord = () =>
|
|
43
|
+
typeof navigator !== 'undefined' &&
|
|
44
|
+
typeof window !== 'undefined' &&
|
|
45
|
+
'MediaRecorder' in window &&
|
|
46
|
+
Boolean(navigator.mediaDevices?.getUserMedia)
|
|
47
|
+
|
|
48
|
+
const kindOf = (file: File): ChatAttachment['kind'] =>
|
|
49
|
+
file.type.startsWith('video/') ? 'video' : file.type.startsWith('image/') ? 'image' : 'file'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The composer: text, a file, a video, or a voice message recorded in place.
|
|
53
|
+
*
|
|
54
|
+
* It holds the draft and nothing else — no transport, no upload, no socket.
|
|
55
|
+
* `onSend` receives the text and the attachments, and `onFiles` hands over the
|
|
56
|
+
* original `File`s, because an object URL is for showing and a `File` is for
|
|
57
|
+
* uploading and the caller needs both.
|
|
58
|
+
*
|
|
59
|
+
* Voice recording is `MediaRecorder` over `getUserMedia`. Where either is
|
|
60
|
+
* missing — an old browser, an insecure origin, a denied microphone — the
|
|
61
|
+
* button is simply not rendered, rather than offered and then failing. The
|
|
62
|
+
* stream's tracks are stopped on every exit path, including unmount: a
|
|
63
|
+
* recorder left running is a microphone indicator that never goes away.
|
|
64
|
+
*/
|
|
65
|
+
export function ChatComposer({
|
|
66
|
+
accept,
|
|
67
|
+
allow,
|
|
68
|
+
className,
|
|
69
|
+
disabled = false,
|
|
70
|
+
labels,
|
|
71
|
+
onFiles,
|
|
72
|
+
onSend,
|
|
73
|
+
placeholder,
|
|
74
|
+
}: ChatComposerProps) {
|
|
75
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
76
|
+
const permit = { file: true, video: true, voice: true, ...allow }
|
|
77
|
+
|
|
78
|
+
const [draft, setDraft] = useState('')
|
|
79
|
+
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
|
80
|
+
const [recording, setRecording] = useState(false)
|
|
81
|
+
const [elapsed, setElapsed] = useState(0)
|
|
82
|
+
const [recordable] = useState(canRecord)
|
|
83
|
+
|
|
84
|
+
const files = useRef<File[]>([])
|
|
85
|
+
const filePicker = useRef<HTMLInputElement>(null)
|
|
86
|
+
const videoPicker = useRef<HTMLInputElement>(null)
|
|
87
|
+
const recorder = useRef<MediaRecorder | null>(null)
|
|
88
|
+
// The duration is measured at stop rather than read off the ticking state:
|
|
89
|
+
// the recorder's own callback closes over whatever `elapsed` was when
|
|
90
|
+
// recording started, which is always zero.
|
|
91
|
+
const startedAt = useRef(0)
|
|
92
|
+
const chunks = useRef<Blob[]>([])
|
|
93
|
+
const textarea = useRef<HTMLTextAreaElement>(null)
|
|
94
|
+
|
|
95
|
+
const stopStream = useCallback(() => {
|
|
96
|
+
recorder.current?.stream.getTracks().forEach((track) => track.stop())
|
|
97
|
+
recorder.current = null
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
useEffect(() => stopStream, [stopStream])
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!recording) return
|
|
104
|
+
const timer = window.setInterval(() => setElapsed((Date.now() - startedAt.current) / 1000), 200)
|
|
105
|
+
return () => window.clearInterval(timer)
|
|
106
|
+
}, [recording])
|
|
107
|
+
|
|
108
|
+
const addFiles = useCallback(
|
|
109
|
+
(picked: FileList | null) => {
|
|
110
|
+
if (!picked?.length) return
|
|
111
|
+
const list = Array.from(picked)
|
|
112
|
+
files.current = [...files.current, ...list]
|
|
113
|
+
setAttachments((was) => [
|
|
114
|
+
...was,
|
|
115
|
+
...list.map((file) => ({
|
|
116
|
+
kind: kindOf(file),
|
|
117
|
+
name: file.name,
|
|
118
|
+
size: file.size,
|
|
119
|
+
url: URL.createObjectURL(file),
|
|
120
|
+
})),
|
|
121
|
+
])
|
|
122
|
+
},
|
|
123
|
+
[],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const startRecording = useCallback(async () => {
|
|
127
|
+
try {
|
|
128
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
129
|
+
const media = new MediaRecorder(stream)
|
|
130
|
+
chunks.current = []
|
|
131
|
+
media.ondataavailable = (event) => {
|
|
132
|
+
if (event.data.size) chunks.current.push(event.data)
|
|
133
|
+
}
|
|
134
|
+
media.onstop = () => {
|
|
135
|
+
const blob = new Blob(chunks.current, { type: media.mimeType })
|
|
136
|
+
const file = new File([blob], 'voice-message', { type: media.mimeType })
|
|
137
|
+
files.current = [...files.current, file]
|
|
138
|
+
setAttachments((was) => [
|
|
139
|
+
...was,
|
|
140
|
+
{
|
|
141
|
+
duration: (Date.now() - startedAt.current) / 1000,
|
|
142
|
+
kind: 'voice',
|
|
143
|
+
size: blob.size,
|
|
144
|
+
url: URL.createObjectURL(blob),
|
|
145
|
+
},
|
|
146
|
+
])
|
|
147
|
+
stopStream()
|
|
148
|
+
}
|
|
149
|
+
recorder.current = media
|
|
150
|
+
media.start()
|
|
151
|
+
startedAt.current = Date.now()
|
|
152
|
+
setElapsed(0)
|
|
153
|
+
setRecording(true)
|
|
154
|
+
} catch {
|
|
155
|
+
// A denied microphone is an answer, not an error to shout about: the
|
|
156
|
+
// viewer said no, and the rest of the composer still works.
|
|
157
|
+
setRecording(false)
|
|
158
|
+
stopStream()
|
|
159
|
+
}
|
|
160
|
+
}, [stopStream])
|
|
161
|
+
|
|
162
|
+
const finishRecording = useCallback(
|
|
163
|
+
(keep: boolean) => {
|
|
164
|
+
const media = recorder.current
|
|
165
|
+
setRecording(false)
|
|
166
|
+
if (!media) return
|
|
167
|
+
if (!keep) media.onstop = stopStream
|
|
168
|
+
media.stop()
|
|
169
|
+
},
|
|
170
|
+
[stopStream],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const removeAttachment = (index: number) => {
|
|
174
|
+
setAttachments((was) => {
|
|
175
|
+
URL.revokeObjectURL(was[index].url)
|
|
176
|
+
return was.filter((_, position) => position !== index)
|
|
177
|
+
})
|
|
178
|
+
files.current = files.current.filter((_, position) => position !== index)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const send = () => {
|
|
182
|
+
if (!draft.trim() && attachments.length === 0) return
|
|
183
|
+
onSend({ attachments, text: draft.trim() })
|
|
184
|
+
onFiles?.(files.current)
|
|
185
|
+
files.current = []
|
|
186
|
+
setAttachments([])
|
|
187
|
+
setDraft('')
|
|
188
|
+
textarea.current?.focus()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const empty = !draft.trim() && attachments.length === 0
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className={cn('nim-composer', className)}>
|
|
195
|
+
{attachments.length ? (
|
|
196
|
+
<ul className="nim-composer__tray">
|
|
197
|
+
{attachments.map((attachment, index) => (
|
|
198
|
+
<li className="nim-composer__chip" key={attachment.url}>
|
|
199
|
+
<Icon
|
|
200
|
+
name={
|
|
201
|
+
attachment.kind === 'voice'
|
|
202
|
+
? 'mic'
|
|
203
|
+
: attachment.kind === 'video'
|
|
204
|
+
? 'video'
|
|
205
|
+
: attachment.kind === 'image'
|
|
206
|
+
? 'camera'
|
|
207
|
+
: 'document'
|
|
208
|
+
}
|
|
209
|
+
size="xs"
|
|
210
|
+
/>
|
|
211
|
+
<span className="nim-composer__chip-name">{attachment.name ?? text.record}</span>
|
|
212
|
+
<IconButton
|
|
213
|
+
label={text.discard}
|
|
214
|
+
name="close"
|
|
215
|
+
onClick={() => removeAttachment(index)}
|
|
216
|
+
size="sm"
|
|
217
|
+
/>
|
|
218
|
+
</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
) : null}
|
|
222
|
+
|
|
223
|
+
<div className="nim-composer__row">
|
|
224
|
+
{recording ? (
|
|
225
|
+
<div className="nim-composer__recording" role="status">
|
|
226
|
+
<span aria-hidden="true" className="nim-composer__pulse" />
|
|
227
|
+
<span className="nim-composer__recording-label">{text.recording}</span>
|
|
228
|
+
<span className="nim-composer__elapsed">{elapsed.toFixed(1)}s</span>
|
|
229
|
+
<IconButton
|
|
230
|
+
label={text.cancel}
|
|
231
|
+
name="close"
|
|
232
|
+
onClick={() => finishRecording(false)}
|
|
233
|
+
size="sm"
|
|
234
|
+
/>
|
|
235
|
+
<IconButton
|
|
236
|
+
label={text.stop}
|
|
237
|
+
name="stop"
|
|
238
|
+
onClick={() => finishRecording(true)}
|
|
239
|
+
size="sm"
|
|
240
|
+
variant="solid"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
) : (
|
|
244
|
+
<>
|
|
245
|
+
{permit.file ? (
|
|
246
|
+
<IconButton
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
label={text.attach}
|
|
249
|
+
name="paperclip"
|
|
250
|
+
onClick={() => filePicker.current?.click()}
|
|
251
|
+
size="sm"
|
|
252
|
+
/>
|
|
253
|
+
) : null}
|
|
254
|
+
{permit.video ? (
|
|
255
|
+
<IconButton
|
|
256
|
+
disabled={disabled}
|
|
257
|
+
label={text.video}
|
|
258
|
+
name="video"
|
|
259
|
+
onClick={() => videoPicker.current?.click()}
|
|
260
|
+
size="sm"
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
<textarea
|
|
265
|
+
className="nim-composer__input"
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
268
|
+
onKeyDown={(event) => {
|
|
269
|
+
// Enter sends, Shift+Enter breaks the line. The other way round
|
|
270
|
+
// costs a keystroke on every message anyone ever sends.
|
|
271
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
272
|
+
event.preventDefault()
|
|
273
|
+
send()
|
|
274
|
+
}
|
|
275
|
+
}}
|
|
276
|
+
placeholder={placeholder}
|
|
277
|
+
ref={textarea}
|
|
278
|
+
rows={1}
|
|
279
|
+
value={draft}
|
|
280
|
+
/>
|
|
281
|
+
|
|
282
|
+
{permit.voice && recordable && empty ? (
|
|
283
|
+
<IconButton
|
|
284
|
+
disabled={disabled}
|
|
285
|
+
label={text.record}
|
|
286
|
+
name="mic"
|
|
287
|
+
onClick={() => void startRecording()}
|
|
288
|
+
size="sm"
|
|
289
|
+
/>
|
|
290
|
+
) : (
|
|
291
|
+
<IconButton
|
|
292
|
+
disabled={disabled || empty}
|
|
293
|
+
label={text.send}
|
|
294
|
+
name="send"
|
|
295
|
+
onClick={send}
|
|
296
|
+
size="sm"
|
|
297
|
+
variant="solid"
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
300
|
+
</>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{/* Hidden pickers rather than styled inputs: the file dialog is the
|
|
305
|
+
platform's, and nothing about it is worth reimplementing. */}
|
|
306
|
+
<input
|
|
307
|
+
accept={accept}
|
|
308
|
+
className="nim-visually-hidden"
|
|
309
|
+
multiple
|
|
310
|
+
onChange={(event) => {
|
|
311
|
+
addFiles(event.target.files)
|
|
312
|
+
event.target.value = ''
|
|
313
|
+
}}
|
|
314
|
+
ref={filePicker}
|
|
315
|
+
tabIndex={-1}
|
|
316
|
+
type="file"
|
|
317
|
+
/>
|
|
318
|
+
<input
|
|
319
|
+
accept="video/*"
|
|
320
|
+
className="nim-visually-hidden"
|
|
321
|
+
onChange={(event) => {
|
|
322
|
+
addFiles(event.target.files)
|
|
323
|
+
event.target.value = ''
|
|
324
|
+
}}
|
|
325
|
+
ref={videoPicker}
|
|
326
|
+
tabIndex={-1}
|
|
327
|
+
type="file"
|
|
328
|
+
/>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|