@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,157 @@
1
+ import { useId, useMemo, useRef, useState } from 'react'
2
+ import { FieldShell } from '@/components/field'
3
+ import { Icon } from '@/components/icon'
4
+ import { cn } from '@/lib/cn'
5
+
6
+ export interface ComboboxOption<T extends string> {
7
+ disabled?: boolean
8
+ label: string
9
+ /** A trailing hint set in the mono — a count, a code, a date. */
10
+ meta?: string
11
+ value: T
12
+ }
13
+
14
+ export interface ComboboxProps<T extends string> {
15
+ className?: string
16
+ /** Shown when nothing matches. Give the viewer the way forward rather than
17
+ a dead end: "No client matches — create it". */
18
+ emptyState?: (query: string) => React.ReactNode
19
+ error?: string
20
+ hint?: string
21
+ id?: string
22
+ label?: string
23
+ onChange: (value: T | null) => void
24
+ options: ComboboxOption<T>[]
25
+ placeholder?: string
26
+ required?: boolean
27
+ value: T | null
28
+ }
29
+
30
+ /**
31
+ * A text input that suggests, and can still be typed into.
32
+ *
33
+ * A real `<input>` with `role="combobox"` over a `role="listbox"`, so the
34
+ * platform's text editing, autofill and form participation are untouched.
35
+ * Arrow keys move the active option, Enter commits it, Escape reverts to the
36
+ * last committed value rather than clearing the field.
37
+ */
38
+ export function Combobox<T extends string>({
39
+ className,
40
+ emptyState,
41
+ error,
42
+ hint,
43
+ id,
44
+ label,
45
+ onChange,
46
+ options,
47
+ placeholder,
48
+ required,
49
+ value,
50
+ }: ComboboxProps<T>) {
51
+ const listId = useId()
52
+ const selected = options.find((option) => option.value === value) ?? null
53
+ const [query, setQuery] = useState('')
54
+ const [open, setOpen] = useState(false)
55
+ const [active, setActive] = useState(0)
56
+ const inputRef = useRef<HTMLInputElement>(null)
57
+
58
+ const matches = useMemo(() => {
59
+ const needle = query.trim().toLowerCase()
60
+ if (!needle) return options
61
+ return options.filter((option) => option.label.toLowerCase().includes(needle))
62
+ }, [options, query])
63
+
64
+ const commit = (option: ComboboxOption<T>) => {
65
+ onChange(option.value)
66
+ setQuery('')
67
+ setOpen(false)
68
+ }
69
+
70
+ const onKeyDown = (event: React.KeyboardEvent) => {
71
+ if (event.key === 'Escape') {
72
+ setQuery('')
73
+ setOpen(false)
74
+ return
75
+ }
76
+ if (!open && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
77
+ setOpen(true)
78
+ return
79
+ }
80
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
81
+ event.preventDefault()
82
+ const step = event.key === 'ArrowDown' ? 1 : -1
83
+ const usable = matches.filter((option) => !option.disabled)
84
+ if (usable.length === 0) return
85
+ setActive((index) => (index + step + usable.length) % usable.length)
86
+ }
87
+ if (event.key === 'Enter') {
88
+ const usable = matches.filter((option) => !option.disabled)
89
+ const option = usable[active]
90
+ if (option) {
91
+ event.preventDefault()
92
+ commit(option)
93
+ }
94
+ }
95
+ }
96
+
97
+ const usable = matches.filter((option) => !option.disabled)
98
+
99
+ return (
100
+ <FieldShell className={className} error={error} hint={hint} id={id} label={label} required={required}>
101
+ {({ control, describedBy }) => (
102
+ <div className="nim-combobox">
103
+ <div className={cn('nim-input-shell', 'nim-input-shell--has-end')}>
104
+ <input
105
+ aria-autocomplete="list"
106
+ aria-controls={open ? listId : undefined}
107
+ aria-describedby={describedBy}
108
+ aria-expanded={open}
109
+ className="nim-input"
110
+ id={control}
111
+ onBlur={() => window.setTimeout(() => setOpen(false), 120)}
112
+ onChange={(event) => {
113
+ setQuery(event.target.value)
114
+ setActive(0)
115
+ setOpen(true)
116
+ }}
117
+ onFocus={() => setOpen(true)}
118
+ onKeyDown={onKeyDown}
119
+ placeholder={placeholder}
120
+ ref={inputRef}
121
+ role="combobox"
122
+ value={open ? query : (selected?.label ?? '')}
123
+ />
124
+ <span className="nim-input-shell__affix nim-input-shell__affix--end">
125
+ <Icon name="chevron-down" size="sm" />
126
+ </span>
127
+ </div>
128
+ {open ? (
129
+ <div className="nim-combobox__list" id={listId} role="listbox">
130
+ {usable.length === 0 ? (
131
+ <div className="nim-combobox__empty">
132
+ {emptyState ? emptyState(query) : `Nothing matches “${query}”.`}
133
+ </div>
134
+ ) : (
135
+ matches.map((option) => (
136
+ <button
137
+ aria-selected={usable.indexOf(option) === active}
138
+ className="nim-combobox__option"
139
+ disabled={option.disabled}
140
+ key={option.value}
141
+ onClick={() => commit(option)}
142
+ onPointerEnter={() => setActive(usable.indexOf(option))}
143
+ role="option"
144
+ type="button"
145
+ >
146
+ <span>{option.label}</span>
147
+ {option.meta ? <span className="nim-combobox__meta">{option.meta}</span> : null}
148
+ </button>
149
+ ))
150
+ )}
151
+ </div>
152
+ ) : null}
153
+ </div>
154
+ )}
155
+ </FieldShell>
156
+ )
157
+ }
@@ -0,0 +1,394 @@
1
+ import { useMemo, useRef, useState } from 'react'
2
+ import { FieldShell } from '@/components/field'
3
+ import { Icon } from '@/components/icon'
4
+ import { IconButton } from '@/components/icon-button'
5
+ import { Popover } from '@/components/menu'
6
+ import { useNim } from '@/components/theme'
7
+ import { cn } from '@/lib/cn'
8
+ import {
9
+ addDays,
10
+ addMonths,
11
+ defaultSystem,
12
+ defaultWeekStart,
13
+ formatNumeric,
14
+ localeFor,
15
+ parseNumeric,
16
+ partsOf,
17
+ startOfMonth,
18
+ todayIso,
19
+ weekdayOf,
20
+ type CalendarSystem,
21
+ type IsoDate,
22
+ } from '@/lib/calendars'
23
+
24
+ export type { CalendarSystem, IsoDate } from '@/lib/calendars'
25
+
26
+ export interface CalendarProps {
27
+ className?: string
28
+ /** Days to flag — a due date, a booking, anything the month should mark. */
29
+ marked?: IsoDate[]
30
+ max?: IsoDate
31
+ min?: IsoDate
32
+ month: IsoDate
33
+ onMonthChange: (month: IsoDate) => void
34
+ onSelect: (date: IsoDate) => void
35
+ /** The calendar the grid is drawn in. Defaults to Jalali for an `fa` locale
36
+ and Gregorian everywhere else. The value stays an ISO Gregorian date in
37
+ both — the calendar is what the viewer reads, not what the API gets. */
38
+ system?: CalendarSystem
39
+ value?: IsoDate | null
40
+ /** 1 = Monday. Defaults to Saturday on the Jalali calendar, Monday on the
41
+ Gregorian one — the week does not start on the same day everywhere. */
42
+ weekStart?: number
43
+ }
44
+
45
+ const labels = {
46
+ next: 'Next month',
47
+ previous: 'Previous month',
48
+ }
49
+
50
+ export function Calendar({
51
+ className,
52
+ marked = [],
53
+ max,
54
+ min,
55
+ month,
56
+ onMonthChange,
57
+ onSelect,
58
+ system,
59
+ value,
60
+ weekStart,
61
+ }: CalendarProps) {
62
+ const { locale } = useNim()
63
+ const calendar = system ?? defaultSystem(locale)
64
+ const start = weekStart ?? defaultWeekStart(calendar)
65
+ const today = todayIso()
66
+ const intlLocale = localeFor(locale, calendar)
67
+
68
+ // Month name, weekday names and the digits all come from the locale and the
69
+ // calendar together. Before 0.4 the grid was Gregorian by contract and the
70
+ // formatter was pinned to `gregory` to stop an `fa` locale labelling it
71
+ // «شهریور» over Gregorian cells. Now the grid itself can be Jalali, so the
72
+ // label and the cells agree either way — and pinning would be the bug.
73
+ const monthFormat = useMemo(
74
+ () => new Intl.DateTimeFormat(intlLocale, { month: 'long', timeZone: 'UTC', year: 'numeric' }),
75
+ [intlLocale],
76
+ )
77
+ const dayFormat = useMemo(() => new Intl.NumberFormat(locale), [locale])
78
+ const weekdayFormat = useMemo(
79
+ () => new Intl.DateTimeFormat(intlLocale, { timeZone: 'UTC', weekday: 'short' }),
80
+ [intlLocale],
81
+ )
82
+
83
+ const anchor = startOfMonth(month, calendar)
84
+ const anchorMonth = partsOf(anchor, calendar).month
85
+
86
+ const days = useMemo(() => {
87
+ // How far back the grid must reach to land on the week's first day.
88
+ const lead = (weekdayOf(anchor) - start + 7) % 7
89
+ const gridStart = addDays(anchor, -lead)
90
+
91
+ return Array.from({ length: 42 }, (_, index) => {
92
+ const date = addDays(gridStart, index)
93
+ const parts = partsOf(date, calendar)
94
+ return { date, day: parts.day, outside: parts.month !== anchorMonth }
95
+ })
96
+ }, [anchor, anchorMonth, calendar, start])
97
+
98
+ const weekdays = useMemo(() => {
99
+ // 2024-01-07 is a Sunday, so adding the weekday index walks a real week.
100
+ const sunday = '2024-01-07'
101
+ return Array.from({ length: 7 }, (_, index) => ({
102
+ key: `${start}-${index}`,
103
+ label: weekdayFormat.format(new Date(`${addDays(sunday, (start + index) % 7)}T00:00:00Z`)),
104
+ }))
105
+ }, [start, weekdayFormat])
106
+
107
+ return (
108
+ <div className={cn('nim-calendar', className)}>
109
+ <div className="nim-calendar__header">
110
+ <IconButton
111
+ label={labels.previous}
112
+ name="chevron-back"
113
+ onClick={() => onMonthChange(addMonths(anchor, -1, calendar))}
114
+ size="sm"
115
+ />
116
+ <span className="nim-calendar__month">
117
+ {monthFormat.format(new Date(`${anchor}T00:00:00Z`))}
118
+ </span>
119
+ <IconButton
120
+ label={labels.next}
121
+ name="chevron-forward"
122
+ onClick={() => onMonthChange(addMonths(anchor, 1, calendar))}
123
+ size="sm"
124
+ />
125
+ </div>
126
+ <div className="nim-calendar__grid" role="grid">
127
+ {weekdays.map((weekday) => (
128
+ <span className="nim-calendar__weekday" key={weekday.key}>
129
+ {weekday.label}
130
+ </span>
131
+ ))}
132
+ {days.map((day) => (
133
+ <button
134
+ aria-selected={day.date === value}
135
+ className={cn(
136
+ 'nim-calendar__day',
137
+ day.outside && 'nim-calendar__day--outside',
138
+ day.date === today && 'nim-calendar__day--today',
139
+ marked.includes(day.date) && 'nim-calendar__day--marked',
140
+ )}
141
+ disabled={(min !== undefined && day.date < min) || (max !== undefined && day.date > max)}
142
+ key={day.date}
143
+ onClick={() => onSelect(day.date)}
144
+ role="gridcell"
145
+ type="button"
146
+ >
147
+ {dayFormat.format(day.day)}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ interface DateEntryProps extends Omit<CalendarProps, 'month' | 'onMonthChange' | 'onSelect' | 'value'> {
156
+ error?: string
157
+ hint?: string
158
+ id?: string
159
+ label?: string
160
+ onChange: (value: IsoDate) => void
161
+ required?: boolean
162
+ value: IsoDate
163
+ }
164
+
165
+ export type DateFieldProps = DateEntryProps
166
+
167
+ /**
168
+ * The typed half of date entry, shared by the field and the picker.
169
+ *
170
+ * On the Gregorian calendar this is `<input type="date">`: the mobile date
171
+ * keyboard, the locale's field order and form validation all come from the
172
+ * platform, and none of it is worth reimplementing. On the Jalali calendar the
173
+ * platform has nothing to offer — no browser ships a Jalali date input — so it
174
+ * is a text field that reads `۱۴۰۴/۰۶/۰۱`, accepts Persian digits, and commits
175
+ * only what round-trips through ICU.
176
+ */
177
+ function DateEntry({
178
+ calendar,
179
+ describedBy,
180
+ id,
181
+ invalid,
182
+ locale,
183
+ onChange,
184
+ value,
185
+ }: {
186
+ calendar: CalendarSystem
187
+ describedBy?: string
188
+ id: string
189
+ invalid?: boolean
190
+ locale: string | undefined
191
+ onChange: (value: IsoDate) => void
192
+ value: IsoDate
193
+ }) {
194
+ // What the viewer is typing, kept apart from the committed value so a
195
+ // half-written year is not repeatedly reformatted under the caret.
196
+ const [draft, setDraft] = useState<string | null>(null)
197
+
198
+ if (calendar === 'gregory') {
199
+ return (
200
+ <input
201
+ aria-describedby={describedBy}
202
+ aria-invalid={invalid ? true : undefined}
203
+ className="nim-input"
204
+ id={id}
205
+ onChange={(event) => onChange(event.target.value)}
206
+ type="date"
207
+ value={value}
208
+ />
209
+ )
210
+ }
211
+
212
+ const shown = draft ?? (value ? formatNumeric(value, locale, calendar) : '')
213
+
214
+ return (
215
+ <input
216
+ aria-describedby={describedBy}
217
+ aria-invalid={invalid ? true : undefined}
218
+ className="nim-input"
219
+ dir="ltr"
220
+ id={id}
221
+ inputMode="numeric"
222
+ onBlur={() => setDraft(null)}
223
+ onChange={(event) => {
224
+ setDraft(event.target.value)
225
+ const parsed = parseNumeric(event.target.value, calendar)
226
+ if (parsed) onChange(parsed)
227
+ else if (event.target.value.trim() === '') onChange('')
228
+ }}
229
+ placeholder={formatNumeric(todayIso(), locale, calendar)}
230
+ type="text"
231
+ value={shown}
232
+ />
233
+ )
234
+ }
235
+
236
+ /**
237
+ * A text field with a calendar under it, in that order of importance: typing a
238
+ * date always works, which matters more to more people than the grid does.
239
+ *
240
+ * Both halves answer the same calendar system, so a Persian interface types
241
+ * ۱۴۰۴/۰۶/۰۱ into a Jalali grid — and still hands the caller `2025-08-23`.
242
+ */
243
+ export function DateField({
244
+ error,
245
+ hint,
246
+ id,
247
+ label,
248
+ onChange,
249
+ required,
250
+ value,
251
+ ...calendarProps
252
+ }: DateFieldProps) {
253
+ const { locale } = useNim()
254
+ const calendar = calendarProps.system ?? defaultSystem(locale)
255
+ const [month, setMonth] = useState<IsoDate>(value || todayIso())
256
+
257
+ return (
258
+ <FieldShell error={error} hint={hint} id={id} label={label} required={required}>
259
+ {({ control, describedBy }) => (
260
+ <div className="nim-stack nim-stack--tight">
261
+ <DateEntry
262
+ calendar={calendar}
263
+ describedBy={describedBy}
264
+ id={control}
265
+ invalid={Boolean(error)}
266
+ locale={locale}
267
+ onChange={(next) => {
268
+ onChange(next)
269
+ if (next) setMonth(next)
270
+ }}
271
+ value={value}
272
+ />
273
+ <Calendar
274
+ {...calendarProps}
275
+ month={month}
276
+ onMonthChange={setMonth}
277
+ onSelect={(date) => {
278
+ onChange(date)
279
+ setMonth(date)
280
+ }}
281
+ system={calendar}
282
+ value={value}
283
+ />
284
+ </div>
285
+ )}
286
+ </FieldShell>
287
+ )
288
+ }
289
+
290
+ export interface DatePickerProps extends DateEntryProps {
291
+ /** Accessible names for the two controls the picker adds. */
292
+ labels?: { clear: string; open: string }
293
+ /** The same date on the other calendar, under the field. On by default for
294
+ Jalali, where a viewer often has to reconcile a Gregorian document. */
295
+ showEquivalent?: boolean
296
+ }
297
+
298
+ /**
299
+ * The compact form of date entry: one field, with the grid behind a button.
300
+ *
301
+ * Use it in a form, where a permanently open month steals the space three
302
+ * other fields need; `DateField` is for the screen whose subject is the date.
303
+ * On the Jalali calendar it prints the Gregorian equivalent under the field —
304
+ * the reconciliation an Iranian office does by hand all day.
305
+ */
306
+ export function DatePicker({
307
+ error,
308
+ hint,
309
+ id,
310
+ label,
311
+ labels: names,
312
+ onChange,
313
+ required,
314
+ showEquivalent,
315
+ value,
316
+ ...calendarProps
317
+ }: DatePickerProps) {
318
+ const { locale } = useNim()
319
+ const calendar = calendarProps.system ?? defaultSystem(locale)
320
+ const [open, setOpen] = useState(false)
321
+ const [month, setMonth] = useState<IsoDate>(value || todayIso())
322
+ const trigger = useRef<HTMLButtonElement>(null)
323
+ const text = { clear: 'Clear date', open: 'Open calendar', ...names }
324
+ const equivalent = showEquivalent ?? calendar === 'persian'
325
+ const other: CalendarSystem = calendar === 'persian' ? 'gregory' : 'persian'
326
+
327
+ return (
328
+ <FieldShell error={error} hint={hint} id={id} label={label} required={required}>
329
+ {({ control, describedBy }) => (
330
+ <div className="nim-date-picker">
331
+ <div className="nim-date-picker__group">
332
+ <DateEntry
333
+ calendar={calendar}
334
+ describedBy={describedBy}
335
+ id={control}
336
+ invalid={Boolean(error)}
337
+ locale={locale}
338
+ onChange={(next) => {
339
+ onChange(next)
340
+ if (next) setMonth(next)
341
+ }}
342
+ value={value}
343
+ />
344
+ {value ? (
345
+ <IconButton
346
+ label={text.clear}
347
+ name="close"
348
+ onClick={() => onChange('')}
349
+ size="sm"
350
+ />
351
+ ) : null}
352
+ <IconButton
353
+ aria-expanded={open}
354
+ label={text.open}
355
+ name="calendar"
356
+ onClick={() => setOpen((was) => !was)}
357
+ ref={trigger}
358
+ size="sm"
359
+ />
360
+ </div>
361
+
362
+ {equivalent && value ? (
363
+ <p className="nim-date-picker__equivalent">
364
+ <Icon name="calendar" size="xs" />
365
+ <span dir={other === 'gregory' ? 'ltr' : undefined}>
366
+ {formatNumeric(value, locale, other)}
367
+ </span>
368
+ </p>
369
+ ) : null}
370
+
371
+ <Popover
372
+ label={label ?? text.open}
373
+ onClose={() => setOpen(false)}
374
+ open={open}
375
+ triggerRef={trigger}
376
+ >
377
+ <Calendar
378
+ {...calendarProps}
379
+ month={month}
380
+ onMonthChange={setMonth}
381
+ onSelect={(date) => {
382
+ onChange(date)
383
+ setMonth(date)
384
+ setOpen(false)
385
+ }}
386
+ system={calendar}
387
+ value={value}
388
+ />
389
+ </Popover>
390
+ </div>
391
+ )}
392
+ </FieldShell>
393
+ )
394
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import type { ReactNode } from 'react'
3
+ import { IconButton } from '@/components/icon-button'
4
+ import { cn } from '@/lib/cn'
5
+
6
+ export interface DialogProps {
7
+ children: ReactNode
8
+ className?: string
9
+ closeLabel?: string
10
+ description?: ReactNode
11
+ footer?: ReactNode
12
+ onClose: () => void
13
+ open: boolean
14
+ title: ReactNode
15
+ }
16
+
17
+ /**
18
+ * The centred modal, for a decision that has to be made now. The Sheet remains
19
+ * nim's mobile-first surface; this is what a desktop confirmation wants.
20
+ *
21
+ * It renders a real `<dialog>` opened with `showModal()`, so the top layer,
22
+ * the focus trap, the inert background and Escape all come from the platform
23
+ * rather than from a scrim div and a keydown listener.
24
+ */
25
+ export function Dialog({
26
+ children,
27
+ className,
28
+ closeLabel = 'Close',
29
+ description,
30
+ footer,
31
+ onClose,
32
+ open,
33
+ title,
34
+ }: DialogProps) {
35
+ const ref = useRef<HTMLDialogElement>(null)
36
+
37
+ useEffect(() => {
38
+ const dialog = ref.current
39
+ if (!dialog) return
40
+
41
+ if (open && !dialog.open) dialog.showModal()
42
+ if (!open && dialog.open) dialog.close()
43
+ }, [open])
44
+
45
+ useEffect(() => {
46
+ const dialog = ref.current
47
+ if (!dialog) return
48
+
49
+ // `close` fires for Escape and for the platform's own dismissals, so the
50
+ // caller's state stays in step without a second key listener.
51
+ const onCancel = () => onClose()
52
+ dialog.addEventListener('close', onCancel)
53
+ return () => dialog.removeEventListener('close', onCancel)
54
+ }, [onClose])
55
+
56
+ return (
57
+ <dialog
58
+ className={cn('nim-dialog', className)}
59
+ onClick={(event) => {
60
+ // A click landing on the dialog element itself is a click on the
61
+ // backdrop — the children cover the rest of its box.
62
+ if (event.target === ref.current) onClose()
63
+ }}
64
+ ref={ref}
65
+ >
66
+ <div className="nim-dialog__header">
67
+ <div>
68
+ <p className="nim-title nim-title--md">{title}</p>
69
+ {description ? <p className="nim-caption">{description}</p> : null}
70
+ </div>
71
+ <IconButton label={closeLabel} name="close" onClick={onClose} size="sm" />
72
+ </div>
73
+ <div className="nim-dialog__body">{children}</div>
74
+ {footer ? <div className="nim-dialog__footer">{footer}</div> : null}
75
+ </dialog>
76
+ )
77
+ }
@@ -0,0 +1,23 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { Icon, type IconName } from '@/components/icon'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ export interface EmptyStateProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
6
+ actions?: ReactNode
7
+ description?: ReactNode
8
+ icon?: IconName
9
+ title: ReactNode
10
+ }
11
+
12
+ export function EmptyState({ actions, className, description, icon = 'search', title, ...props }: EmptyStateProps) {
13
+ return (
14
+ <div className={cn('nim-empty', className)} {...props}>
15
+ <span className="nim-empty__icon">
16
+ <Icon name={icon} size="md" />
17
+ </span>
18
+ <p className="nim-title nim-title--md">{title}</p>
19
+ {description ? <p className="nim-body nim-body--sm nim-empty__body">{description}</p> : null}
20
+ {actions ? <div className="nim-empty__actions">{actions}</div> : null}
21
+ </div>
22
+ )
23
+ }