@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
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nim.zone/ui",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "nim — the front-end design system and UI kit shared across nim products.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"design-system",
|
|
8
|
+
"ui-kit",
|
|
9
|
+
"react",
|
|
10
|
+
"components",
|
|
11
|
+
"tokens",
|
|
12
|
+
"rtl",
|
|
13
|
+
"persian",
|
|
14
|
+
"tailwind",
|
|
15
|
+
"accessible"
|
|
16
|
+
],
|
|
17
|
+
"author": "Nima Sarayan",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://nim.zone/uikit",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/nimasrn/nim.git",
|
|
23
|
+
"directory": "nim-ui"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/nimasrn/nim/issues"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"*.css"
|
|
33
|
+
],
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/nim.js"
|
|
38
|
+
},
|
|
39
|
+
"./styles.css": "./dist/nim.css",
|
|
40
|
+
"./src/theme/index.css": "./src/theme/index.css",
|
|
41
|
+
"./fonts.css": "./src/theme/fonts.css"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"dev": "vite",
|
|
51
|
+
"build": "tsc -b && vite build --mode lib",
|
|
52
|
+
"build:docs": "tsc -b && vite build",
|
|
53
|
+
"preview": "vite preview",
|
|
54
|
+
"lint": "eslint . --max-warnings=0",
|
|
55
|
+
"typecheck": "tsc -b --pretty",
|
|
56
|
+
"prepublishOnly": "npm run build"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": ">=18",
|
|
60
|
+
"react-dom": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"lucide-react": "^0.544.0"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@eslint/js": "^9.39.4",
|
|
67
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
68
|
+
"@types/node": "^26.2.0",
|
|
69
|
+
"@types/react": "^18.3.18",
|
|
70
|
+
"@types/react-dom": "^18.3.5",
|
|
71
|
+
"@vitejs/plugin-react-swc": "^3.9.0",
|
|
72
|
+
"eslint": "^9.39.4",
|
|
73
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
74
|
+
"globals": "^16.4.0",
|
|
75
|
+
"react": "^18.3.1",
|
|
76
|
+
"react-dom": "^18.3.1",
|
|
77
|
+
"tailwindcss": "^4.2.2",
|
|
78
|
+
"typescript": "~5.9.3",
|
|
79
|
+
"typescript-eslint": "^8.59.3",
|
|
80
|
+
"vite": "^6.3.5",
|
|
81
|
+
"vite-plugin-dts": "^4.5.4"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
4
|
+
import { IconButton } from '@/components/icon-button'
|
|
5
|
+
import { cn } from '@/lib/cn'
|
|
6
|
+
|
|
7
|
+
export interface AdminNavItem {
|
|
8
|
+
href?: string
|
|
9
|
+
icon?: IconName
|
|
10
|
+
key: string
|
|
11
|
+
label: ReactNode
|
|
12
|
+
onSelect?: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AdminNavGroup {
|
|
16
|
+
icon?: IconName
|
|
17
|
+
items: AdminNavItem[]
|
|
18
|
+
key: string
|
|
19
|
+
label: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AdminShellProps {
|
|
23
|
+
/** Brand mark at the top of the sidebar. */
|
|
24
|
+
brand?: ReactNode
|
|
25
|
+
children: ReactNode
|
|
26
|
+
className?: string
|
|
27
|
+
/** Under the nav: session state, build version — the things an operator
|
|
28
|
+
checks before believing a screen. */
|
|
29
|
+
sidebarFooter?: ReactNode
|
|
30
|
+
groups: AdminNavGroup[]
|
|
31
|
+
labels?: { menu: string; nav: string; close: string }
|
|
32
|
+
/** `key` of the active item. */
|
|
33
|
+
value: string
|
|
34
|
+
/** Search field, status pills, the signed-in operator. */
|
|
35
|
+
toolbar?: ReactNode
|
|
36
|
+
title?: ReactNode
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_LABELS = {
|
|
40
|
+
close: 'Close menu',
|
|
41
|
+
menu: 'Open menu',
|
|
42
|
+
nav: 'Admin navigation',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The console layout: a grouped sidebar, a topbar, and one scrolling workspace.
|
|
47
|
+
*
|
|
48
|
+
* The counterpart to `AppShell`, and deliberately a different component rather
|
|
49
|
+
* than a `variant`: an operator console is a desktop-first, two-column, deep
|
|
50
|
+
* hierarchy, and a phone app is a single column with five destinations. Sharing
|
|
51
|
+
* one component would mean every screen carrying the other's assumptions.
|
|
52
|
+
*
|
|
53
|
+
* Below the layout's breakpoint the same sidebar becomes a drawer — the same
|
|
54
|
+
* markup, not a second nav to keep in sync, which is how the two drift apart.
|
|
55
|
+
*/
|
|
56
|
+
export function AdminShell({
|
|
57
|
+
brand,
|
|
58
|
+
children,
|
|
59
|
+
className,
|
|
60
|
+
groups,
|
|
61
|
+
labels,
|
|
62
|
+
sidebarFooter,
|
|
63
|
+
title,
|
|
64
|
+
toolbar,
|
|
65
|
+
value,
|
|
66
|
+
}: AdminShellProps) {
|
|
67
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
68
|
+
const [open, setOpen] = useState(false)
|
|
69
|
+
|
|
70
|
+
const nav = (
|
|
71
|
+
<nav aria-label={text.nav} className="nim-admin__nav">
|
|
72
|
+
{groups.map((group) => (
|
|
73
|
+
<div className="nim-admin__group" key={group.key}>
|
|
74
|
+
<p className="nim-admin__group-label">
|
|
75
|
+
{group.icon ? <Icon name={group.icon} size="xs" /> : null}
|
|
76
|
+
{group.label}
|
|
77
|
+
</p>
|
|
78
|
+
{group.items.map((item) => {
|
|
79
|
+
const active = item.key === value
|
|
80
|
+
const content = (
|
|
81
|
+
<>
|
|
82
|
+
{item.icon ? <Icon name={item.icon} size="sm" /> : null}
|
|
83
|
+
<span>{item.label}</span>
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
const props = {
|
|
87
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
88
|
+
className: 'nim-admin__link',
|
|
89
|
+
'data-active': active ? 'true' : undefined,
|
|
90
|
+
onClick: () => {
|
|
91
|
+
item.onSelect?.()
|
|
92
|
+
setOpen(false)
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
return item.href ? (
|
|
96
|
+
<a href={item.href} key={item.key} {...props}>
|
|
97
|
+
{content}
|
|
98
|
+
</a>
|
|
99
|
+
) : (
|
|
100
|
+
<button key={item.key} type="button" {...props}>
|
|
101
|
+
{content}
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
})}
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</nav>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn('nim-admin', className)} data-drawer={open ? 'open' : undefined}>
|
|
112
|
+
<aside className="nim-admin__sidebar">
|
|
113
|
+
{brand ? <div className="nim-admin__brand">{brand}</div> : null}
|
|
114
|
+
{nav}
|
|
115
|
+
{sidebarFooter ? <div className="nim-admin__sidebar-foot">{sidebarFooter}</div> : null}
|
|
116
|
+
</aside>
|
|
117
|
+
|
|
118
|
+
{/* The drawer is the sidebar again, not a second navigation: one source
|
|
119
|
+
for the items, so the two cannot disagree. */}
|
|
120
|
+
<div className="nim-admin__drawer" hidden={!open}>
|
|
121
|
+
<div className="nim-admin__scrim" onClick={() => setOpen(false)} />
|
|
122
|
+
<div className="nim-admin__drawer-panel">
|
|
123
|
+
<div className="nim-admin__drawer-head">
|
|
124
|
+
{brand}
|
|
125
|
+
<IconButton label={text.close} name="close" onClick={() => setOpen(false)} size="sm" />
|
|
126
|
+
</div>
|
|
127
|
+
{nav}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="nim-admin__workspace">
|
|
132
|
+
<header className="nim-admin__topbar">
|
|
133
|
+
<IconButton
|
|
134
|
+
aria-expanded={open}
|
|
135
|
+
className="nim-admin__menu"
|
|
136
|
+
label={text.menu}
|
|
137
|
+
name="menu"
|
|
138
|
+
onClick={() => setOpen(true)}
|
|
139
|
+
size="sm"
|
|
140
|
+
/>
|
|
141
|
+
{title ? <h1 className="nim-admin__title">{title}</h1> : null}
|
|
142
|
+
{toolbar ? <div className="nim-admin__toolbar">{toolbar}</div> : null}
|
|
143
|
+
</header>
|
|
144
|
+
<main className="nim-admin__main">{children}</main>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface DetailHeaderProps {
|
|
151
|
+
/** Buttons for this record: approve, retry, export. */
|
|
152
|
+
actions?: ReactNode
|
|
153
|
+
back?: { href?: string; label: string; onClick?: () => void }
|
|
154
|
+
className?: string
|
|
155
|
+
/** Status badge, owner, id — the facts that qualify the title. */
|
|
156
|
+
meta?: ReactNode
|
|
157
|
+
status?: ReactNode
|
|
158
|
+
subtitle?: ReactNode
|
|
159
|
+
title: ReactNode
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The top of a record: where it sits, what it is, and what can be done to it.
|
|
164
|
+
*
|
|
165
|
+
* The actions live here rather than at the bottom of the page because an
|
|
166
|
+
* operator working a queue acts on the record without reading all of it, and a
|
|
167
|
+
* button below a thousand-row table is a button nobody finds.
|
|
168
|
+
*/
|
|
169
|
+
export function DetailHeader({
|
|
170
|
+
actions,
|
|
171
|
+
back,
|
|
172
|
+
className,
|
|
173
|
+
meta,
|
|
174
|
+
status,
|
|
175
|
+
subtitle,
|
|
176
|
+
title,
|
|
177
|
+
}: DetailHeaderProps) {
|
|
178
|
+
return (
|
|
179
|
+
<header className={cn('nim-detail-header', className)}>
|
|
180
|
+
{back ? (
|
|
181
|
+
back.href ? (
|
|
182
|
+
<a className="nim-detail-header__back" href={back.href}>
|
|
183
|
+
<Icon name="chevron-back" size="sm" />
|
|
184
|
+
{back.label}
|
|
185
|
+
</a>
|
|
186
|
+
) : (
|
|
187
|
+
<button className="nim-detail-header__back" onClick={back.onClick} type="button">
|
|
188
|
+
<Icon name="chevron-back" size="sm" />
|
|
189
|
+
{back.label}
|
|
190
|
+
</button>
|
|
191
|
+
)
|
|
192
|
+
) : null}
|
|
193
|
+
|
|
194
|
+
<div className="nim-detail-header__row">
|
|
195
|
+
<div className="nim-detail-header__text">
|
|
196
|
+
{/* The status sits beside the heading, not inside it: a badge folded
|
|
197
|
+
into an <h1> becomes part of the heading's accessible name, and
|
|
198
|
+
"Payment #48210 Awaiting review" is not what the record is
|
|
199
|
+
called. */}
|
|
200
|
+
<div className="nim-detail-header__headline">
|
|
201
|
+
<h1 className="nim-detail-header__title">{title}</h1>
|
|
202
|
+
{status ? <span className="nim-detail-header__status">{status}</span> : null}
|
|
203
|
+
</div>
|
|
204
|
+
{subtitle ? <p className="nim-detail-header__subtitle">{subtitle}</p> : null}
|
|
205
|
+
{meta ? <p className="nim-detail-header__meta">{meta}</p> : null}
|
|
206
|
+
</div>
|
|
207
|
+
{actions ? <div className="nim-detail-header__actions">{actions}</div> : null}
|
|
208
|
+
</div>
|
|
209
|
+
</header>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface FilterChip {
|
|
214
|
+
key: string
|
|
215
|
+
label: ReactNode
|
|
216
|
+
onRemove: () => void
|
|
217
|
+
/** The value the filter is set to, shown after the label. */
|
|
218
|
+
value?: ReactNode
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface FilterChipsProps {
|
|
222
|
+
chips: FilterChip[]
|
|
223
|
+
className?: string
|
|
224
|
+
clearLabel?: string
|
|
225
|
+
labels?: { remove: (label: string) => string; toolbar: string }
|
|
226
|
+
onClearAll?: () => void
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The filters currently narrowing a table, each removable.
|
|
231
|
+
*
|
|
232
|
+
* It renders nothing when there are none — an empty toolbar reserving space
|
|
233
|
+
* for filters nobody set is the reason these strips feel like chrome. Every
|
|
234
|
+
* chip states what it removes in its accessible name, because "remove" ten
|
|
235
|
+
* times over is no name at all.
|
|
236
|
+
*/
|
|
237
|
+
export function FilterChips({
|
|
238
|
+
chips,
|
|
239
|
+
className,
|
|
240
|
+
clearLabel,
|
|
241
|
+
labels,
|
|
242
|
+
onClearAll,
|
|
243
|
+
}: FilterChipsProps) {
|
|
244
|
+
if (chips.length === 0) return null
|
|
245
|
+
const text = {
|
|
246
|
+
remove: (label: string) => `Remove filter ${label}`,
|
|
247
|
+
toolbar: 'Active filters',
|
|
248
|
+
...labels,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div aria-label={text.toolbar} className={cn('nim-filter-chips', className)} role="toolbar">
|
|
253
|
+
{chips.map((chip) => (
|
|
254
|
+
<span className="nim-filter-chip" key={chip.key}>
|
|
255
|
+
<span className="nim-filter-chip__label">
|
|
256
|
+
{chip.label}
|
|
257
|
+
{chip.value !== undefined ? <>: {chip.value}</> : null}
|
|
258
|
+
</span>
|
|
259
|
+
<button
|
|
260
|
+
aria-label={text.remove(typeof chip.label === 'string' ? chip.label : chip.key)}
|
|
261
|
+
className="nim-filter-chip__remove"
|
|
262
|
+
onClick={chip.onRemove}
|
|
263
|
+
type="button"
|
|
264
|
+
>
|
|
265
|
+
<Icon name="close" size="xs" />
|
|
266
|
+
</button>
|
|
267
|
+
</span>
|
|
268
|
+
))}
|
|
269
|
+
{onClearAll && clearLabel ? (
|
|
270
|
+
<button className="nim-filter-chips__clear" onClick={onClearAll} type="button">
|
|
271
|
+
{clearLabel}
|
|
272
|
+
</button>
|
|
273
|
+
) : null}
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface ActivityEvent {
|
|
279
|
+
/** What happened, in the product's own words. */
|
|
280
|
+
action: ReactNode
|
|
281
|
+
actor?: ReactNode
|
|
282
|
+
/** ISO timestamp. Formatted here, in the viewer's locale. */
|
|
283
|
+
at?: string
|
|
284
|
+
icon?: IconName
|
|
285
|
+
id: string
|
|
286
|
+
target?: ReactNode
|
|
287
|
+
tone?: 'accent' | 'danger' | 'default' | 'success' | 'warning'
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ActivityFeedProps {
|
|
291
|
+
className?: string
|
|
292
|
+
empty?: ReactNode
|
|
293
|
+
events: ActivityEvent[]
|
|
294
|
+
locale?: string
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Who did what, most recent first.
|
|
299
|
+
*
|
|
300
|
+
* Timestamps are absolute rather than "3 hours ago": an audit trail is read to
|
|
301
|
+
* reconstruct a sequence, and a relative time that keeps moving is exactly
|
|
302
|
+
* what you cannot compare two of.
|
|
303
|
+
*/
|
|
304
|
+
export function ActivityFeed({ className, empty, events, locale }: ActivityFeedProps) {
|
|
305
|
+
const when = new Intl.DateTimeFormat(locale, {
|
|
306
|
+
day: 'numeric',
|
|
307
|
+
hour: '2-digit',
|
|
308
|
+
minute: '2-digit',
|
|
309
|
+
month: 'short',
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
if (events.length === 0) {
|
|
313
|
+
return <div className={cn('nim-activity', className)}>{empty}</div>
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<ol className={cn('nim-activity', className)}>
|
|
318
|
+
{events.map((event) => (
|
|
319
|
+
<li className="nim-activity__item" data-tone={event.tone} key={event.id}>
|
|
320
|
+
<span className="nim-activity__marker">
|
|
321
|
+
{event.icon ? <Icon name={event.icon} size="xs" /> : null}
|
|
322
|
+
</span>
|
|
323
|
+
<div className="nim-activity__body">
|
|
324
|
+
<p className="nim-activity__action">
|
|
325
|
+
{event.actor ? <strong>{event.actor}</strong> : null} {event.action}{' '}
|
|
326
|
+
{event.target ? <span className="nim-activity__target">{event.target}</span> : null}
|
|
327
|
+
</p>
|
|
328
|
+
{event.at ? (
|
|
329
|
+
<time className="nim-activity__time" dateTime={event.at}>
|
|
330
|
+
{when.format(new Date(event.at))}
|
|
331
|
+
</time>
|
|
332
|
+
) : null}
|
|
333
|
+
</div>
|
|
334
|
+
</li>
|
|
335
|
+
))}
|
|
336
|
+
</ol>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { TabBar, type TabBarProps } from '@/components/tab-bar'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface AppShellProps {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
className?: string
|
|
8
|
+
/** Sticky top row: a title, a back control, an action. Optional — many
|
|
9
|
+
screens carry their own header inside the content instead. */
|
|
10
|
+
header?: ReactNode
|
|
11
|
+
/** The bottom navigation. Omit it on a screen deeper in the stack: a tab bar
|
|
12
|
+
that stays visible under a detail page invites the viewer to leave. */
|
|
13
|
+
tabs?: TabBarProps
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The frame an app lives in: a sticky header, one scrolling region, and the
|
|
18
|
+
* tab bar.
|
|
19
|
+
*
|
|
20
|
+
* The scroll container is here and nowhere else, which is what makes scroll
|
|
21
|
+
* restoration, pull-to-refresh and "scroll to top on tab change" one thing to
|
|
22
|
+
* implement rather than one per screen. The content region reserves room for
|
|
23
|
+
* the bar it is drawn under, so the last row of a list is never hidden behind
|
|
24
|
+
* it — the failure this component exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
export function AppShell({ children, className, header, tabs }: AppShellProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={cn('nim-app-shell', className)}>
|
|
29
|
+
{header ? <header className="nim-app-shell__header">{header}</header> : null}
|
|
30
|
+
<main className="nim-app-shell__content" data-has-tabs={tabs ? 'true' : undefined}>
|
|
31
|
+
{children}
|
|
32
|
+
</main>
|
|
33
|
+
{tabs ? <TabBar {...tabs} /> : null}
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Button } from '@/components/button'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface AuthScreenProps {
|
|
6
|
+
/** The form itself: a PhoneField, an OtpInput, a PasswordField. */
|
|
7
|
+
children: ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
/** Brand mark above the heading. */
|
|
10
|
+
brand?: ReactNode
|
|
11
|
+
/** Small link back to the previous step — the OTP step's way home. */
|
|
12
|
+
back?: { label: string; onClick: () => void }
|
|
13
|
+
/** Terms line, support link, resend timer. Sits under the CTA. */
|
|
14
|
+
footer?: ReactNode
|
|
15
|
+
subtitle?: ReactNode
|
|
16
|
+
title: ReactNode
|
|
17
|
+
/** The single primary action. A sign-in screen has exactly one. */
|
|
18
|
+
action?: { disabled?: boolean; label: string; loading?: boolean; onClick: () => void }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The frame every step of a sign-in flow shares: brand, one heading, one
|
|
23
|
+
* explanation, the fields, and a single action pinned to the bottom.
|
|
24
|
+
*
|
|
25
|
+
* Keeping it one component is what makes the phone step, the code step and the
|
|
26
|
+
* password step read as one screen changing rather than three screens — and it
|
|
27
|
+
* puts the CTA in the same place each time, which is the part a thumb learns.
|
|
28
|
+
*/
|
|
29
|
+
export function AuthScreen({
|
|
30
|
+
action,
|
|
31
|
+
back,
|
|
32
|
+
brand,
|
|
33
|
+
children,
|
|
34
|
+
className,
|
|
35
|
+
footer,
|
|
36
|
+
subtitle,
|
|
37
|
+
title,
|
|
38
|
+
}: AuthScreenProps) {
|
|
39
|
+
return (
|
|
40
|
+
<section className={cn('nim-auth', className)}>
|
|
41
|
+
{brand ? <div className="nim-auth__brand">{brand}</div> : null}
|
|
42
|
+
|
|
43
|
+
<div className="nim-auth__body">
|
|
44
|
+
{back ? (
|
|
45
|
+
<Button className="nim-auth__back" iconStart="chevron-back" onClick={back.onClick} size="sm" variant="ghost">
|
|
46
|
+
{back.label}
|
|
47
|
+
</Button>
|
|
48
|
+
) : null}
|
|
49
|
+
<h1 className="nim-auth__title">{title}</h1>
|
|
50
|
+
{subtitle ? <p className="nim-auth__subtitle">{subtitle}</p> : null}
|
|
51
|
+
<div className="nim-auth__fields">{children}</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="nim-auth__foot">
|
|
55
|
+
{action ? (
|
|
56
|
+
<Button
|
|
57
|
+
disabled={action.disabled}
|
|
58
|
+
fullWidth
|
|
59
|
+
loading={action.loading}
|
|
60
|
+
onClick={action.onClick}
|
|
61
|
+
size="lg"
|
|
62
|
+
variant="accent"
|
|
63
|
+
>
|
|
64
|
+
{action.label}
|
|
65
|
+
</Button>
|
|
66
|
+
) : null}
|
|
67
|
+
{footer ? <div className="nim-auth__footer">{footer}</div> : null}
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export type AvatarSize = 'lg' | 'md' | 'sm'
|
|
5
|
+
|
|
6
|
+
export interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
|
|
7
|
+
/** Falls back to initials derived from `name` when no image is supplied. */
|
|
8
|
+
name: string
|
|
9
|
+
shape?: 'round' | 'square'
|
|
10
|
+
size?: AvatarSize
|
|
11
|
+
src?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const initials = (name: string) =>
|
|
15
|
+
name
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.slice(0, 2)
|
|
19
|
+
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
20
|
+
.join('')
|
|
21
|
+
|
|
22
|
+
export function Avatar({ className, name, shape = 'round', size = 'md', src, ...props }: AvatarProps) {
|
|
23
|
+
return (
|
|
24
|
+
<span
|
|
25
|
+
className={cn('nim-avatar', size !== 'md' && `nim-avatar--${size}`, shape === 'square' && 'nim-avatar--square', className)}
|
|
26
|
+
{...props}
|
|
27
|
+
>
|
|
28
|
+
{src ? <img alt="" src={src} /> : <span aria-hidden="true">{initials(name)}</span>}
|
|
29
|
+
<span className="nim-visually-hidden">{name}</span>
|
|
30
|
+
</span>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export type BadgeSize = 'md' | 'sm'
|
|
5
|
+
export type BadgeTone = 'outline' | 'soft' | 'solid'
|
|
6
|
+
export type BadgeVariant = 'accent' | 'danger' | 'info' | 'neutral' | 'success' | 'warning'
|
|
7
|
+
|
|
8
|
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
children: ReactNode
|
|
10
|
+
/** Shows a status dot before the label — for live/among-states meanings. */
|
|
11
|
+
dot?: boolean
|
|
12
|
+
pill?: boolean
|
|
13
|
+
size?: BadgeSize
|
|
14
|
+
tone?: BadgeTone
|
|
15
|
+
variant?: BadgeVariant
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Badge({
|
|
19
|
+
children,
|
|
20
|
+
className,
|
|
21
|
+
dot = false,
|
|
22
|
+
pill = false,
|
|
23
|
+
size = 'md',
|
|
24
|
+
tone = 'soft',
|
|
25
|
+
variant = 'neutral',
|
|
26
|
+
...props
|
|
27
|
+
}: BadgeProps) {
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
className={cn(
|
|
31
|
+
'nim-badge',
|
|
32
|
+
`nim-badge--${variant}`,
|
|
33
|
+
`nim-badge--${tone}`,
|
|
34
|
+
size === 'sm' && 'nim-badge--sm',
|
|
35
|
+
pill && 'nim-badge--pill',
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
{dot ? <span aria-hidden="true" className="nim-badge__dot" /> : null}
|
|
41
|
+
{children}
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export type BannerTone = 'accent' | 'danger' | 'info' | 'neutral' | 'success' | 'warning'
|
|
6
|
+
|
|
7
|
+
export interface BannerProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
8
|
+
action?: ReactNode
|
|
9
|
+
children: ReactNode
|
|
10
|
+
icon?: IconName
|
|
11
|
+
title?: ReactNode
|
|
12
|
+
tone?: BannerTone
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ICON: Record<BannerTone, IconName> = {
|
|
16
|
+
accent: 'sparkle',
|
|
17
|
+
danger: 'danger',
|
|
18
|
+
info: 'info',
|
|
19
|
+
neutral: 'info',
|
|
20
|
+
success: 'check-circle',
|
|
21
|
+
warning: 'alert',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Banner({
|
|
25
|
+
action,
|
|
26
|
+
children,
|
|
27
|
+
className,
|
|
28
|
+
icon,
|
|
29
|
+
title,
|
|
30
|
+
tone = 'neutral',
|
|
31
|
+
...props
|
|
32
|
+
}: BannerProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className={cn('nim-banner', `nim-banner--${tone}`, className)}
|
|
36
|
+
role={tone === 'danger' ? 'alert' : 'status'}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
<Icon className="nim-banner__icon" name={icon ?? DEFAULT_ICON[tone]} size="sm" />
|
|
40
|
+
<div className="nim-banner__content">
|
|
41
|
+
{title ? <p className="nim-banner__title">{title}</p> : null}
|
|
42
|
+
<div>{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
{action ? <div>{action}</div> : null}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|