@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,61 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
label?: string
|
|
6
|
+
size?: 'lg' | 'md' | 'sm'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Spinner({ className, label = 'Loading', size = 'md', ...props }: SpinnerProps) {
|
|
10
|
+
return (
|
|
11
|
+
<span
|
|
12
|
+
className={cn('nim-spinner', size !== 'md' && `nim-spinner--${size}`, className)}
|
|
13
|
+
role="status"
|
|
14
|
+
{...props}
|
|
15
|
+
>
|
|
16
|
+
<span className="nim-visually-hidden">{label}</span>
|
|
17
|
+
</span>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
|
|
22
|
+
label?: string
|
|
23
|
+
/** Omit for an indeterminate bar. */
|
|
24
|
+
value?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Progress({ className, label, value, ...props }: ProgressProps) {
|
|
28
|
+
const indeterminate = value === undefined
|
|
29
|
+
const clamped = indeterminate ? 0 : Math.min(100, Math.max(0, value))
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
aria-label={label}
|
|
34
|
+
aria-valuemax={100}
|
|
35
|
+
aria-valuemin={0}
|
|
36
|
+
aria-valuenow={indeterminate ? undefined : clamped}
|
|
37
|
+
className={cn('nim-progress', indeterminate && 'nim-progress--indeterminate', className)}
|
|
38
|
+
role="progressbar"
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
<div className="nim-progress__fill" style={indeterminate ? undefined : { inlineSize: `${clamped}%` }} />
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SkeletonProps extends HTMLAttributes<HTMLSpanElement> {
|
|
47
|
+
height?: number | string
|
|
48
|
+
radius?: string
|
|
49
|
+
width?: number | string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Skeleton({ className, height = '1em', radius, width = '100%', ...props }: SkeletonProps) {
|
|
53
|
+
return (
|
|
54
|
+
<span
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
className={cn('nim-skeleton', className)}
|
|
57
|
+
style={{ blockSize: height, borderRadius: radius, inlineSize: width }}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
|
|
3
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
|
|
6
|
+
export interface FieldShellProps {
|
|
7
|
+
children: (ids: { control: string; describedBy?: string }) => ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
error?: string
|
|
10
|
+
hint?: string
|
|
11
|
+
id?: string
|
|
12
|
+
label?: string
|
|
13
|
+
required?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every nim form control shares this frame, which is what guarantees that a
|
|
18
|
+
* label, a hint, and an error are wired to the control with the right ids on
|
|
19
|
+
* every screen — the part teams most often get wrong by hand.
|
|
20
|
+
*/
|
|
21
|
+
export function FieldShell({ children, className, error, hint, id, label, required }: FieldShellProps) {
|
|
22
|
+
const generated = useId()
|
|
23
|
+
const control = id ?? `nim-${generated}`
|
|
24
|
+
const hintId = hint ? `${control}-hint` : undefined
|
|
25
|
+
const errorId = error ? `${control}-error` : undefined
|
|
26
|
+
const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn('nim-field', error && 'nim-field--invalid', className)}>
|
|
30
|
+
{label ? (
|
|
31
|
+
<label className="nim-field__label" htmlFor={control}>
|
|
32
|
+
{label}
|
|
33
|
+
{required ? (
|
|
34
|
+
<span aria-hidden="true" className="nim-field__required">
|
|
35
|
+
*
|
|
36
|
+
</span>
|
|
37
|
+
) : null}
|
|
38
|
+
</label>
|
|
39
|
+
) : null}
|
|
40
|
+
{children({ control, describedBy })}
|
|
41
|
+
{error ? (
|
|
42
|
+
<p className="nim-field__error" id={errorId}>
|
|
43
|
+
{error}
|
|
44
|
+
</p>
|
|
45
|
+
) : null}
|
|
46
|
+
{hint && !error ? (
|
|
47
|
+
<p className="nim-field__hint" id={hintId}>
|
|
48
|
+
{hint}
|
|
49
|
+
</p>
|
|
50
|
+
) : null}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
|
56
|
+
error?: string
|
|
57
|
+
hint?: string
|
|
58
|
+
iconEnd?: IconName
|
|
59
|
+
iconStart?: IconName
|
|
60
|
+
label?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function Input({ className, error, hint, iconEnd, iconStart, id, label, required, ...props }: InputProps) {
|
|
64
|
+
return (
|
|
65
|
+
<FieldShell error={error} hint={hint} id={id} label={label} required={required}>
|
|
66
|
+
{({ control, describedBy }) => (
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
'nim-input-shell',
|
|
70
|
+
iconStart && 'nim-input-shell--has-start',
|
|
71
|
+
iconEnd && 'nim-input-shell--has-end',
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{iconStart ? (
|
|
75
|
+
<span className="nim-input-shell__affix nim-input-shell__affix--start">
|
|
76
|
+
<Icon name={iconStart} size="sm" />
|
|
77
|
+
</span>
|
|
78
|
+
) : null}
|
|
79
|
+
<input
|
|
80
|
+
aria-describedby={describedBy}
|
|
81
|
+
aria-invalid={error ? true : undefined}
|
|
82
|
+
className={cn('nim-input', className)}
|
|
83
|
+
id={control}
|
|
84
|
+
required={required}
|
|
85
|
+
{...props}
|
|
86
|
+
/>
|
|
87
|
+
{iconEnd ? (
|
|
88
|
+
<span className="nim-input-shell__affix nim-input-shell__affix--end">
|
|
89
|
+
<Icon name={iconEnd} size="sm" />
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</FieldShell>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
99
|
+
error?: string
|
|
100
|
+
hint?: string
|
|
101
|
+
label?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function Textarea({ className, error, hint, id, label, required, rows = 4, ...props }: TextareaProps) {
|
|
105
|
+
return (
|
|
106
|
+
<FieldShell error={error} hint={hint} id={id} label={label} required={required}>
|
|
107
|
+
{({ control, describedBy }) => (
|
|
108
|
+
<textarea
|
|
109
|
+
aria-describedby={describedBy}
|
|
110
|
+
aria-invalid={error ? true : undefined}
|
|
111
|
+
className={cn('nim-textarea', className)}
|
|
112
|
+
id={control}
|
|
113
|
+
required={required}
|
|
114
|
+
rows={rows}
|
|
115
|
+
{...props}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
</FieldShell>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SelectOption {
|
|
123
|
+
disabled?: boolean
|
|
124
|
+
label: string
|
|
125
|
+
value: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
|
129
|
+
error?: string
|
|
130
|
+
hint?: string
|
|
131
|
+
label?: string
|
|
132
|
+
options: SelectOption[]
|
|
133
|
+
placeholder?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function Select({
|
|
137
|
+
className,
|
|
138
|
+
error,
|
|
139
|
+
hint,
|
|
140
|
+
id,
|
|
141
|
+
label,
|
|
142
|
+
options,
|
|
143
|
+
placeholder,
|
|
144
|
+
required,
|
|
145
|
+
...props
|
|
146
|
+
}: SelectProps) {
|
|
147
|
+
return (
|
|
148
|
+
<FieldShell error={error} hint={hint} id={id} label={label} required={required}>
|
|
149
|
+
{({ control, describedBy }) => (
|
|
150
|
+
<div className="nim-input-shell nim-input-shell--has-end">
|
|
151
|
+
<select
|
|
152
|
+
aria-describedby={describedBy}
|
|
153
|
+
aria-invalid={error ? true : undefined}
|
|
154
|
+
className={cn('nim-select', className)}
|
|
155
|
+
id={control}
|
|
156
|
+
required={required}
|
|
157
|
+
{...props}
|
|
158
|
+
>
|
|
159
|
+
{placeholder ? (
|
|
160
|
+
<option value="" disabled>
|
|
161
|
+
{placeholder}
|
|
162
|
+
</option>
|
|
163
|
+
) : null}
|
|
164
|
+
{options.map((option) => (
|
|
165
|
+
<option disabled={option.disabled} key={option.value} value={option.value}>
|
|
166
|
+
{option.label}
|
|
167
|
+
</option>
|
|
168
|
+
))}
|
|
169
|
+
</select>
|
|
170
|
+
<span className="nim-input-shell__affix nim-input-shell__affix--end">
|
|
171
|
+
<Icon name="chevron-down" size="sm" />
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</FieldShell>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes } from 'react'
|
|
3
|
+
import { Icon, type IconName, type IconSize } from '@/components/icon'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
|
|
6
|
+
export type IconButtonSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
export type IconButtonVariant = 'ghost' | 'outline' | 'solid'
|
|
8
|
+
|
|
9
|
+
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
10
|
+
/** Required: an icon-only control has no other accessible name. */
|
|
11
|
+
label: string
|
|
12
|
+
name: IconName
|
|
13
|
+
size?: IconButtonSize
|
|
14
|
+
variant?: IconButtonVariant
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GLYPH_SIZE: Record<IconButtonSize, IconSize> = { sm: 'sm', md: 'md', lg: 'md' }
|
|
18
|
+
|
|
19
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
20
|
+
{ className, label, name, size = 'md', type = 'button', variant = 'ghost', ...props },
|
|
21
|
+
ref,
|
|
22
|
+
) {
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
aria-label={label}
|
|
26
|
+
className={cn('nim-icon-button', `nim-icon-button--${variant}`, `nim-icon-button--${size}`, className)}
|
|
27
|
+
ref={ref}
|
|
28
|
+
title={label}
|
|
29
|
+
type={type}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
<Icon name={name} size={GLYPH_SIZE[size]} />
|
|
33
|
+
</button>
|
|
34
|
+
)
|
|
35
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertTriangle,
|
|
3
|
+
ArrowRight,
|
|
4
|
+
Bell,
|
|
5
|
+
Bookmark,
|
|
6
|
+
Calendar,
|
|
7
|
+
Camera,
|
|
8
|
+
Check,
|
|
9
|
+
ChevronDown,
|
|
10
|
+
ChevronLeft,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronUp,
|
|
13
|
+
CircleAlert,
|
|
14
|
+
CircleStop,
|
|
15
|
+
CircleCheck,
|
|
16
|
+
Clock,
|
|
17
|
+
Copy,
|
|
18
|
+
Download,
|
|
19
|
+
ExternalLink,
|
|
20
|
+
Eye,
|
|
21
|
+
FileText,
|
|
22
|
+
Filter,
|
|
23
|
+
Heart,
|
|
24
|
+
Home,
|
|
25
|
+
Info,
|
|
26
|
+
Loader,
|
|
27
|
+
LogOut,
|
|
28
|
+
Menu,
|
|
29
|
+
Mic,
|
|
30
|
+
Minus,
|
|
31
|
+
Moon,
|
|
32
|
+
Paperclip,
|
|
33
|
+
Pause,
|
|
34
|
+
Pencil,
|
|
35
|
+
Play,
|
|
36
|
+
Plus,
|
|
37
|
+
Search,
|
|
38
|
+
Send,
|
|
39
|
+
Settings,
|
|
40
|
+
Share2,
|
|
41
|
+
Sparkles,
|
|
42
|
+
Star,
|
|
43
|
+
Sun,
|
|
44
|
+
Trash2,
|
|
45
|
+
TrendingDown,
|
|
46
|
+
TrendingUp,
|
|
47
|
+
Upload,
|
|
48
|
+
Video,
|
|
49
|
+
User,
|
|
50
|
+
Wallet,
|
|
51
|
+
X,
|
|
52
|
+
} from 'lucide-react'
|
|
53
|
+
import type { LucideIcon } from 'lucide-react'
|
|
54
|
+
import type { SVGProps } from 'react'
|
|
55
|
+
import { cn } from '@/lib/cn'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Icons are addressed by role, not by vendor name. The registry is the whole
|
|
59
|
+
* point: it keeps the icon set finite and reviewable, it stops two screens
|
|
60
|
+
* meaning "delete" with two different glyphs, and it means swapping icon
|
|
61
|
+
* libraries later is one file rather than a codebase sweep.
|
|
62
|
+
*/
|
|
63
|
+
const REGISTRY = {
|
|
64
|
+
alert: AlertTriangle,
|
|
65
|
+
'arrow-forward': ArrowRight,
|
|
66
|
+
bell: Bell,
|
|
67
|
+
bookmark: Bookmark,
|
|
68
|
+
calendar: Calendar,
|
|
69
|
+
camera: Camera,
|
|
70
|
+
check: Check,
|
|
71
|
+
'check-circle': CircleCheck,
|
|
72
|
+
'chevron-back': ChevronLeft,
|
|
73
|
+
'chevron-down': ChevronDown,
|
|
74
|
+
'chevron-forward': ChevronRight,
|
|
75
|
+
'chevron-up': ChevronUp,
|
|
76
|
+
clock: Clock,
|
|
77
|
+
close: X,
|
|
78
|
+
copy: Copy,
|
|
79
|
+
danger: CircleAlert,
|
|
80
|
+
document: FileText,
|
|
81
|
+
download: Download,
|
|
82
|
+
edit: Pencil,
|
|
83
|
+
external: ExternalLink,
|
|
84
|
+
eye: Eye,
|
|
85
|
+
filter: Filter,
|
|
86
|
+
heart: Heart,
|
|
87
|
+
home: Home,
|
|
88
|
+
info: Info,
|
|
89
|
+
loading: Loader,
|
|
90
|
+
menu: Menu,
|
|
91
|
+
mic: Mic,
|
|
92
|
+
minus: Minus,
|
|
93
|
+
moon: Moon,
|
|
94
|
+
paperclip: Paperclip,
|
|
95
|
+
pause: Pause,
|
|
96
|
+
play: Play,
|
|
97
|
+
plus: Plus,
|
|
98
|
+
search: Search,
|
|
99
|
+
send: Send,
|
|
100
|
+
settings: Settings,
|
|
101
|
+
share: Share2,
|
|
102
|
+
'sign-out': LogOut,
|
|
103
|
+
stop: CircleStop,
|
|
104
|
+
sparkle: Sparkles,
|
|
105
|
+
star: Star,
|
|
106
|
+
sun: Sun,
|
|
107
|
+
trash: Trash2,
|
|
108
|
+
'trend-down': TrendingDown,
|
|
109
|
+
'trend-up': TrendingUp,
|
|
110
|
+
upload: Upload,
|
|
111
|
+
video: Video,
|
|
112
|
+
user: User,
|
|
113
|
+
wallet: Wallet,
|
|
114
|
+
} satisfies Record<string, LucideIcon>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Icons that point somewhere. These mirror under `dir="rtl"`, because a
|
|
118
|
+
* "forward" arrow in Persian points left; everything else — a plus, a check, a
|
|
119
|
+
* clock — means the same thing in both directions and must NOT be flipped.
|
|
120
|
+
* Getting this wrong per-component is why RTL interfaces end up with mirrored
|
|
121
|
+
* checkmarks, so the decision lives here, once.
|
|
122
|
+
*/
|
|
123
|
+
const DIRECTIONAL = new Set<string>([
|
|
124
|
+
'arrow-forward',
|
|
125
|
+
'chevron-back',
|
|
126
|
+
'chevron-forward',
|
|
127
|
+
'external',
|
|
128
|
+
'send',
|
|
129
|
+
'share',
|
|
130
|
+
'sign-out',
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
export type IconName = keyof typeof REGISTRY
|
|
134
|
+
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
135
|
+
export type IconTone = 'accent' | 'danger' | 'default' | 'muted' | 'success' | 'warning'
|
|
136
|
+
|
|
137
|
+
const SIZES: Record<IconSize, number> = { xs: 14, sm: 16, md: 20, lg: 24, xl: 32 }
|
|
138
|
+
|
|
139
|
+
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'ref'> {
|
|
140
|
+
/** Give a label only when the icon is the sole carrier of meaning. */
|
|
141
|
+
label?: string
|
|
142
|
+
name: IconName
|
|
143
|
+
size?: IconSize
|
|
144
|
+
tone?: IconTone
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function Icon({ className, label, name, size = 'md', tone = 'default', ...props }: IconProps) {
|
|
148
|
+
const Glyph = REGISTRY[name]
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Glyph
|
|
152
|
+
aria-hidden={label ? undefined : true}
|
|
153
|
+
aria-label={label}
|
|
154
|
+
className={cn('nim-icon', className)}
|
|
155
|
+
data-flip={DIRECTIONAL.has(name) ? 'true' : undefined}
|
|
156
|
+
data-tone={tone === 'default' ? undefined : tone}
|
|
157
|
+
focusable="false"
|
|
158
|
+
height={SIZES[size]}
|
|
159
|
+
role={label ? 'img' : undefined}
|
|
160
|
+
strokeWidth={1.75}
|
|
161
|
+
width={SIZES[size]}
|
|
162
|
+
{...props}
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const iconNames = Object.keys(REGISTRY) as IconName[]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
interface BoxProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
children: ReactNode
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The mobile app viewport, centred on a desktop screen. */
|
|
9
|
+
export function AppFrame({ children, className, ...props }: BoxProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={cn('nim-app-frame', className)} {...props}>
|
|
12
|
+
{children}
|
|
13
|
+
</div>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Vertical rhythm. Spacing between components belongs to the page, not to
|
|
18
|
+
the components, and this is where the page expresses it. */
|
|
19
|
+
export function Stack({
|
|
20
|
+
children,
|
|
21
|
+
className,
|
|
22
|
+
gap = 'md',
|
|
23
|
+
...props
|
|
24
|
+
}: BoxProps & { gap?: 'loose' | 'md' | 'tight' }) {
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn('nim-stack', gap !== 'md' && `nim-stack--${gap}`, className)} {...props}>
|
|
27
|
+
{children}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function Inline({ children, className, ...props }: BoxProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className={cn('nim-inline', className)} {...props}>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface ListProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
/** Drops the surrounding plate and keeps only the dividers. */
|
|
8
|
+
plain?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function List({ children, className, plain = false, ...props }: ListProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className={cn('nim-list', plain && 'nim-list--plain', className)} {...props}>
|
|
14
|
+
{children}
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ListRowProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|
20
|
+
/** Renders the row as a link. Mutually exclusive with `onClick`. */
|
|
21
|
+
href?: string
|
|
22
|
+
leading?: ReactNode
|
|
23
|
+
subtitle?: ReactNode
|
|
24
|
+
title: ReactNode
|
|
25
|
+
trailing?: ReactNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ListRow({
|
|
29
|
+
className,
|
|
30
|
+
href,
|
|
31
|
+
leading,
|
|
32
|
+
onClick,
|
|
33
|
+
subtitle,
|
|
34
|
+
title,
|
|
35
|
+
trailing,
|
|
36
|
+
...props
|
|
37
|
+
}: ListRowProps) {
|
|
38
|
+
const interactive = Boolean(href || onClick)
|
|
39
|
+
const content = (
|
|
40
|
+
<>
|
|
41
|
+
{leading ? <span className="nim-list-row__leading">{leading}</span> : null}
|
|
42
|
+
<span className="nim-list-row__content">
|
|
43
|
+
<span className="nim-list-row__title">{title}</span>
|
|
44
|
+
{subtitle ? <span className="nim-list-row__subtitle">{subtitle}</span> : null}
|
|
45
|
+
</span>
|
|
46
|
+
{trailing ? <span className="nim-list-row__trailing">{trailing}</span> : null}
|
|
47
|
+
{interactive && !trailing ? (
|
|
48
|
+
<Icon className="nim-list-row__chevron" name="chevron-forward" size="sm" />
|
|
49
|
+
) : null}
|
|
50
|
+
</>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const classes = cn('nim-list-row', interactive && 'nim-list-row--interactive', className)
|
|
54
|
+
|
|
55
|
+
// A row that does something is a real button or link, never a div with a
|
|
56
|
+
// click handler — that is what gives it keyboard focus for free.
|
|
57
|
+
if (href) {
|
|
58
|
+
return (
|
|
59
|
+
<a className={classes} href={href} {...props}>
|
|
60
|
+
{content}
|
|
61
|
+
</a>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (onClick) {
|
|
66
|
+
return (
|
|
67
|
+
<button className={classes} onClick={onClick} type="button" {...props}>
|
|
68
|
+
{content}
|
|
69
|
+
</button>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={classes} {...props}>
|
|
75
|
+
{content}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|