@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,172 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { ReactElement, ReactNode, Ref, RefObject } from 'react'
|
|
4
|
+
import { Icon, type IconName } from '@/components/icon'
|
|
5
|
+
import { cn } from '@/lib/cn'
|
|
6
|
+
import { useAnchor } from '@/lib/use-anchor'
|
|
7
|
+
|
|
8
|
+
export interface MenuAction {
|
|
9
|
+
danger?: boolean
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
icon?: IconName
|
|
12
|
+
kind?: 'action'
|
|
13
|
+
label: string
|
|
14
|
+
onSelect: () => void
|
|
15
|
+
/** Rendered in the mono, on the trailing edge — a hint, never a binding. */
|
|
16
|
+
shortcut?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MenuHeading {
|
|
20
|
+
kind: 'heading'
|
|
21
|
+
label: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MenuSeparator {
|
|
25
|
+
kind: 'separator'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type MenuItem = MenuAction | MenuHeading | MenuSeparator
|
|
29
|
+
|
|
30
|
+
export interface MenuProps {
|
|
31
|
+
/** The trigger is the caller's, so the menu has no opinion about what opens
|
|
32
|
+
it — it only needs the ref to anchor against. */
|
|
33
|
+
children: (props: { open: boolean; ref: Ref<HTMLButtonElement>; toggle: () => void }) => ReactElement
|
|
34
|
+
className?: string
|
|
35
|
+
items: MenuItem[]
|
|
36
|
+
label: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isAction = (item: MenuItem): item is MenuAction => item.kind === undefined || item.kind === 'action'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A list of actions, dismissed by choosing one. A menu holds actions and never
|
|
43
|
+
* inputs — something the viewer types into is a Popover.
|
|
44
|
+
*
|
|
45
|
+
* The trigger is supplied by the caller as a render prop so the menu never has
|
|
46
|
+
* an opinion about what opens it; it only needs the ref to anchor against.
|
|
47
|
+
* Arrow keys move a roving active item, Enter and Space choose it, Escape and
|
|
48
|
+
* an outside click dismiss, and focus returns to the trigger either way.
|
|
49
|
+
*/
|
|
50
|
+
export function Menu({ children, className, items, label }: MenuProps) {
|
|
51
|
+
const [open, setOpen] = useState(false)
|
|
52
|
+
const [active, setActive] = useState(0)
|
|
53
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
54
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
55
|
+
const position = useAnchor(triggerRef, panelRef, { onDismiss: () => setOpen(false), open })
|
|
56
|
+
|
|
57
|
+
const actions = items.filter(isAction)
|
|
58
|
+
const enabled = actions.filter((item) => !item.disabled)
|
|
59
|
+
|
|
60
|
+
const toggle = () => {
|
|
61
|
+
setActive(0)
|
|
62
|
+
setOpen((value) => !value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const choose = (item: MenuAction) => {
|
|
66
|
+
setOpen(false)
|
|
67
|
+
item.onSelect()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
71
|
+
if (enabled.length === 0) return
|
|
72
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
73
|
+
event.preventDefault()
|
|
74
|
+
const step = event.key === 'ArrowDown' ? 1 : -1
|
|
75
|
+
setActive((index) => (index + step + enabled.length) % enabled.length)
|
|
76
|
+
}
|
|
77
|
+
if (event.key === 'Home') {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
setActive(0)
|
|
80
|
+
}
|
|
81
|
+
if (event.key === 'End') {
|
|
82
|
+
event.preventDefault()
|
|
83
|
+
setActive(enabled.length - 1)
|
|
84
|
+
}
|
|
85
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
const item = enabled[active]
|
|
88
|
+
if (item) choose(item)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{children({ open, ref: triggerRef, toggle })}
|
|
95
|
+
{open && typeof document !== 'undefined'
|
|
96
|
+
? createPortal(
|
|
97
|
+
<div
|
|
98
|
+
aria-label={label}
|
|
99
|
+
className={cn('nim-menu', className)}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
ref={panelRef}
|
|
102
|
+
role="menu"
|
|
103
|
+
style={{ insetBlockStart: position.top, insetInlineStart: position.left }}
|
|
104
|
+
tabIndex={-1}
|
|
105
|
+
>
|
|
106
|
+
{items.map((item, index) => {
|
|
107
|
+
if (item.kind === 'separator') return <hr className="nim-menu__separator" key={`sep-${index}`} />
|
|
108
|
+
if (item.kind === 'heading')
|
|
109
|
+
return (
|
|
110
|
+
<p className="nim-menu__label" key={`head-${index}`}>
|
|
111
|
+
{item.label}
|
|
112
|
+
</p>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<button
|
|
117
|
+
className={cn('nim-menu__item', item.danger && 'nim-menu__item--danger')}
|
|
118
|
+
data-active={enabled.indexOf(item) === active ? 'true' : undefined}
|
|
119
|
+
disabled={item.disabled}
|
|
120
|
+
key={item.label}
|
|
121
|
+
onClick={() => choose(item)}
|
|
122
|
+
onPointerEnter={() => setActive(enabled.indexOf(item))}
|
|
123
|
+
role="menuitem"
|
|
124
|
+
type="button"
|
|
125
|
+
>
|
|
126
|
+
{item.icon ? <Icon className="nim-menu__icon" name={item.icon} size="sm" /> : null}
|
|
127
|
+
<span>{item.label}</span>
|
|
128
|
+
{item.shortcut ? <span className="nim-menu__shortcut">{item.shortcut}</span> : null}
|
|
129
|
+
</button>
|
|
130
|
+
)
|
|
131
|
+
})}
|
|
132
|
+
</div>,
|
|
133
|
+
document.body,
|
|
134
|
+
)
|
|
135
|
+
: null}
|
|
136
|
+
</>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface PopoverProps {
|
|
141
|
+
children: ReactNode
|
|
142
|
+
className?: string
|
|
143
|
+
label: string
|
|
144
|
+
onClose: () => void
|
|
145
|
+
open: boolean
|
|
146
|
+
/** The element the panel is placed against. */
|
|
147
|
+
triggerRef: RefObject<HTMLElement | null>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A menu's geometry with a form's contents. Unlike a menu it does not close on
|
|
152
|
+
* a click inside, because the thing inside is what the viewer came for.
|
|
153
|
+
*/
|
|
154
|
+
export function Popover({ children, className, label, onClose, open, triggerRef }: PopoverProps) {
|
|
155
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
156
|
+
const position = useAnchor(triggerRef, panelRef, { onDismiss: onClose, open })
|
|
157
|
+
|
|
158
|
+
if (!open || typeof document === 'undefined') return null
|
|
159
|
+
|
|
160
|
+
return createPortal(
|
|
161
|
+
<div
|
|
162
|
+
aria-label={label}
|
|
163
|
+
className={cn('nim-popover', className)}
|
|
164
|
+
ref={panelRef}
|
|
165
|
+
role="dialog"
|
|
166
|
+
style={{ insetBlockStart: position.top, insetInlineStart: position.left }}
|
|
167
|
+
>
|
|
168
|
+
{children}
|
|
169
|
+
</div>,
|
|
170
|
+
document.body,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Button } from '@/components/button'
|
|
4
|
+
import { IconButton } from '@/components/icon-button'
|
|
5
|
+
import { cn } from '@/lib/cn'
|
|
6
|
+
|
|
7
|
+
export interface OnboardingSlide {
|
|
8
|
+
/** Illustration, video, or anything else. Sized by the caller. */
|
|
9
|
+
art?: ReactNode
|
|
10
|
+
body?: ReactNode
|
|
11
|
+
id: string
|
|
12
|
+
/** The chip above the title — the promise, in three or four words. */
|
|
13
|
+
label?: string
|
|
14
|
+
/** A caption card under the art: a headline and its supporting points. */
|
|
15
|
+
proof?: { icon?: ReactNode; points?: string[]; title: ReactNode }
|
|
16
|
+
title: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OnboardingProps {
|
|
20
|
+
className?: string
|
|
21
|
+
/** Label for the button on the last slide. */
|
|
22
|
+
finishLabel: string
|
|
23
|
+
/** Label for the button on every other slide. */
|
|
24
|
+
nextLabel: string
|
|
25
|
+
/** Reached from the finish button, or from skip. */
|
|
26
|
+
onDone: () => void
|
|
27
|
+
onSkip?: () => void
|
|
28
|
+
/** Notified on every slide change, including from a dot. */
|
|
29
|
+
onStep?: (index: number) => void
|
|
30
|
+
slides: OnboardingSlide[]
|
|
31
|
+
skipLabel?: string
|
|
32
|
+
/** Version string, support line — whatever sits under the CTA. */
|
|
33
|
+
footnote?: ReactNode
|
|
34
|
+
/** Brand mark in the top bar. */
|
|
35
|
+
brand?: ReactNode
|
|
36
|
+
/** Accessible names for the controls. */
|
|
37
|
+
labels?: { back: string; dot: (index: number) => string }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_LABELS = {
|
|
41
|
+
back: 'Back',
|
|
42
|
+
dot: (index: number) => `Slide ${index + 1}`,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The three-screen intro a product opens with: art, a promise, a body, and one
|
|
47
|
+
* CTA that advances.
|
|
48
|
+
*
|
|
49
|
+
* State is the component's, because a first-run carousel is never resumed from
|
|
50
|
+
* a URL — `onStep` reports it for analytics and `onDone` fires when the viewer
|
|
51
|
+
* either finishes or skips, so the caller routes in one place instead of two.
|
|
52
|
+
* Slides are announced through a live region rather than by moving focus,
|
|
53
|
+
* which would yank a screen reader out of the CTA it is already on.
|
|
54
|
+
*/
|
|
55
|
+
export function Onboarding({
|
|
56
|
+
brand,
|
|
57
|
+
className,
|
|
58
|
+
finishLabel,
|
|
59
|
+
footnote,
|
|
60
|
+
labels,
|
|
61
|
+
nextLabel,
|
|
62
|
+
onDone,
|
|
63
|
+
onSkip,
|
|
64
|
+
onStep,
|
|
65
|
+
skipLabel,
|
|
66
|
+
slides,
|
|
67
|
+
}: OnboardingProps) {
|
|
68
|
+
const [index, setIndex] = useState(0)
|
|
69
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
70
|
+
const slide = slides[Math.min(index, slides.length - 1)]
|
|
71
|
+
const last = index === slides.length - 1
|
|
72
|
+
|
|
73
|
+
const goTo = useCallback(
|
|
74
|
+
(next: number) => {
|
|
75
|
+
setIndex(next)
|
|
76
|
+
onStep?.(next)
|
|
77
|
+
},
|
|
78
|
+
[onStep],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<section className={cn('nim-onboarding', className)}>
|
|
83
|
+
<header className="nim-onboarding__bar">
|
|
84
|
+
<span className="nim-onboarding__brand">{brand}</span>
|
|
85
|
+
{skipLabel ? (
|
|
86
|
+
<Button
|
|
87
|
+
iconEnd="chevron-forward"
|
|
88
|
+
onClick={onSkip ?? onDone}
|
|
89
|
+
size="sm"
|
|
90
|
+
variant="ghost"
|
|
91
|
+
>
|
|
92
|
+
{skipLabel}
|
|
93
|
+
</Button>
|
|
94
|
+
) : null}
|
|
95
|
+
</header>
|
|
96
|
+
|
|
97
|
+
<div aria-live="polite" className="nim-onboarding__stage">
|
|
98
|
+
{slide.art ? <div className="nim-onboarding__art">{slide.art}</div> : null}
|
|
99
|
+
{slide.proof ? (
|
|
100
|
+
<div className="nim-onboarding__proof">
|
|
101
|
+
{slide.proof.icon ? (
|
|
102
|
+
<span className="nim-onboarding__proof-icon">{slide.proof.icon}</span>
|
|
103
|
+
) : null}
|
|
104
|
+
<span className="nim-onboarding__proof-text">
|
|
105
|
+
<span className="nim-onboarding__proof-title">{slide.proof.title}</span>
|
|
106
|
+
{slide.proof.points?.length ? (
|
|
107
|
+
<span className="nim-onboarding__proof-points">{slide.proof.points.join(' · ')}</span>
|
|
108
|
+
) : null}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
) : null}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="nim-onboarding__copy">
|
|
115
|
+
{slide.label ? <span className="nim-onboarding__chip">{slide.label}</span> : null}
|
|
116
|
+
<h1 className="nim-onboarding__title">{slide.title}</h1>
|
|
117
|
+
{slide.body ? <p className="nim-onboarding__body">{slide.body}</p> : null}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<footer className="nim-onboarding__controls">
|
|
121
|
+
<div className="nim-onboarding__dots">
|
|
122
|
+
{slides.map((item, dot) => (
|
|
123
|
+
<button
|
|
124
|
+
aria-current={dot === index ? 'step' : undefined}
|
|
125
|
+
aria-label={text.dot(dot)}
|
|
126
|
+
className="nim-onboarding__dot"
|
|
127
|
+
key={item.id}
|
|
128
|
+
onClick={() => goTo(dot)}
|
|
129
|
+
type="button"
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
<div className="nim-onboarding__cta">
|
|
134
|
+
{index > 0 ? (
|
|
135
|
+
<IconButton
|
|
136
|
+
label={text.back}
|
|
137
|
+
name="chevron-back"
|
|
138
|
+
onClick={() => goTo(index - 1)}
|
|
139
|
+
size="lg"
|
|
140
|
+
variant="outline"
|
|
141
|
+
/>
|
|
142
|
+
) : null}
|
|
143
|
+
<Button
|
|
144
|
+
fullWidth
|
|
145
|
+
iconEnd={last ? 'arrow-forward' : undefined}
|
|
146
|
+
onClick={() => (last ? onDone() : goTo(index + 1))}
|
|
147
|
+
size="lg"
|
|
148
|
+
variant="accent"
|
|
149
|
+
>
|
|
150
|
+
{last ? finishLabel : nextLabel}
|
|
151
|
+
</Button>
|
|
152
|
+
</div>
|
|
153
|
+
{footnote ? <p className="nim-onboarding__footnote">{footnote}</p> : null}
|
|
154
|
+
</footer>
|
|
155
|
+
</section>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import type { ClipboardEvent, KeyboardEvent } from 'react'
|
|
3
|
+
import { cn } from '@/lib/cn'
|
|
4
|
+
import { toAsciiDigits } from '@/lib/countries'
|
|
5
|
+
|
|
6
|
+
export interface OtpInputProps {
|
|
7
|
+
/** Fires as soon as the last box is filled — by typing, pasting, or SMS
|
|
8
|
+
autofill. The caller does not need to watch `value.length` itself. */
|
|
9
|
+
autoFocus?: boolean
|
|
10
|
+
className?: string
|
|
11
|
+
error?: string
|
|
12
|
+
label: string
|
|
13
|
+
length?: number
|
|
14
|
+
onChange: (value: string) => void
|
|
15
|
+
onComplete?: (value: string) => void
|
|
16
|
+
/** Per-box accessible name, e.g. `(i) => `Digit ${i + 1}``. */
|
|
17
|
+
digitLabel?: (index: number) => string
|
|
18
|
+
/** The code so far, ASCII digits only, shorter than `length` while typing. */
|
|
19
|
+
value: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The boxed one-time code.
|
|
24
|
+
*
|
|
25
|
+
* One `<input>` per digit, but the string is the caller's: the component never
|
|
26
|
+
* holds a per-box array, so a paste, an SMS autofill, and a keystroke all take
|
|
27
|
+
* the same path and cannot disagree. The row is pinned `dir="ltr"` even inside
|
|
28
|
+
* a Persian page — a code is a number, and the first digit belongs on the left
|
|
29
|
+
* in every script. Input is normalised through `toAsciiDigits`, so ۱۲۳۴۵ typed
|
|
30
|
+
* on a Persian keyboard arrives as `12345`.
|
|
31
|
+
*
|
|
32
|
+
* `autocomplete="one-time-code"` on the first box is what lets iOS and Android
|
|
33
|
+
* offer the code from the SMS itself.
|
|
34
|
+
*/
|
|
35
|
+
export function OtpInput({
|
|
36
|
+
autoFocus = false,
|
|
37
|
+
className,
|
|
38
|
+
digitLabel,
|
|
39
|
+
error,
|
|
40
|
+
label,
|
|
41
|
+
length = 5,
|
|
42
|
+
onChange,
|
|
43
|
+
onComplete,
|
|
44
|
+
value,
|
|
45
|
+
}: OtpInputProps) {
|
|
46
|
+
const root = useRef<HTMLDivElement>(null)
|
|
47
|
+
const digits = value.slice(0, length).split('')
|
|
48
|
+
|
|
49
|
+
const focusBox = useCallback((index: number) => {
|
|
50
|
+
const boxes = root.current?.querySelectorAll<HTMLInputElement>('input')
|
|
51
|
+
boxes?.[Math.max(0, Math.min(index, boxes.length - 1))]?.focus()
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (autoFocus) focusBox(0)
|
|
56
|
+
}, [autoFocus, focusBox])
|
|
57
|
+
|
|
58
|
+
const commit = useCallback(
|
|
59
|
+
(next: string, caret: number) => {
|
|
60
|
+
const trimmed = next.slice(0, length)
|
|
61
|
+
onChange(trimmed)
|
|
62
|
+
if (trimmed.length === length) onComplete?.(trimmed)
|
|
63
|
+
else focusBox(caret)
|
|
64
|
+
},
|
|
65
|
+
[focusBox, length, onChange, onComplete],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const handleInput = useCallback(
|
|
69
|
+
(index: number, raw: string) => {
|
|
70
|
+
const typed = toAsciiDigits(raw)
|
|
71
|
+
if (!typed) return
|
|
72
|
+
// A box can receive more than one digit — a paste into the middle, or a
|
|
73
|
+
// keyboard suggestion. Write from here and let the rest overflow right.
|
|
74
|
+
const next = (value.slice(0, index) + typed).slice(0, length)
|
|
75
|
+
commit(next, next.length)
|
|
76
|
+
},
|
|
77
|
+
[commit, length, value],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const handleKeyDown = useCallback(
|
|
81
|
+
(index: number, event: KeyboardEvent<HTMLInputElement>) => {
|
|
82
|
+
if (event.key === 'Backspace') {
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
// Backspace on an empty box deletes the digit before it, which is the
|
|
85
|
+
// behaviour a viewer expects from what looks like one field.
|
|
86
|
+
const target = value[index] ? index : index - 1
|
|
87
|
+
if (target < 0) return
|
|
88
|
+
onChange(value.slice(0, target) + value.slice(target + 1))
|
|
89
|
+
focusBox(target)
|
|
90
|
+
} else if (event.key === 'ArrowLeft') {
|
|
91
|
+
focusBox(index - 1)
|
|
92
|
+
} else if (event.key === 'ArrowRight') {
|
|
93
|
+
focusBox(index + 1)
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[focusBox, onChange, value],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const handlePaste = useCallback(
|
|
100
|
+
(event: ClipboardEvent<HTMLDivElement>) => {
|
|
101
|
+
const pasted = toAsciiDigits(event.clipboardData.getData('text'))
|
|
102
|
+
if (!pasted) return
|
|
103
|
+
event.preventDefault()
|
|
104
|
+
commit(pasted.slice(0, length), pasted.length)
|
|
105
|
+
},
|
|
106
|
+
[commit, length],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div className={cn('nim-otp', error && 'nim-otp--invalid', className)}>
|
|
111
|
+
<div
|
|
112
|
+
aria-label={label}
|
|
113
|
+
className="nim-otp__boxes"
|
|
114
|
+
dir="ltr"
|
|
115
|
+
onPaste={handlePaste}
|
|
116
|
+
ref={root}
|
|
117
|
+
role="group"
|
|
118
|
+
>
|
|
119
|
+
{Array.from({ length }, (_, index) => (
|
|
120
|
+
<input
|
|
121
|
+
aria-invalid={error ? true : undefined}
|
|
122
|
+
aria-label={digitLabel ? digitLabel(index) : `${label} ${index + 1}`}
|
|
123
|
+
autoComplete={index === 0 ? 'one-time-code' : 'off'}
|
|
124
|
+
className="nim-otp__box"
|
|
125
|
+
data-filled={digits[index] ? 'true' : undefined}
|
|
126
|
+
enterKeyHint="done"
|
|
127
|
+
inputMode="numeric"
|
|
128
|
+
key={index}
|
|
129
|
+
onChange={(event) => handleInput(index, event.target.value)}
|
|
130
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
131
|
+
onKeyDown={(event) => handleKeyDown(index, event)}
|
|
132
|
+
type="text"
|
|
133
|
+
value={digits[index] ?? ''}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
{error ? (
|
|
138
|
+
<p className="nim-otp__error" role="alert">
|
|
139
|
+
{error}
|
|
140
|
+
</p>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Icon } from '@/components/icon'
|
|
2
|
+
import { cn } from '@/lib/cn'
|
|
3
|
+
|
|
4
|
+
export interface PaginationProps {
|
|
5
|
+
className?: string
|
|
6
|
+
label?: string
|
|
7
|
+
nextLabel?: string
|
|
8
|
+
onChange: (page: number) => void
|
|
9
|
+
page: number
|
|
10
|
+
pageCount: number
|
|
11
|
+
previousLabel?: string
|
|
12
|
+
/** Free text on the leading edge — "Showing 1–6 of 84". */
|
|
13
|
+
summary?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders at most seven slots: first, last, the current page and its
|
|
18
|
+
* neighbours, and an ellipsis wherever the run breaks. The window never
|
|
19
|
+
* changes width as the viewer moves through it, so the control does not
|
|
20
|
+
* reflow under the pointer.
|
|
21
|
+
*/
|
|
22
|
+
const windowFor = (page: number, pageCount: number): (number | 'gap')[] => {
|
|
23
|
+
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
|
|
24
|
+
|
|
25
|
+
const pages = new Set([1, pageCount, page, page - 1, page + 1])
|
|
26
|
+
if (page <= 3) [2, 3, 4].forEach((value) => pages.add(value))
|
|
27
|
+
if (page >= pageCount - 2) [pageCount - 3, pageCount - 2, pageCount - 1].forEach((value) => pages.add(value))
|
|
28
|
+
|
|
29
|
+
const sorted = [...pages].filter((value) => value >= 1 && value <= pageCount).sort((a, b) => a - b)
|
|
30
|
+
|
|
31
|
+
return sorted.flatMap((value, index) => (index > 0 && value - sorted[index - 1]! > 1 ? ['gap' as const, value] : [value]))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Pagination({
|
|
35
|
+
className,
|
|
36
|
+
label = 'Pagination',
|
|
37
|
+
nextLabel = 'Next page',
|
|
38
|
+
onChange,
|
|
39
|
+
page,
|
|
40
|
+
pageCount,
|
|
41
|
+
previousLabel = 'Previous page',
|
|
42
|
+
summary,
|
|
43
|
+
}: PaginationProps) {
|
|
44
|
+
return (
|
|
45
|
+
<nav aria-label={label} className={cn('nim-pagination', className)}>
|
|
46
|
+
{summary ? <p className="nim-pagination__summary">{summary}</p> : <span />}
|
|
47
|
+
<div className="nim-pagination__list">
|
|
48
|
+
<button
|
|
49
|
+
aria-label={previousLabel}
|
|
50
|
+
className="nim-pagination__item"
|
|
51
|
+
disabled={page <= 1}
|
|
52
|
+
onClick={() => onChange(page - 1)}
|
|
53
|
+
type="button"
|
|
54
|
+
>
|
|
55
|
+
<Icon name="chevron-back" size="sm" />
|
|
56
|
+
</button>
|
|
57
|
+
{windowFor(page, pageCount).map((slot, index) =>
|
|
58
|
+
slot === 'gap' ? (
|
|
59
|
+
<span aria-hidden="true" className="nim-pagination__ellipsis" key={`gap-${index}`}>
|
|
60
|
+
…
|
|
61
|
+
</span>
|
|
62
|
+
) : (
|
|
63
|
+
<button
|
|
64
|
+
aria-current={slot === page ? 'page' : undefined}
|
|
65
|
+
className="nim-pagination__item"
|
|
66
|
+
key={slot}
|
|
67
|
+
onClick={() => onChange(slot)}
|
|
68
|
+
type="button"
|
|
69
|
+
>
|
|
70
|
+
{slot}
|
|
71
|
+
</button>
|
|
72
|
+
),
|
|
73
|
+
)}
|
|
74
|
+
<button
|
|
75
|
+
aria-label={nextLabel}
|
|
76
|
+
className="nim-pagination__item"
|
|
77
|
+
disabled={page >= pageCount}
|
|
78
|
+
onClick={() => onChange(page + 1)}
|
|
79
|
+
type="button"
|
|
80
|
+
>
|
|
81
|
+
<Icon name="chevron-forward" size="sm" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</nav>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { InputHTMLAttributes } from 'react'
|
|
3
|
+
import { FieldShell } from '@/components/field'
|
|
4
|
+
import { Icon } from '@/components/icon'
|
|
5
|
+
import { cn } from '@/lib/cn'
|
|
6
|
+
|
|
7
|
+
export type PasswordStrength = 'fair' | 'good' | 'strong' | 'weak'
|
|
8
|
+
|
|
9
|
+
export interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type'> {
|
|
10
|
+
error?: string
|
|
11
|
+
hint?: string
|
|
12
|
+
label?: string
|
|
13
|
+
/** Draws the meter. Omit on a sign-in field: scoring a password someone
|
|
14
|
+
already has tells them nothing they can act on. */
|
|
15
|
+
strength?: PasswordStrength
|
|
16
|
+
/** Accessible names for the reveal toggle and the meter. */
|
|
17
|
+
labels?: { hide: string; show: string; strength: (level: PasswordStrength) => string }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LABELS = {
|
|
21
|
+
hide: 'Hide password',
|
|
22
|
+
show: 'Show password',
|
|
23
|
+
strength: (level: PasswordStrength) => `Password strength: ${level}`,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STEPS: PasswordStrength[] = ['weak', 'fair', 'good', 'strong']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A password input with a reveal toggle and an optional strength meter.
|
|
30
|
+
*
|
|
31
|
+
* Revealing is a real `type` swap rather than a font trick, so a password
|
|
32
|
+
* manager still sees a password field, and the toggle is a button with a
|
|
33
|
+
* label — an icon that changes silently leaves a screen reader unable to tell
|
|
34
|
+
* whether the characters are showing.
|
|
35
|
+
*
|
|
36
|
+
* Scoring is the caller's: strength depends on the policy being enforced, and
|
|
37
|
+
* a meter that disagrees with the server's rules is worse than none.
|
|
38
|
+
*/
|
|
39
|
+
export function PasswordField({
|
|
40
|
+
className,
|
|
41
|
+
error,
|
|
42
|
+
hint,
|
|
43
|
+
id,
|
|
44
|
+
label,
|
|
45
|
+
labels,
|
|
46
|
+
required,
|
|
47
|
+
strength,
|
|
48
|
+
...props
|
|
49
|
+
}: PasswordFieldProps) {
|
|
50
|
+
const [visible, setVisible] = useState(false)
|
|
51
|
+
const text = { ...DEFAULT_LABELS, ...labels }
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<FieldShell error={error} hint={hint} id={id} label={label} required={required}>
|
|
55
|
+
{({ control, describedBy }) => (
|
|
56
|
+
<>
|
|
57
|
+
<div className="nim-input-shell nim-input-shell--has-end">
|
|
58
|
+
<input
|
|
59
|
+
aria-describedby={describedBy}
|
|
60
|
+
aria-invalid={error ? true : undefined}
|
|
61
|
+
autoComplete={props.autoComplete ?? 'current-password'}
|
|
62
|
+
className={cn('nim-input', className)}
|
|
63
|
+
id={control}
|
|
64
|
+
required={required}
|
|
65
|
+
{...props}
|
|
66
|
+
type={visible ? 'text' : 'password'}
|
|
67
|
+
/>
|
|
68
|
+
<button
|
|
69
|
+
aria-controls={control}
|
|
70
|
+
aria-label={visible ? text.hide : text.show}
|
|
71
|
+
aria-pressed={visible}
|
|
72
|
+
className="nim-password__toggle"
|
|
73
|
+
onClick={() => setVisible((was) => !was)}
|
|
74
|
+
type="button"
|
|
75
|
+
>
|
|
76
|
+
<Icon name="eye" size="sm" />
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{strength ? (
|
|
81
|
+
<div
|
|
82
|
+
aria-label={text.strength(strength)}
|
|
83
|
+
className="nim-password__meter"
|
|
84
|
+
data-level={strength}
|
|
85
|
+
role="img"
|
|
86
|
+
>
|
|
87
|
+
{STEPS.map((step, index) => (
|
|
88
|
+
<span
|
|
89
|
+
className="nim-password__step"
|
|
90
|
+
data-on={index <= STEPS.indexOf(strength) ? 'true' : undefined}
|
|
91
|
+
key={step}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
) : null}
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</FieldShell>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A default score for products without a policy of their own: length first,
|
|
104
|
+
* then variety. Deliberately blunt — it is a hint, never a gate.
|
|
105
|
+
*/
|
|
106
|
+
export function scorePassword(password: string): PasswordStrength {
|
|
107
|
+
if (password.length < 8) return 'weak'
|
|
108
|
+
const variety = [/[a-z]/, /[A-Z]/, /\d/, /[^\w\s]/].filter((rule) => rule.test(password)).length
|
|
109
|
+
if (password.length >= 14 && variety >= 3) return 'strong'
|
|
110
|
+
if (password.length >= 10 && variety >= 2) return 'good'
|
|
111
|
+
return 'fair'
|
|
112
|
+
}
|