@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,30 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface StatProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/** Signed change, e.g. `+12%`. Direction colours it and picks the arrow. */
|
|
7
|
+
delta?: string
|
|
8
|
+
deltaDirection?: 'down' | 'up'
|
|
9
|
+
label: ReactNode
|
|
10
|
+
unit?: ReactNode
|
|
11
|
+
value: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Stat({ className, delta, deltaDirection = 'up', label, unit, value, ...props }: StatProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={cn('nim-stat', className)} {...props}>
|
|
17
|
+
<p className="nim-stat__value">
|
|
18
|
+
{value}
|
|
19
|
+
{unit ? <span className="nim-stat__unit">{unit}</span> : null}
|
|
20
|
+
</p>
|
|
21
|
+
<p className="nim-label nim-stat__label">{label}</p>
|
|
22
|
+
{delta ? (
|
|
23
|
+
<p className="nim-stat__delta" data-direction={deltaDirection}>
|
|
24
|
+
<Icon name={deltaDirection === 'up' ? 'trend-up' : 'trend-down'} size="xs" />
|
|
25
|
+
{delta}
|
|
26
|
+
</p>
|
|
27
|
+
) : null}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Icon } from '@/components/icon'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export interface StepperProps {
|
|
5
|
+
className?: string
|
|
6
|
+
decrementLabel?: string
|
|
7
|
+
incrementLabel?: string
|
|
8
|
+
label: string
|
|
9
|
+
max?: number
|
|
10
|
+
min?: number
|
|
11
|
+
onChange: (value: number) => void
|
|
12
|
+
step?: number
|
|
13
|
+
value: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A number with two full-size targets. Both buttons are control-height
|
|
18
|
+
* squares, so the pair clears the touch minimum rather than shrinking into the
|
|
19
|
+
* cramped ± chevrons this control usually becomes.
|
|
20
|
+
*/
|
|
21
|
+
export function Stepper({
|
|
22
|
+
className,
|
|
23
|
+
decrementLabel = 'Decrease',
|
|
24
|
+
incrementLabel = 'Increase',
|
|
25
|
+
label,
|
|
26
|
+
max = Number.MAX_SAFE_INTEGER,
|
|
27
|
+
min = 0,
|
|
28
|
+
onChange,
|
|
29
|
+
step = 1,
|
|
30
|
+
value,
|
|
31
|
+
}: StepperProps) {
|
|
32
|
+
const clamp = (next: number) => Math.min(Math.max(next, min), max)
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
aria-label={label}
|
|
37
|
+
aria-valuemax={max}
|
|
38
|
+
aria-valuemin={min}
|
|
39
|
+
aria-valuenow={value}
|
|
40
|
+
className={cn('nim-stepper', className)}
|
|
41
|
+
role="spinbutton"
|
|
42
|
+
tabIndex={0}
|
|
43
|
+
onKeyDown={(event) => {
|
|
44
|
+
if (event.key === 'ArrowUp') {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
onChange(clamp(value + step))
|
|
47
|
+
}
|
|
48
|
+
if (event.key === 'ArrowDown') {
|
|
49
|
+
event.preventDefault()
|
|
50
|
+
onChange(clamp(value - step))
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<button
|
|
55
|
+
aria-label={decrementLabel}
|
|
56
|
+
className="nim-stepper__button"
|
|
57
|
+
disabled={value <= min}
|
|
58
|
+
onClick={() => onChange(clamp(value - step))}
|
|
59
|
+
tabIndex={-1}
|
|
60
|
+
type="button"
|
|
61
|
+
>
|
|
62
|
+
<Icon name="minus" size="sm" />
|
|
63
|
+
</button>
|
|
64
|
+
<span className="nim-stepper__value">{value}</span>
|
|
65
|
+
<button
|
|
66
|
+
aria-label={incrementLabel}
|
|
67
|
+
className="nim-stepper__button"
|
|
68
|
+
disabled={value >= max}
|
|
69
|
+
onClick={() => onChange(clamp(value + step))}
|
|
70
|
+
tabIndex={-1}
|
|
71
|
+
type="button"
|
|
72
|
+
>
|
|
73
|
+
<Icon name="plus" size="sm" />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface TabBarItem {
|
|
6
|
+
/** Lifted and filled: the one action the product is for. At most one item
|
|
7
|
+
should carry it — two centres is no centre. */
|
|
8
|
+
center?: boolean
|
|
9
|
+
href?: string
|
|
10
|
+
icon: IconName
|
|
11
|
+
/** Full name for assistive tech when `label` is shortened to fit. */
|
|
12
|
+
fullLabel?: string
|
|
13
|
+
key: string
|
|
14
|
+
label: string
|
|
15
|
+
onSelect?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TabBarProps {
|
|
19
|
+
/** Accessible name for the bar itself. */
|
|
20
|
+
label: string
|
|
21
|
+
className?: string
|
|
22
|
+
items: TabBarItem[]
|
|
23
|
+
/** `key` of the active item. */
|
|
24
|
+
value: string
|
|
25
|
+
/** Wrap items in the app's own router link. Given the item and the rendered
|
|
26
|
+
content, returns the element to use — nim never imports a router. */
|
|
27
|
+
renderItem?: (item: TabBarItem, content: ReactNode, props: Record<string, unknown>) => ReactNode
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The floating bottom navigation of a mobile app: three to five destinations,
|
|
32
|
+
* one of them optionally lifted into a primary action.
|
|
33
|
+
*
|
|
34
|
+
* It renders real links or buttons and marks the active one with
|
|
35
|
+
* `aria-current="page"`, so the bar is navigable by keyboard and readable by
|
|
36
|
+
* assistive tech. Routing stays outside: `renderItem` hands the caller the
|
|
37
|
+
* content and the props to spread onto their own `<Link>`, which is why the
|
|
38
|
+
* kit ships no router dependency.
|
|
39
|
+
*
|
|
40
|
+
* It sits above the safe-area inset rather than under the home indicator, and
|
|
41
|
+
* the page below owes it `padding-block-end` — a bar that covers the last row
|
|
42
|
+
* of a list is the most common way this pattern goes wrong.
|
|
43
|
+
*/
|
|
44
|
+
export function TabBar({ className, items, label, renderItem, value }: TabBarProps) {
|
|
45
|
+
return (
|
|
46
|
+
<nav aria-label={label} className={cn('nim-tab-bar', className)}>
|
|
47
|
+
<div className="nim-tab-bar__row" style={{ '--nim-tab-count': items.length } as CSSProperties}>
|
|
48
|
+
{items.map((item) => {
|
|
49
|
+
const active = item.key === value
|
|
50
|
+
const content = (
|
|
51
|
+
<>
|
|
52
|
+
<Icon name={item.icon} size={item.center ? 'lg' : 'md'} />
|
|
53
|
+
<span className="nim-tab-bar__label">{item.label}</span>
|
|
54
|
+
</>
|
|
55
|
+
)
|
|
56
|
+
const props = {
|
|
57
|
+
'aria-current': active ? ('page' as const) : undefined,
|
|
58
|
+
'aria-label': item.fullLabel ?? item.label,
|
|
59
|
+
className: cn('nim-tab-bar__item', item.center && 'nim-tab-bar__item--center'),
|
|
60
|
+
'data-active': active ? 'true' : undefined,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (renderItem) return (
|
|
64
|
+
<div className="nim-tab-bar__slot" key={item.key}>
|
|
65
|
+
{renderItem(item, content, props)}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return item.href ? (
|
|
70
|
+
<a href={item.href} key={item.key} {...props}>
|
|
71
|
+
{content}
|
|
72
|
+
</a>
|
|
73
|
+
) : (
|
|
74
|
+
<button key={item.key} onClick={item.onSelect} type="button" {...props}>
|
|
75
|
+
{content}
|
|
76
|
+
</button>
|
|
77
|
+
)
|
|
78
|
+
})}
|
|
79
|
+
</div>
|
|
80
|
+
</nav>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Icon } from '@/components/icon'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export type SortDirection = 'ascending' | 'descending'
|
|
6
|
+
|
|
7
|
+
export interface TableColumn<Row> {
|
|
8
|
+
/** Aligns to the trailing edge in tabular figures. Use it for money, counts,
|
|
9
|
+
and dates — anything a reader compares down a column. */
|
|
10
|
+
numeric?: boolean
|
|
11
|
+
header: ReactNode
|
|
12
|
+
key: string
|
|
13
|
+
render: (row: Row) => ReactNode
|
|
14
|
+
sortable?: boolean
|
|
15
|
+
width?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TableProps<Row> {
|
|
19
|
+
caption?: string
|
|
20
|
+
className?: string
|
|
21
|
+
columns: TableColumn<Row>[]
|
|
22
|
+
onSort?: (key: string) => void
|
|
23
|
+
rowKey: (row: Row) => string
|
|
24
|
+
rows: Row[]
|
|
25
|
+
sort?: { direction: SortDirection; key: string }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A table, not a grid of divs: the caller supplies columns and rows and gets
|
|
30
|
+
* real `<table>` semantics, which is what lets a screen reader announce "row 3
|
|
31
|
+
* of 84, Amount 4,200".
|
|
32
|
+
*
|
|
33
|
+
* Row height follows `--nim-density`, the same multiplier that drives control
|
|
34
|
+
* heights — the reason density is a token rather than a prop here.
|
|
35
|
+
*/
|
|
36
|
+
export function Table<Row>({ caption, className, columns, onSort, rowKey, rows, sort }: TableProps<Row>) {
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn('nim-table-wrap', className)}>
|
|
39
|
+
<table className="nim-table">
|
|
40
|
+
{caption ? <caption className="nim-caption">{caption}</caption> : null}
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
{columns.map((column) => {
|
|
44
|
+
const sorted = sort?.key === column.key ? sort.direction : undefined
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<th
|
|
48
|
+
aria-sort={sorted}
|
|
49
|
+
className={cn(column.numeric && 'nim-table__cell--numeric')}
|
|
50
|
+
key={column.key}
|
|
51
|
+
scope="col"
|
|
52
|
+
style={column.width ? { inlineSize: column.width } : undefined}
|
|
53
|
+
>
|
|
54
|
+
{column.sortable && onSort ? (
|
|
55
|
+
<button className="nim-table__sort" onClick={() => onSort(column.key)} type="button">
|
|
56
|
+
{column.header}
|
|
57
|
+
{sorted ? <Icon name={sorted === 'ascending' ? 'chevron-up' : 'chevron-down'} size="xs" /> : null}
|
|
58
|
+
</button>
|
|
59
|
+
) : (
|
|
60
|
+
column.header
|
|
61
|
+
)}
|
|
62
|
+
</th>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
<tbody>
|
|
68
|
+
{rows.map((row) => (
|
|
69
|
+
<tr key={rowKey(row)}>
|
|
70
|
+
{columns.map((column) => (
|
|
71
|
+
<td className={cn(column.numeric && 'nim-table__cell--numeric')} key={column.key}>
|
|
72
|
+
{column.render(row)}
|
|
73
|
+
</td>
|
|
74
|
+
))}
|
|
75
|
+
</tr>
|
|
76
|
+
))}
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import type { HTMLAttributes } from 'react'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface TabOption<T extends string> {
|
|
6
|
+
/** A trailing figure — a count, never a decoration. */
|
|
7
|
+
count?: number | string
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
label: string
|
|
10
|
+
value: T
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TabsProps<T extends string> extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
|
14
|
+
label: string
|
|
15
|
+
onChange: (value: T) => void
|
|
16
|
+
options: TabOption<T>[]
|
|
17
|
+
value: T
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tabs switch a region of the page. A Segmented control sets a value. They
|
|
22
|
+
* look similar and mean different things, and 0.1 had the segmented control
|
|
23
|
+
* carrying both jobs.
|
|
24
|
+
*
|
|
25
|
+
* Arrow keys move between tabs and select as they go — the pattern a tablist
|
|
26
|
+
* is expected to follow when its panels are cheap to render.
|
|
27
|
+
*/
|
|
28
|
+
export function Tabs<T extends string>({ className, label, onChange, options, value, ...props }: TabsProps<T>) {
|
|
29
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
30
|
+
|
|
31
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
32
|
+
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
|
33
|
+
if (step === 0) return
|
|
34
|
+
|
|
35
|
+
event.preventDefault()
|
|
36
|
+
const selectable = options.filter((option) => !option.disabled)
|
|
37
|
+
const current = selectable.findIndex((option) => option.value === value)
|
|
38
|
+
const next = selectable[(current + step + selectable.length) % selectable.length]
|
|
39
|
+
if (!next) return
|
|
40
|
+
|
|
41
|
+
onChange(next.value)
|
|
42
|
+
listRef.current?.querySelector<HTMLButtonElement>(`[data-value="${next.value}"]`)?.focus()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
aria-label={label}
|
|
48
|
+
className={cn('nim-tabs', className)}
|
|
49
|
+
onKeyDown={onKeyDown}
|
|
50
|
+
ref={listRef}
|
|
51
|
+
role="tablist"
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{options.map((option) => (
|
|
55
|
+
<button
|
|
56
|
+
aria-selected={option.value === value}
|
|
57
|
+
className="nim-tab"
|
|
58
|
+
data-value={option.value}
|
|
59
|
+
disabled={option.disabled}
|
|
60
|
+
key={option.value}
|
|
61
|
+
onClick={() => onChange(option.value)}
|
|
62
|
+
role="tab"
|
|
63
|
+
tabIndex={option.value === value ? 0 : -1}
|
|
64
|
+
type="button"
|
|
65
|
+
>
|
|
66
|
+
{option.label}
|
|
67
|
+
{option.count === undefined ? null : <span className="nim-tab__count">{option.count}</span>}
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
3
|
+
import { Progress, Spinner } from '@/components/feedback'
|
|
4
|
+
import { cn } from '@/lib/cn'
|
|
5
|
+
|
|
6
|
+
export type TaskStepStatus = 'active' | 'done' | 'failed' | 'pending' | 'skipped'
|
|
7
|
+
|
|
8
|
+
export interface TaskStep {
|
|
9
|
+
/** What went wrong, or what is happening. Shown under the label — a status
|
|
10
|
+
word alone leaves a stuck viewer with nothing to act on. */
|
|
11
|
+
detail?: ReactNode
|
|
12
|
+
id: string
|
|
13
|
+
label: ReactNode
|
|
14
|
+
status: TaskStepStatus
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TaskProgressProps {
|
|
18
|
+
/** Rendered under the steps: a cancel control, a support line. */
|
|
19
|
+
action?: ReactNode
|
|
20
|
+
className?: string
|
|
21
|
+
/** The headline under the ring — what the job is doing right now. */
|
|
22
|
+
caption?: ReactNode
|
|
23
|
+
labels?: { of: (done: number, total: number) => string; status: Record<TaskStepStatus, string> }
|
|
24
|
+
steps: TaskStep[]
|
|
25
|
+
title?: ReactNode
|
|
26
|
+
/** 0–100. Omit to derive it from the steps, which is what a job with equal
|
|
27
|
+
stages wants; pass it when the server knows better. */
|
|
28
|
+
value?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_LABELS = {
|
|
32
|
+
of: (done: number, total: number) => `${done} of ${total} steps`,
|
|
33
|
+
status: {
|
|
34
|
+
active: 'In progress',
|
|
35
|
+
done: 'Done',
|
|
36
|
+
failed: 'Failed',
|
|
37
|
+
pending: 'Waiting',
|
|
38
|
+
skipped: 'Skipped',
|
|
39
|
+
} as Record<TaskStepStatus, string>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STATUS_ICON: Record<Exclude<TaskStepStatus, 'active'>, IconName> = {
|
|
43
|
+
done: 'check',
|
|
44
|
+
failed: 'close',
|
|
45
|
+
pending: 'clock',
|
|
46
|
+
skipped: 'minus',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A long-running job the viewer is waiting on: a scan, an import, a render.
|
|
51
|
+
*
|
|
52
|
+
* The steps are the point. A bare percentage tells someone how long to wait;
|
|
53
|
+
* a named stage tells them what is happening and, when it fails, which part
|
|
54
|
+
* failed — which is the difference between "try again" and "try again with a
|
|
55
|
+
* better photo". Failure is a state of a step, not a replacement for the list.
|
|
56
|
+
*
|
|
57
|
+
* The region is `aria-live="polite"`, so a stage completing is announced
|
|
58
|
+
* without the viewer having to keep looking at it.
|
|
59
|
+
*/
|
|
60
|
+
export function TaskProgress({
|
|
61
|
+
action,
|
|
62
|
+
caption,
|
|
63
|
+
className,
|
|
64
|
+
labels,
|
|
65
|
+
steps,
|
|
66
|
+
title,
|
|
67
|
+
value,
|
|
68
|
+
}: TaskProgressProps) {
|
|
69
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
70
|
+
const done = steps.filter((step) => step.status === 'done' || step.status === 'skipped').length
|
|
71
|
+
const percent = value ?? (steps.length ? Math.round((done / steps.length) * 100) : 0)
|
|
72
|
+
const failed = steps.some((step) => step.status === 'failed')
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section
|
|
76
|
+
aria-live="polite"
|
|
77
|
+
className={cn('nim-task', failed && 'nim-task--failed', className)}
|
|
78
|
+
>
|
|
79
|
+
<header className="nim-task__head">
|
|
80
|
+
{title ? <h2 className="nim-task__title">{title}</h2> : null}
|
|
81
|
+
{caption ? <p className="nim-task__caption">{caption}</p> : null}
|
|
82
|
+
{/* The bar takes the danger tone from the section, not from a prop:
|
|
83
|
+
a failed job is a state of the whole task, and Progress owns one
|
|
84
|
+
fill in one colour. */}
|
|
85
|
+
<Progress label={text.of(done, steps.length)} value={percent} />
|
|
86
|
+
</header>
|
|
87
|
+
|
|
88
|
+
<ol className="nim-task__steps">
|
|
89
|
+
{steps.map((step) => (
|
|
90
|
+
<li className="nim-task__step" data-status={step.status} key={step.id}>
|
|
91
|
+
<span className="nim-task__marker">
|
|
92
|
+
{step.status === 'active' ? (
|
|
93
|
+
<Spinner size="sm" />
|
|
94
|
+
) : (
|
|
95
|
+
<Icon name={STATUS_ICON[step.status]} size="xs" />
|
|
96
|
+
)}
|
|
97
|
+
</span>
|
|
98
|
+
<span className="nim-task__step-text">
|
|
99
|
+
<span className="nim-task__step-label">{step.label}</span>
|
|
100
|
+
<span className="nim-task__step-detail">
|
|
101
|
+
{step.detail ?? text.status[step.status]}
|
|
102
|
+
</span>
|
|
103
|
+
</span>
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ol>
|
|
107
|
+
|
|
108
|
+
{action ? <div className="nim-task__action">{action}</div> : null}
|
|
109
|
+
</section>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* nim has two independent appearance axes.
|
|
7
|
+
*
|
|
8
|
+
* A STYLE owns how the interface is shaped: radii, elevation geometry, type
|
|
9
|
+
* voice, press behaviour. A COLOURWAY owns how it is coloured: surfaces, ink,
|
|
10
|
+
* lines, accent, status. They compose freely — `ledger` + `teal` is a legal
|
|
11
|
+
* pairing, not a mistake — which is why they are two attributes rather than
|
|
12
|
+
* one `theme` name multiplying out into a file per combination.
|
|
13
|
+
*/
|
|
14
|
+
export type NimStyle = 'ledger' | 'vlora'
|
|
15
|
+
export type NimColorway = 'coral' | 'oxblood' | 'teal' | 'vermilion'
|
|
16
|
+
|
|
17
|
+
/** `system` follows the OS and is resolved by CSS, not by JavaScript. */
|
|
18
|
+
export type NimScheme = 'dark' | 'light' | 'system'
|
|
19
|
+
export type NimDirection = 'ltr' | 'rtl'
|
|
20
|
+
|
|
21
|
+
interface NimContextValue {
|
|
22
|
+
colorway: NimColorway
|
|
23
|
+
direction: NimDirection
|
|
24
|
+
/** BCP 47 tag. Components that format dates or numbers use it; the script
|
|
25
|
+
corrections in `persian.css` key off the `lang` attribute it writes. */
|
|
26
|
+
locale: string | undefined
|
|
27
|
+
scheme: NimScheme
|
|
28
|
+
setColorway: (colorway: NimColorway) => void
|
|
29
|
+
setScheme: (scheme: NimScheme) => void
|
|
30
|
+
setStyle: (style: NimStyle) => void
|
|
31
|
+
style: NimStyle
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NimContext = createContext<NimContextValue | null>(null)
|
|
35
|
+
|
|
36
|
+
export interface NimProviderProps {
|
|
37
|
+
children: ReactNode
|
|
38
|
+
className?: string
|
|
39
|
+
defaultColorway?: NimColorway
|
|
40
|
+
defaultScheme?: NimScheme
|
|
41
|
+
defaultStyle?: NimStyle
|
|
42
|
+
direction?: NimDirection
|
|
43
|
+
/** BCP 47 tag, e.g. `fa-IR`. Written to `lang`, which is what turns on the
|
|
44
|
+
Persian script corrections and gives Calendar its month and weekday
|
|
45
|
+
names and its digits. Direction is separate: `dir` says which way the
|
|
46
|
+
line runs, `lang` says which script is being set. */
|
|
47
|
+
locale?: string
|
|
48
|
+
/** Writes the appearance attributes onto <html> as well, so portalled
|
|
49
|
+
surfaces (sheets, dialogs, menus, toasts) inherit them from outside the
|
|
50
|
+
React tree. */
|
|
51
|
+
syncDocument?: boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function NimProvider({
|
|
55
|
+
children,
|
|
56
|
+
className,
|
|
57
|
+
defaultColorway = 'vermilion',
|
|
58
|
+
defaultScheme = 'light',
|
|
59
|
+
defaultStyle = 'ledger',
|
|
60
|
+
direction = 'ltr',
|
|
61
|
+
locale,
|
|
62
|
+
syncDocument = true,
|
|
63
|
+
}: NimProviderProps) {
|
|
64
|
+
const [style, setStyle] = useState<NimStyle>(defaultStyle)
|
|
65
|
+
const [colorway, setColorway] = useState<NimColorway>(defaultColorway)
|
|
66
|
+
const [scheme, setScheme] = useState<NimScheme>(defaultScheme)
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!syncDocument || typeof document === 'undefined') return
|
|
70
|
+
const root = document.documentElement
|
|
71
|
+
root.dataset.nimStyle = style
|
|
72
|
+
root.dataset.nimColorway = colorway
|
|
73
|
+
// `system` is the absence of a pin: the contract's `color-scheme: light dark`
|
|
74
|
+
// then lets the OS decide, and every light-dark() pair follows.
|
|
75
|
+
if (scheme === 'system') delete root.dataset.nimScheme
|
|
76
|
+
else root.dataset.nimScheme = scheme
|
|
77
|
+
root.dir = direction
|
|
78
|
+
if (locale) root.lang = locale
|
|
79
|
+
}, [colorway, direction, locale, scheme, style, syncDocument])
|
|
80
|
+
|
|
81
|
+
const value = useMemo(
|
|
82
|
+
() => ({ colorway, direction, locale, scheme, setColorway, setScheme, setStyle, style }),
|
|
83
|
+
[colorway, direction, locale, scheme, style],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<NimContext.Provider value={value}>
|
|
88
|
+
<div
|
|
89
|
+
className={cn('nim-root', className)}
|
|
90
|
+
data-nim-colorway={colorway}
|
|
91
|
+
data-nim-scheme={scheme === 'system' ? undefined : scheme}
|
|
92
|
+
data-nim-style={style}
|
|
93
|
+
dir={direction}
|
|
94
|
+
lang={locale}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
</NimContext.Provider>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function useNim() {
|
|
103
|
+
const context = useContext(NimContext)
|
|
104
|
+
if (!context) throw new Error('useNim must be used inside <NimProvider>')
|
|
105
|
+
return context
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Convenience for a header toggle: flips light ⇄ dark, leaving `system`. */
|
|
109
|
+
export function useSchemeToggle() {
|
|
110
|
+
const { scheme, setScheme } = useNim()
|
|
111
|
+
return useCallback(() => setScheme(scheme === 'dark' ? 'light' : 'dark'), [scheme, setScheme])
|
|
112
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
5
|
+
import { cn } from '@/lib/cn'
|
|
6
|
+
|
|
7
|
+
export type ToastTone = 'accent' | 'danger' | 'neutral' | 'success'
|
|
8
|
+
|
|
9
|
+
export interface ToastOptions {
|
|
10
|
+
action?: { label: string; onPress: () => void }
|
|
11
|
+
/** Milliseconds on screen. Pass 0 to require a manual dismissal. */
|
|
12
|
+
duration?: number
|
|
13
|
+
message: string
|
|
14
|
+
tone?: ToastTone
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ToastRecord extends ToastOptions {
|
|
18
|
+
id: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ToastContext = createContext<((options: ToastOptions) => void) | null>(null)
|
|
22
|
+
|
|
23
|
+
const TONE_ICON: Record<ToastTone, IconName> = {
|
|
24
|
+
accent: 'sparkle',
|
|
25
|
+
danger: 'danger',
|
|
26
|
+
neutral: 'info',
|
|
27
|
+
success: 'check-circle',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
31
|
+
const [toasts, setToasts] = useState<ToastRecord[]>([])
|
|
32
|
+
const nextId = useRef(0)
|
|
33
|
+
|
|
34
|
+
const dismiss = useCallback((id: number) => {
|
|
35
|
+
setToasts((current) => current.filter((toast) => toast.id !== id))
|
|
36
|
+
}, [])
|
|
37
|
+
|
|
38
|
+
const push = useCallback(
|
|
39
|
+
(options: ToastOptions) => {
|
|
40
|
+
const id = nextId.current++
|
|
41
|
+
setToasts((current) => [...current, { ...options, id }])
|
|
42
|
+
const duration = options.duration ?? 4000
|
|
43
|
+
if (duration > 0) window.setTimeout(() => dismiss(id), duration)
|
|
44
|
+
},
|
|
45
|
+
[dismiss],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const value = useMemo(() => push, [push])
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<ToastContext.Provider value={value}>
|
|
52
|
+
{children}
|
|
53
|
+
{typeof document !== 'undefined'
|
|
54
|
+
? createPortal(
|
|
55
|
+
<div aria-live="polite" className="nim-toast-stack">
|
|
56
|
+
{toasts.map((toast) => (
|
|
57
|
+
<div className={cn('nim-toast', `nim-toast--${toast.tone ?? 'neutral'}`)} key={toast.id}>
|
|
58
|
+
<Icon className="nim-toast__icon" name={TONE_ICON[toast.tone ?? 'neutral']} size="sm" />
|
|
59
|
+
<span className="nim-toast__message">{toast.message}</span>
|
|
60
|
+
{toast.action ? (
|
|
61
|
+
<button
|
|
62
|
+
className="nim-toast__action"
|
|
63
|
+
onClick={() => {
|
|
64
|
+
toast.action?.onPress()
|
|
65
|
+
dismiss(toast.id)
|
|
66
|
+
}}
|
|
67
|
+
type="button"
|
|
68
|
+
>
|
|
69
|
+
{toast.action.label}
|
|
70
|
+
</button>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>,
|
|
75
|
+
document.body,
|
|
76
|
+
)
|
|
77
|
+
: null}
|
|
78
|
+
</ToastContext.Provider>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Throws when used outside the provider — a silent no-op would hide the bug. */
|
|
83
|
+
export function useToast() {
|
|
84
|
+
const context = useContext(ToastContext)
|
|
85
|
+
if (!context) throw new Error('useToast must be used inside <ToastProvider>')
|
|
86
|
+
return context
|
|
87
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export interface TooltipProps {
|
|
5
|
+
children: ReactNode
|
|
6
|
+
className?: string
|
|
7
|
+
/** The text shown on hover. Never the only place the name lives. */
|
|
8
|
+
label: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A name for a control that shows only an icon.
|
|
13
|
+
*
|
|
14
|
+
* The bubble is `aria-hidden`: the trigger inside must already carry its own
|
|
15
|
+
* accessible name (IconButton does), so a screen reader is never read the same
|
|
16
|
+
* label twice, and a viewer who cannot hover never depends on this.
|
|
17
|
+
*
|
|
18
|
+
* Hover waits 200ms; keyboard focus does not, because a viewer who tabbed here
|
|
19
|
+
* has already asked.
|
|
20
|
+
*/
|
|
21
|
+
export function Tooltip({ children, className, label }: TooltipProps) {
|
|
22
|
+
return (
|
|
23
|
+
<span className={cn('nim-tooltip', className)}>
|
|
24
|
+
{children}
|
|
25
|
+
<span aria-hidden="true" className="nim-tooltip__bubble" role="tooltip">
|
|
26
|
+
{label}
|
|
27
|
+
</span>
|
|
28
|
+
</span>
|
|
29
|
+
)
|
|
30
|
+
}
|