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