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