@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -0
  3. package/dist/components/admin-shell.d.ts +121 -0
  4. package/dist/components/app-shell.d.ts +23 -0
  5. package/dist/components/auth-screen.d.ts +33 -0
  6. package/dist/components/avatar.d.ts +10 -0
  7. package/dist/components/badge.d.ts +14 -0
  8. package/dist/components/banner.d.ts +11 -0
  9. package/dist/components/breadcrumb.d.ts +12 -0
  10. package/dist/components/button.d.ts +21 -0
  11. package/dist/components/card.d.ts +15 -0
  12. package/dist/components/chat-composer.d.ts +52 -0
  13. package/dist/components/chat.d.ts +77 -0
  14. package/dist/components/checkout.d.ts +79 -0
  15. package/dist/components/choice.d.ts +15 -0
  16. package/dist/components/combobox.d.ts +31 -0
  17. package/dist/components/date-field.d.ts +58 -0
  18. package/dist/components/dialog.d.ts +20 -0
  19. package/dist/components/empty-state.d.ts +9 -0
  20. package/dist/components/feedback.d.ts +18 -0
  21. package/dist/components/field.d.ts +47 -0
  22. package/dist/components/icon-button.d.ts +12 -0
  23. package/dist/components/icon.d.ts +72 -0
  24. package/dist/components/layout.d.ts +13 -0
  25. package/dist/components/list.d.ts +16 -0
  26. package/dist/components/menu.d.ts +56 -0
  27. package/dist/components/onboarding.d.ts +50 -0
  28. package/dist/components/otp-input.d.ts +29 -0
  29. package/dist/components/pagination.d.ts +12 -0
  30. package/dist/components/password-field.d.ts +33 -0
  31. package/dist/components/phone-field.d.ts +44 -0
  32. package/dist/components/plan-card.d.ts +45 -0
  33. package/dist/components/plan-picker.d.ts +52 -0
  34. package/dist/components/profile-header.d.ts +47 -0
  35. package/dist/components/profile-screen.d.ts +44 -0
  36. package/dist/components/resource-meter.d.ts +22 -0
  37. package/dist/components/section-header.d.ts +8 -0
  38. package/dist/components/segmented.d.ts +18 -0
  39. package/dist/components/sheet.d.ts +18 -0
  40. package/dist/components/sign-in-flow.d.ts +60 -0
  41. package/dist/components/slider.d.ts +10 -0
  42. package/dist/components/stat.d.ts +10 -0
  43. package/dist/components/stepper.d.ts +17 -0
  44. package/dist/components/tab-bar.d.ts +40 -0
  45. package/dist/components/table.d.ts +33 -0
  46. package/dist/components/tabs.d.ts +23 -0
  47. package/dist/components/task-progress.d.ts +38 -0
  48. package/dist/components/theme.d.ts +49 -0
  49. package/dist/components/toast.d.ts +17 -0
  50. package/dist/components/tooltip.d.ts +18 -0
  51. package/dist/components/typography.d.ts +18 -0
  52. package/dist/components/wizard.d.ts +70 -0
  53. package/dist/index.d.ts +104 -0
  54. package/dist/lib/calendars.d.ts +81 -0
  55. package/dist/lib/cn.d.ts +7 -0
  56. package/dist/lib/countries.d.ts +33 -0
  57. package/dist/lib/use-anchor.d.ts +22 -0
  58. package/dist/nim.css +1 -0
  59. package/dist/nim.js +3266 -0
  60. package/package.json +83 -0
  61. package/src/components/admin-shell.tsx +338 -0
  62. package/src/components/app-shell.tsx +36 -0
  63. package/src/components/auth-screen.tsx +71 -0
  64. package/src/components/avatar.tsx +32 -0
  65. package/src/components/badge.tsx +44 -0
  66. package/src/components/banner.tsx +47 -0
  67. package/src/components/breadcrumb.tsx +45 -0
  68. package/src/components/button.tsx +64 -0
  69. package/src/components/card.tsx +46 -0
  70. package/src/components/chat-composer.tsx +331 -0
  71. package/src/components/chat.tsx +352 -0
  72. package/src/components/checkout.tsx +173 -0
  73. package/src/components/choice.tsx +46 -0
  74. package/src/components/combobox.tsx +157 -0
  75. package/src/components/date-field.tsx +394 -0
  76. package/src/components/dialog.tsx +77 -0
  77. package/src/components/empty-state.tsx +23 -0
  78. package/src/components/feedback.tsx +61 -0
  79. package/src/components/field.tsx +177 -0
  80. package/src/components/icon-button.tsx +35 -0
  81. package/src/components/icon.tsx +167 -0
  82. package/src/components/layout.tsx +38 -0
  83. package/src/components/list.tsx +78 -0
  84. package/src/components/menu.tsx +172 -0
  85. package/src/components/onboarding.tsx +157 -0
  86. package/src/components/otp-input.tsx +144 -0
  87. package/src/components/pagination.tsx +86 -0
  88. package/src/components/password-field.tsx +112 -0
  89. package/src/components/phone-field.tsx +242 -0
  90. package/src/components/plan-card.tsx +124 -0
  91. package/src/components/plan-picker.tsx +144 -0
  92. package/src/components/profile-header.tsx +134 -0
  93. package/src/components/profile-screen.tsx +104 -0
  94. package/src/components/resource-meter.tsx +60 -0
  95. package/src/components/section-header.tsx +31 -0
  96. package/src/components/segmented.tsx +53 -0
  97. package/src/components/sheet.tsx +74 -0
  98. package/src/components/sign-in-flow.tsx +322 -0
  99. package/src/components/slider.tsx +50 -0
  100. package/src/components/stat.tsx +30 -0
  101. package/src/components/stepper.tsx +77 -0
  102. package/src/components/tab-bar.tsx +82 -0
  103. package/src/components/table.tsx +81 -0
  104. package/src/components/tabs.tsx +72 -0
  105. package/src/components/task-progress.tsx +111 -0
  106. package/src/components/theme.tsx +112 -0
  107. package/src/components/toast.tsx +87 -0
  108. package/src/components/tooltip.tsx +30 -0
  109. package/src/components/typography.tsx +65 -0
  110. package/src/components/wizard.tsx +192 -0
  111. package/src/index.ts +216 -0
  112. package/src/lib/calendars.ts +227 -0
  113. package/src/lib/cn.ts +8 -0
  114. package/src/lib/countries.ts +110 -0
  115. package/src/lib/use-anchor.ts +92 -0
  116. package/src/theme/colorways/coral.css +62 -0
  117. package/src/theme/colorways/oxblood.css +23 -0
  118. package/src/theme/colorways/paper.css +68 -0
  119. package/src/theme/colorways/teal.css +56 -0
  120. package/src/theme/colorways/vermilion.css +21 -0
  121. package/src/theme/components.css +4606 -0
  122. package/src/theme/contract.css +204 -0
  123. package/src/theme/fonts.css +67 -0
  124. package/src/theme/index.css +33 -0
  125. package/src/theme/persian.css +47 -0
  126. package/src/theme/reset.css +118 -0
  127. package/src/theme/styles/ledger.css +106 -0
  128. package/src/theme/styles/vlora.css +91 -0
  129. 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
+ }