@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,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
+ }