@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,61 @@
1
+ import type { HTMLAttributes } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
5
+ label?: string
6
+ size?: 'lg' | 'md' | 'sm'
7
+ }
8
+
9
+ export function Spinner({ className, label = 'Loading', size = 'md', ...props }: SpinnerProps) {
10
+ return (
11
+ <span
12
+ className={cn('nim-spinner', size !== 'md' && `nim-spinner--${size}`, className)}
13
+ role="status"
14
+ {...props}
15
+ >
16
+ <span className="nim-visually-hidden">{label}</span>
17
+ </span>
18
+ )
19
+ }
20
+
21
+ export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
22
+ label?: string
23
+ /** Omit for an indeterminate bar. */
24
+ value?: number
25
+ }
26
+
27
+ export function Progress({ className, label, value, ...props }: ProgressProps) {
28
+ const indeterminate = value === undefined
29
+ const clamped = indeterminate ? 0 : Math.min(100, Math.max(0, value))
30
+
31
+ return (
32
+ <div
33
+ aria-label={label}
34
+ aria-valuemax={100}
35
+ aria-valuemin={0}
36
+ aria-valuenow={indeterminate ? undefined : clamped}
37
+ className={cn('nim-progress', indeterminate && 'nim-progress--indeterminate', className)}
38
+ role="progressbar"
39
+ {...props}
40
+ >
41
+ <div className="nim-progress__fill" style={indeterminate ? undefined : { inlineSize: `${clamped}%` }} />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export interface SkeletonProps extends HTMLAttributes<HTMLSpanElement> {
47
+ height?: number | string
48
+ radius?: string
49
+ width?: number | string
50
+ }
51
+
52
+ export function Skeleton({ className, height = '1em', radius, width = '100%', ...props }: SkeletonProps) {
53
+ return (
54
+ <span
55
+ aria-hidden="true"
56
+ className={cn('nim-skeleton', className)}
57
+ style={{ blockSize: height, borderRadius: radius, inlineSize: width }}
58
+ {...props}
59
+ />
60
+ )
61
+ }
@@ -0,0 +1,177 @@
1
+ import { useId } from 'react'
2
+ import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react'
3
+ import { Icon, type IconName } from '@/components/icon'
4
+ import { cn } from '@/lib/cn'
5
+
6
+ export interface FieldShellProps {
7
+ children: (ids: { control: string; describedBy?: string }) => ReactNode
8
+ className?: string
9
+ error?: string
10
+ hint?: string
11
+ id?: string
12
+ label?: string
13
+ required?: boolean
14
+ }
15
+
16
+ /**
17
+ * Every nim form control shares this frame, which is what guarantees that a
18
+ * label, a hint, and an error are wired to the control with the right ids on
19
+ * every screen — the part teams most often get wrong by hand.
20
+ */
21
+ export function FieldShell({ children, className, error, hint, id, label, required }: FieldShellProps) {
22
+ const generated = useId()
23
+ const control = id ?? `nim-${generated}`
24
+ const hintId = hint ? `${control}-hint` : undefined
25
+ const errorId = error ? `${control}-error` : undefined
26
+ const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined
27
+
28
+ return (
29
+ <div className={cn('nim-field', error && 'nim-field--invalid', className)}>
30
+ {label ? (
31
+ <label className="nim-field__label" htmlFor={control}>
32
+ {label}
33
+ {required ? (
34
+ <span aria-hidden="true" className="nim-field__required">
35
+ *
36
+ </span>
37
+ ) : null}
38
+ </label>
39
+ ) : null}
40
+ {children({ control, describedBy })}
41
+ {error ? (
42
+ <p className="nim-field__error" id={errorId}>
43
+ {error}
44
+ </p>
45
+ ) : null}
46
+ {hint && !error ? (
47
+ <p className="nim-field__hint" id={hintId}>
48
+ {hint}
49
+ </p>
50
+ ) : null}
51
+ </div>
52
+ )
53
+ }
54
+
55
+ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
56
+ error?: string
57
+ hint?: string
58
+ iconEnd?: IconName
59
+ iconStart?: IconName
60
+ label?: string
61
+ }
62
+
63
+ export function Input({ className, error, hint, iconEnd, iconStart, id, label, required, ...props }: InputProps) {
64
+ return (
65
+ <FieldShell error={error} hint={hint} id={id} label={label} required={required}>
66
+ {({ control, describedBy }) => (
67
+ <div
68
+ className={cn(
69
+ 'nim-input-shell',
70
+ iconStart && 'nim-input-shell--has-start',
71
+ iconEnd && 'nim-input-shell--has-end',
72
+ )}
73
+ >
74
+ {iconStart ? (
75
+ <span className="nim-input-shell__affix nim-input-shell__affix--start">
76
+ <Icon name={iconStart} size="sm" />
77
+ </span>
78
+ ) : null}
79
+ <input
80
+ aria-describedby={describedBy}
81
+ aria-invalid={error ? true : undefined}
82
+ className={cn('nim-input', className)}
83
+ id={control}
84
+ required={required}
85
+ {...props}
86
+ />
87
+ {iconEnd ? (
88
+ <span className="nim-input-shell__affix nim-input-shell__affix--end">
89
+ <Icon name={iconEnd} size="sm" />
90
+ </span>
91
+ ) : null}
92
+ </div>
93
+ )}
94
+ </FieldShell>
95
+ )
96
+ }
97
+
98
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
99
+ error?: string
100
+ hint?: string
101
+ label?: string
102
+ }
103
+
104
+ export function Textarea({ className, error, hint, id, label, required, rows = 4, ...props }: TextareaProps) {
105
+ return (
106
+ <FieldShell error={error} hint={hint} id={id} label={label} required={required}>
107
+ {({ control, describedBy }) => (
108
+ <textarea
109
+ aria-describedby={describedBy}
110
+ aria-invalid={error ? true : undefined}
111
+ className={cn('nim-textarea', className)}
112
+ id={control}
113
+ required={required}
114
+ rows={rows}
115
+ {...props}
116
+ />
117
+ )}
118
+ </FieldShell>
119
+ )
120
+ }
121
+
122
+ export interface SelectOption {
123
+ disabled?: boolean
124
+ label: string
125
+ value: string
126
+ }
127
+
128
+ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
129
+ error?: string
130
+ hint?: string
131
+ label?: string
132
+ options: SelectOption[]
133
+ placeholder?: string
134
+ }
135
+
136
+ export function Select({
137
+ className,
138
+ error,
139
+ hint,
140
+ id,
141
+ label,
142
+ options,
143
+ placeholder,
144
+ required,
145
+ ...props
146
+ }: SelectProps) {
147
+ return (
148
+ <FieldShell error={error} hint={hint} id={id} label={label} required={required}>
149
+ {({ control, describedBy }) => (
150
+ <div className="nim-input-shell nim-input-shell--has-end">
151
+ <select
152
+ aria-describedby={describedBy}
153
+ aria-invalid={error ? true : undefined}
154
+ className={cn('nim-select', className)}
155
+ id={control}
156
+ required={required}
157
+ {...props}
158
+ >
159
+ {placeholder ? (
160
+ <option value="" disabled>
161
+ {placeholder}
162
+ </option>
163
+ ) : null}
164
+ {options.map((option) => (
165
+ <option disabled={option.disabled} key={option.value} value={option.value}>
166
+ {option.label}
167
+ </option>
168
+ ))}
169
+ </select>
170
+ <span className="nim-input-shell__affix nim-input-shell__affix--end">
171
+ <Icon name="chevron-down" size="sm" />
172
+ </span>
173
+ </div>
174
+ )}
175
+ </FieldShell>
176
+ )
177
+ }
@@ -0,0 +1,35 @@
1
+ import { forwardRef } from 'react'
2
+ import type { ButtonHTMLAttributes } from 'react'
3
+ import { Icon, type IconName, type IconSize } from '@/components/icon'
4
+ import { cn } from '@/lib/cn'
5
+
6
+ export type IconButtonSize = 'sm' | 'md' | 'lg'
7
+ export type IconButtonVariant = 'ghost' | 'outline' | 'solid'
8
+
9
+ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
10
+ /** Required: an icon-only control has no other accessible name. */
11
+ label: string
12
+ name: IconName
13
+ size?: IconButtonSize
14
+ variant?: IconButtonVariant
15
+ }
16
+
17
+ const GLYPH_SIZE: Record<IconButtonSize, IconSize> = { sm: 'sm', md: 'md', lg: 'md' }
18
+
19
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
20
+ { className, label, name, size = 'md', type = 'button', variant = 'ghost', ...props },
21
+ ref,
22
+ ) {
23
+ return (
24
+ <button
25
+ aria-label={label}
26
+ className={cn('nim-icon-button', `nim-icon-button--${variant}`, `nim-icon-button--${size}`, className)}
27
+ ref={ref}
28
+ title={label}
29
+ type={type}
30
+ {...props}
31
+ >
32
+ <Icon name={name} size={GLYPH_SIZE[size]} />
33
+ </button>
34
+ )
35
+ })
@@ -0,0 +1,167 @@
1
+ import {
2
+ AlertTriangle,
3
+ ArrowRight,
4
+ Bell,
5
+ Bookmark,
6
+ Calendar,
7
+ Camera,
8
+ Check,
9
+ ChevronDown,
10
+ ChevronLeft,
11
+ ChevronRight,
12
+ ChevronUp,
13
+ CircleAlert,
14
+ CircleStop,
15
+ CircleCheck,
16
+ Clock,
17
+ Copy,
18
+ Download,
19
+ ExternalLink,
20
+ Eye,
21
+ FileText,
22
+ Filter,
23
+ Heart,
24
+ Home,
25
+ Info,
26
+ Loader,
27
+ LogOut,
28
+ Menu,
29
+ Mic,
30
+ Minus,
31
+ Moon,
32
+ Paperclip,
33
+ Pause,
34
+ Pencil,
35
+ Play,
36
+ Plus,
37
+ Search,
38
+ Send,
39
+ Settings,
40
+ Share2,
41
+ Sparkles,
42
+ Star,
43
+ Sun,
44
+ Trash2,
45
+ TrendingDown,
46
+ TrendingUp,
47
+ Upload,
48
+ Video,
49
+ User,
50
+ Wallet,
51
+ X,
52
+ } from 'lucide-react'
53
+ import type { LucideIcon } from 'lucide-react'
54
+ import type { SVGProps } from 'react'
55
+ import { cn } from '@/lib/cn'
56
+
57
+ /**
58
+ * Icons are addressed by role, not by vendor name. The registry is the whole
59
+ * point: it keeps the icon set finite and reviewable, it stops two screens
60
+ * meaning "delete" with two different glyphs, and it means swapping icon
61
+ * libraries later is one file rather than a codebase sweep.
62
+ */
63
+ const REGISTRY = {
64
+ alert: AlertTriangle,
65
+ 'arrow-forward': ArrowRight,
66
+ bell: Bell,
67
+ bookmark: Bookmark,
68
+ calendar: Calendar,
69
+ camera: Camera,
70
+ check: Check,
71
+ 'check-circle': CircleCheck,
72
+ 'chevron-back': ChevronLeft,
73
+ 'chevron-down': ChevronDown,
74
+ 'chevron-forward': ChevronRight,
75
+ 'chevron-up': ChevronUp,
76
+ clock: Clock,
77
+ close: X,
78
+ copy: Copy,
79
+ danger: CircleAlert,
80
+ document: FileText,
81
+ download: Download,
82
+ edit: Pencil,
83
+ external: ExternalLink,
84
+ eye: Eye,
85
+ filter: Filter,
86
+ heart: Heart,
87
+ home: Home,
88
+ info: Info,
89
+ loading: Loader,
90
+ menu: Menu,
91
+ mic: Mic,
92
+ minus: Minus,
93
+ moon: Moon,
94
+ paperclip: Paperclip,
95
+ pause: Pause,
96
+ play: Play,
97
+ plus: Plus,
98
+ search: Search,
99
+ send: Send,
100
+ settings: Settings,
101
+ share: Share2,
102
+ 'sign-out': LogOut,
103
+ stop: CircleStop,
104
+ sparkle: Sparkles,
105
+ star: Star,
106
+ sun: Sun,
107
+ trash: Trash2,
108
+ 'trend-down': TrendingDown,
109
+ 'trend-up': TrendingUp,
110
+ upload: Upload,
111
+ video: Video,
112
+ user: User,
113
+ wallet: Wallet,
114
+ } satisfies Record<string, LucideIcon>
115
+
116
+ /**
117
+ * Icons that point somewhere. These mirror under `dir="rtl"`, because a
118
+ * "forward" arrow in Persian points left; everything else — a plus, a check, a
119
+ * clock — means the same thing in both directions and must NOT be flipped.
120
+ * Getting this wrong per-component is why RTL interfaces end up with mirrored
121
+ * checkmarks, so the decision lives here, once.
122
+ */
123
+ const DIRECTIONAL = new Set<string>([
124
+ 'arrow-forward',
125
+ 'chevron-back',
126
+ 'chevron-forward',
127
+ 'external',
128
+ 'send',
129
+ 'share',
130
+ 'sign-out',
131
+ ])
132
+
133
+ export type IconName = keyof typeof REGISTRY
134
+ export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
135
+ export type IconTone = 'accent' | 'danger' | 'default' | 'muted' | 'success' | 'warning'
136
+
137
+ const SIZES: Record<IconSize, number> = { xs: 14, sm: 16, md: 20, lg: 24, xl: 32 }
138
+
139
+ export interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'ref'> {
140
+ /** Give a label only when the icon is the sole carrier of meaning. */
141
+ label?: string
142
+ name: IconName
143
+ size?: IconSize
144
+ tone?: IconTone
145
+ }
146
+
147
+ export function Icon({ className, label, name, size = 'md', tone = 'default', ...props }: IconProps) {
148
+ const Glyph = REGISTRY[name]
149
+
150
+ return (
151
+ <Glyph
152
+ aria-hidden={label ? undefined : true}
153
+ aria-label={label}
154
+ className={cn('nim-icon', className)}
155
+ data-flip={DIRECTIONAL.has(name) ? 'true' : undefined}
156
+ data-tone={tone === 'default' ? undefined : tone}
157
+ focusable="false"
158
+ height={SIZES[size]}
159
+ role={label ? 'img' : undefined}
160
+ strokeWidth={1.75}
161
+ width={SIZES[size]}
162
+ {...props}
163
+ />
164
+ )
165
+ }
166
+
167
+ export const iconNames = Object.keys(REGISTRY) as IconName[]
@@ -0,0 +1,38 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { cn } from '@/lib/cn'
3
+
4
+ interface BoxProps extends HTMLAttributes<HTMLDivElement> {
5
+ children: ReactNode
6
+ }
7
+
8
+ /** The mobile app viewport, centred on a desktop screen. */
9
+ export function AppFrame({ children, className, ...props }: BoxProps) {
10
+ return (
11
+ <div className={cn('nim-app-frame', className)} {...props}>
12
+ {children}
13
+ </div>
14
+ )
15
+ }
16
+
17
+ /** Vertical rhythm. Spacing between components belongs to the page, not to
18
+ the components, and this is where the page expresses it. */
19
+ export function Stack({
20
+ children,
21
+ className,
22
+ gap = 'md',
23
+ ...props
24
+ }: BoxProps & { gap?: 'loose' | 'md' | 'tight' }) {
25
+ return (
26
+ <div className={cn('nim-stack', gap !== 'md' && `nim-stack--${gap}`, className)} {...props}>
27
+ {children}
28
+ </div>
29
+ )
30
+ }
31
+
32
+ export function Inline({ children, className, ...props }: BoxProps) {
33
+ return (
34
+ <div className={cn('nim-inline', className)} {...props}>
35
+ {children}
36
+ </div>
37
+ )
38
+ }
@@ -0,0 +1,78 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react'
2
+ import { Icon } from '@/components/icon'
3
+ import { cn } from '@/lib/cn'
4
+
5
+ export interface ListProps extends HTMLAttributes<HTMLDivElement> {
6
+ children: ReactNode
7
+ /** Drops the surrounding plate and keeps only the dividers. */
8
+ plain?: boolean
9
+ }
10
+
11
+ export function List({ children, className, plain = false, ...props }: ListProps) {
12
+ return (
13
+ <div className={cn('nim-list', plain && 'nim-list--plain', className)} {...props}>
14
+ {children}
15
+ </div>
16
+ )
17
+ }
18
+
19
+ export interface ListRowProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
20
+ /** Renders the row as a link. Mutually exclusive with `onClick`. */
21
+ href?: string
22
+ leading?: ReactNode
23
+ subtitle?: ReactNode
24
+ title: ReactNode
25
+ trailing?: ReactNode
26
+ }
27
+
28
+ export function ListRow({
29
+ className,
30
+ href,
31
+ leading,
32
+ onClick,
33
+ subtitle,
34
+ title,
35
+ trailing,
36
+ ...props
37
+ }: ListRowProps) {
38
+ const interactive = Boolean(href || onClick)
39
+ const content = (
40
+ <>
41
+ {leading ? <span className="nim-list-row__leading">{leading}</span> : null}
42
+ <span className="nim-list-row__content">
43
+ <span className="nim-list-row__title">{title}</span>
44
+ {subtitle ? <span className="nim-list-row__subtitle">{subtitle}</span> : null}
45
+ </span>
46
+ {trailing ? <span className="nim-list-row__trailing">{trailing}</span> : null}
47
+ {interactive && !trailing ? (
48
+ <Icon className="nim-list-row__chevron" name="chevron-forward" size="sm" />
49
+ ) : null}
50
+ </>
51
+ )
52
+
53
+ const classes = cn('nim-list-row', interactive && 'nim-list-row--interactive', className)
54
+
55
+ // A row that does something is a real button or link, never a div with a
56
+ // click handler — that is what gives it keyboard focus for free.
57
+ if (href) {
58
+ return (
59
+ <a className={classes} href={href} {...props}>
60
+ {content}
61
+ </a>
62
+ )
63
+ }
64
+
65
+ if (onClick) {
66
+ return (
67
+ <button className={classes} onClick={onClick} type="button" {...props}>
68
+ {content}
69
+ </button>
70
+ )
71
+ }
72
+
73
+ return (
74
+ <div className={classes} {...props}>
75
+ {content}
76
+ </div>
77
+ )
78
+ }