nitro-web 0.0.85 → 0.0.87

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 (41) hide show
  1. package/client/globals.ts +10 -6
  2. package/package.json +14 -6
  3. package/types/{required-globals.d.ts → globals.d.ts} +3 -1
  4. package/.editorconfig +0 -9
  5. package/components/auth/auth.api.js +0 -410
  6. package/components/auth/reset.tsx +0 -86
  7. package/components/auth/signin.tsx +0 -76
  8. package/components/auth/signup.tsx +0 -62
  9. package/components/billing/stripe.api.js +0 -268
  10. package/components/dashboard/dashboard.tsx +0 -32
  11. package/components/partials/element/accordion.tsx +0 -102
  12. package/components/partials/element/avatar.tsx +0 -40
  13. package/components/partials/element/button.tsx +0 -98
  14. package/components/partials/element/calendar.tsx +0 -125
  15. package/components/partials/element/dropdown.tsx +0 -248
  16. package/components/partials/element/filters.tsx +0 -194
  17. package/components/partials/element/github-link.tsx +0 -16
  18. package/components/partials/element/initials.tsx +0 -66
  19. package/components/partials/element/message.tsx +0 -141
  20. package/components/partials/element/modal.tsx +0 -90
  21. package/components/partials/element/sidebar.tsx +0 -195
  22. package/components/partials/element/tooltip.tsx +0 -154
  23. package/components/partials/element/topbar.tsx +0 -15
  24. package/components/partials/form/checkbox.tsx +0 -150
  25. package/components/partials/form/drop-handler.tsx +0 -68
  26. package/components/partials/form/drop.tsx +0 -141
  27. package/components/partials/form/field-color.tsx +0 -86
  28. package/components/partials/form/field-currency.tsx +0 -158
  29. package/components/partials/form/field-date.tsx +0 -252
  30. package/components/partials/form/field.tsx +0 -231
  31. package/components/partials/form/form-error.tsx +0 -27
  32. package/components/partials/form/location.tsx +0 -225
  33. package/components/partials/form/select.tsx +0 -360
  34. package/components/partials/is-first-render.ts +0 -14
  35. package/components/partials/not-found.tsx +0 -7
  36. package/components/partials/styleguide.tsx +0 -407
  37. package/semver-updater.cjs +0 -13
  38. package/tsconfig.json +0 -38
  39. package/tsconfig.types.json +0 -15
  40. package/types/core-only-globals.d.ts +0 -9
  41. package/types.ts +0 -60
@@ -1,252 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { format, isValid, parse } from 'date-fns'
3
- import { getPrefixWidth } from 'nitro-web/util'
4
- import { Calendar, Dropdown } from 'nitro-web'
5
- import { dayButtonClassName } from '../element/calendar'
6
-
7
- type Mode = 'single' | 'multiple' | 'range'
8
- type DropdownRef = {
9
- setIsActive: (value: boolean) => void
10
- }
11
-
12
- type PreFieldDateProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
13
- /** field name or path on state (used to match errors), e.g. 'date', 'company.email' **/
14
- name: string
15
- /** mode of the date picker */
16
- mode: Mode
17
- /** name is used as the id if not provided */
18
- id?: string
19
- /** show the time picker */
20
- showTime?: boolean
21
- /** prefix to add to the input */
22
- prefix?: string
23
- /** number of months to show in the dropdown */
24
- numberOfMonths?: number
25
- /** icon to show in the input */
26
- Icon?: React.ReactNode
27
- /** direction of the dropdown */
28
- dir?: 'bottom-left'|'bottom-right'|'top-left'|'top-right'
29
- }
30
-
31
- // An array is returned for mode = 'multiple' or 'range'
32
- export type FieldDateProps = (
33
- | ({ mode: 'single' } & PreFieldDateProps & {
34
- onChange?: (e: { target: { name: string, value: null|number } }) => void
35
- value?: null|number|string
36
- })
37
- | ({ mode: 'multiple' | 'range' } & PreFieldDateProps & {
38
- onChange?: (e: { target: { name: string, value: (null|number)[] } }) => void
39
- value?: null|number|string|(null|number|string)[]
40
- })
41
- )
42
-
43
- type TimePickerProps = {
44
- date: Date|null
45
- onChange: (mode: Mode, value: number|null) => void
46
- }
47
-
48
- export function FieldDate({
49
- dir = 'bottom-left',
50
- Icon,
51
- mode,
52
- numberOfMonths,
53
- onChange: onChangeProp,
54
- prefix = '',
55
- showTime,
56
- value: valueProp,
57
- ...props
58
- }: FieldDateProps) {
59
- const localePattern = `d MMM yyyy${showTime && mode == 'single' ? ' hh:mmaa' : ''}`
60
- const [prefixWidth, setPrefixWidth] = useState(0)
61
- const dropdownRef = useRef<DropdownRef>(null)
62
- const [month, setMonth] = useState<number|undefined>()
63
- const [lastUpdated, setLastUpdated] = useState(0)
64
- const id = props.id || props.name
65
-
66
- // Since value and onChange are optional, we need to hold the value in state if not provided
67
- const [internalValue, setInternalValue] = useState<typeof valueProp>(valueProp)
68
- const value = valueProp ?? internalValue
69
- const onChange = onChangeProp ?? ((e: { target: { name: string, value: any } }) => setInternalValue(e.target.value))
70
-
71
- // Convert the value to an array of valid* dates
72
- const dates = useMemo(() => {
73
- const arrOfNumbers = typeof value === 'string'
74
- ? value.split(/\s*,\s*/g).map(o => parseFloat(o))
75
- : Array.isArray(value) ? value : [value]
76
- const out = arrOfNumbers.map(date => isValid(date) ? new Date(date as number) : null) /// changed to null
77
- return out
78
- }, [value])
79
-
80
- // Hold the input value in state
81
- const [inputValue, setInputValue] = useState(() => getInputValue(dates))
82
-
83
- // Update the date's inputValue (text) when the value changes outside of the component
84
- useEffect(() => {
85
- if (new Date().getTime() > lastUpdated + 100) setInputValue(getInputValue(dates))
86
- }, [dates])
87
-
88
- // Get the prefix content width
89
- useEffect(() => {
90
- setPrefixWidth(getPrefixWidth(prefix, 4))
91
- }, [prefix])
92
-
93
- function onCalendarChange(mode: Mode, value: null|number|(null|number)[]) {
94
- if (mode == 'single' && !showTime) dropdownRef.current?.setIsActive(false) // Close the dropdown
95
- setInputValue(getInputValue(value))
96
- // Update the value
97
- onChange({ target: { name: props.name, value: getOutputValue(value) } })
98
- setLastUpdated(new Date().getTime())
99
- }
100
-
101
- function onInputChange(e: React.ChangeEvent<HTMLInputElement>) {
102
- setInputValue(e.target.value) // keep the input value in sync
103
-
104
- let split = e.target.value.split(/-|,/).map(o => {
105
- const date = parse(o.trim(), localePattern, new Date())
106
- return isValid(date) ? date : null
107
- })
108
-
109
- // For single/range we need limit the array
110
- if (mode == 'range' && split.length > 1) split.length = 2
111
- else if (mode == 'multiple') split = split.filter(o => o) // remove invalid dates
112
-
113
- // Swap dates if needed
114
- if (mode == 'range' && (split[0] || 0) > (split[1] || 0)) split = [split[0], split[0]]
115
-
116
- // Set month
117
- for (let i=split.length; i--;) {
118
- if (split[i]) setMonth((split[i] as Date).getTime())
119
- break
120
- }
121
-
122
- // Update the value
123
- const value = mode == 'single' ? split[0]?.getTime() ?? null : split.map(d => d?.getTime() ?? null)
124
- onChange({ target: { name: props.name, value: getOutputValue(value) }})
125
- setLastUpdated(new Date().getTime())
126
- }
127
-
128
- function getInputValue(value: Date|number|null|(Date|number|null)[]) {
129
- const _dates = Array.isArray(value) ? value : [value]
130
- return _dates.map(o => o ? format(o, localePattern) : '').join(mode == 'range' ? ' - ' : ', ')
131
- }
132
-
133
- function getOutputValue(value: Date|number|null|(Date|number|null)[]): any {
134
- // console.log(value)
135
- return value
136
- }
137
-
138
- return (
139
- <Dropdown
140
- ref={dropdownRef}
141
- menuToggles={false}
142
- // animate={false}
143
- // menuIsOpen={true}
144
- minWidth={0}
145
- menuContent={
146
- <div className="flex">
147
- <Calendar
148
- // Calendar actually accepts an array of dates, but the type is not typed correctly
149
- {...{ mode: mode, value: dates as any, numberOfMonths: numberOfMonths, month: month }}
150
- preserveTime={!!showTime}
151
- onChange={onCalendarChange}
152
- className="pt-1 pb-2 px-3"
153
- />
154
- {!!showTime && mode == 'single' && <TimePicker date={dates?.[0]} onChange={onCalendarChange} />}
155
- </div>
156
- }
157
- dir={dir}
158
- >
159
- <div className="grid grid-cols-1">
160
- {Icon}
161
- {
162
- prefix &&
163
- // Similar classNames to the input.tsx:IconWrapper()
164
- <span className="z-[0] col-start-1 row-start-1 self-center select-none justify-self-start text-input-base ml-[12px] ml-input-x">
165
- {prefix}
166
- </span>
167
- }
168
- <input
169
- {...props}
170
- key={'k' + prefixWidth}
171
- id={id}
172
- autoComplete="off"
173
- className={(props.className||'')}// + props.className?.includes('is-invalid') ? ' is-invalid' : ''}
174
- onBlur={() => setInputValue(getInputValue(dates))}
175
- onChange={onInputChange}
176
- style={{ textIndent: prefixWidth + 'px' }}
177
- type="text"
178
- value={inputValue}
179
- />
180
- </div>
181
- </Dropdown>
182
- )
183
- }
184
-
185
- function TimePicker({ date, onChange }: TimePickerProps) {
186
- const lists = [
187
- [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // hours
188
- [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55], // minutes
189
- ['AM', 'PM'], // AM/PM
190
- ]
191
-
192
- // Get current values from date or use defaults
193
- const hour = date ? parseInt(format(date, 'h')) : undefined
194
- const minute = date ? parseInt(format(date, 'm')) : undefined
195
- const period = date ? format(date, 'a') : undefined
196
-
197
- const handleTimeChange = (type: 'hour' | 'minute' | 'period', value: string | number) => {
198
- // Create a new date object from the current date or current time
199
- const newDate = new Date(date || new Date())
200
-
201
- if (type === 'hour') {
202
- // Parse the time with the new hour value
203
- const timeString = `${value}:${format(newDate, 'mm')} ${format(newDate, 'a')}`
204
- const updatedDate = parse(timeString, 'h:mm a', newDate)
205
- newDate.setHours(updatedDate.getHours(), updatedDate.getMinutes())
206
- } else if (type === 'minute') {
207
- // Parse the time with the new minute value
208
- const timeString = `${format(newDate, 'h')}:${value} ${format(newDate, 'a')}`
209
- const updatedDate = parse(timeString, 'h:mm a', newDate)
210
- newDate.setMinutes(updatedDate.getMinutes())
211
- } else if (type === 'period') {
212
- // Parse the time with the new period value
213
- const timeString = `${format(newDate, 'h')}:${format(newDate, 'mm')} ${value}`
214
- const updatedDate = parse(timeString, 'h:mm a', newDate)
215
- newDate.setHours(updatedDate.getHours())
216
- }
217
-
218
- onChange('single', newDate.getTime())
219
- }
220
-
221
- return (
222
- lists.map((list, i) => {
223
- const type = i === 0 ? 'hour' : i === 1 ? 'minute' : 'period'
224
- const currentValue = i === 0 ? hour : i === 1 ? minute : period
225
-
226
- return (
227
- <div key={i} className="w-[60px] py-1 relative overflow-hidden hover:overflow-y-auto border-l border-gray-100">
228
- <div className="w-[60px] absolute flex flex-col items-center">
229
- {list.map(item => (
230
- <div
231
- className="py-1 flex group cursor-pointer"
232
- key={item}
233
- onClick={() => handleTimeChange(type, item)}
234
- >
235
- <button
236
- key={item}
237
- className={
238
- `${dayButtonClassName} rounded-full flex justify-center items-center group-hover:bg-gray-100 `
239
- + (item === currentValue ? '!bg-input-border-focus text-white' : '')
240
- }
241
- onClick={() => handleTimeChange(type, item)}
242
- >
243
- {item.toString().padStart(2, '0').toLowerCase()}
244
- </button>
245
- </div>
246
- ))}
247
- </div>
248
- </div>
249
- )
250
- })
251
- )
252
- }
@@ -1,231 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- // fill-current tw class for lucide icons (https://github.com/lucide-icons/lucide/discussions/458)
3
- import { css } from 'twin.macro'
4
- import { FieldCurrency, FieldCurrencyProps, FieldColor, FieldColorProps, FieldDate, FieldDateProps } from 'nitro-web'
5
- import { twMerge, getErrorFromState, deepFind } from 'nitro-web/util'
6
- import { Errors, type Error } from 'nitro-web/types'
7
- import { MailIcon, CalendarIcon, FunnelIcon, SearchIcon, EyeIcon, EyeOffIcon } from 'lucide-react'
8
- import { memo } from 'react'
9
-
10
- type FieldType = 'text' | 'password' | 'email' | 'filter' | 'search' | 'textarea' | 'currency' | 'date' | 'color'
11
- type InputProps = React.InputHTMLAttributes<HTMLInputElement>
12
- type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
13
- type FieldExtraProps = {
14
- /** field name or path on state (used to match errors), e.g. 'date', 'company.email' */
15
- name: string
16
- /** name is applied if id is not provided */
17
- id?: string
18
- /** state object to get the value, and check errors against */
19
- state?: { errors?: Errors, [key: string]: any }
20
- /** type of the field */
21
- type?: FieldType
22
- /** icon to show in the input */
23
- icon?: React.ReactNode
24
- iconPos?: 'left' | 'right'
25
- /** Dependencies to break the implicit memoization of onChange/onInputChange */
26
- deps?: unknown[]
27
- placeholder?: string
28
- /** title used to find related error messages */
29
- errorTitle?: string|RegExp
30
- }
31
- type IconWrapperProps = {
32
- iconPos: 'left' | 'right'
33
- icon?: React.ReactNode
34
- [key: string]: unknown
35
- }
36
- // Discriminated union (https://stackoverflow.com/a/77351290/1900648)
37
- export type FieldProps = (
38
- | ({ type?: 'text' | 'password' | 'email' | 'filter' | 'search' } & InputProps & FieldExtraProps)
39
- | ({ type: 'textarea' } & TextareaProps & FieldExtraProps)
40
- | ({ type: 'currency' } & FieldCurrencyProps & FieldExtraProps)
41
- | ({ type: 'color' } & FieldColorProps & FieldExtraProps)
42
- | ({ type: 'date' } & FieldDateProps & FieldExtraProps)
43
- )
44
- type IsFieldCachedProps = {
45
- name: string
46
- state?: FieldProps['state']
47
- deps?: FieldProps['deps']
48
- errorTitle?: FieldProps['errorTitle']
49
- }
50
-
51
- export const Field = memo(FieldBase, (prev, next) => {
52
- return isFieldCached(prev, next)
53
- })
54
-
55
- function FieldBase({ state, icon, iconPos: ip, errorTitle, ...props }: FieldProps) {
56
- // `type` must be kept as props.type for TS to be happy and follow the conditions below
57
- let value!: string
58
- let Icon!: React.ReactNode
59
- const error = getErrorFromState(state, errorTitle || props.name)
60
- const type = props.type
61
- const iconPos = ip == 'left' || (type == 'color' && !ip) ? 'left' : 'right'
62
- const id = props.id || props.name
63
-
64
- if (!props.name) {
65
- throw new Error('Field component requires a `name` prop')
66
- }
67
-
68
- // Input type
69
- const [inputType, setInputType] = useState(() => { // eslint-disable-line
70
- return type == 'password' ? 'password' : (type == 'textarea' ? 'textarea' : 'text')
71
- })
72
-
73
- // Value: Input is always controlled if state is passed in
74
- if (typeof props.value !== 'undefined') value = props.value as string
75
- else if (typeof state == 'object') {
76
- const v = deepFind(state, props.name) as string | undefined
77
- value = v ?? ''
78
- }
79
-
80
- // Icon
81
- if (type == 'password') {
82
- Icon = <IconWrapper
83
- iconPos={iconPos}
84
- icon={icon || inputType == 'password' ? <EyeOffIcon /> : <EyeIcon />}
85
- onClick={() => setInputType(o => o == 'password' ? 'text' : 'password')}
86
- className="size-[15px] size-input-icon pointer-events-auto"
87
- />
88
- } else if (type == 'email') {
89
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <MailIcon />} className="size-[14px] size-input-icon" />
90
- } else if (type == 'filter') {
91
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <FunnelIcon />} className="size-[14px] size-input-icon" />
92
- } else if (type == 'search') {
93
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <SearchIcon />} className="size-[14px] size-input-icon" />
94
- } else if (type == 'color') {
95
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <ColorSvg hex={value}/>} className="size-[17px]" />
96
- } else if (type == 'date') {
97
- Icon = <IconWrapper iconPos={iconPos} icon={icon || <CalendarIcon />} className="size-[14px] size-input-icon" />
98
- } else if (icon) {
99
- Icon = <IconWrapper iconPos={iconPos} icon={icon} className="size-[14px] size-input-icon" />
100
- }
101
-
102
- // Classname
103
- const inputClassName = getInputClasses({ error, Icon, iconPos, type })
104
- const commonProps = { id: id, value: value, className: inputClassName }
105
-
106
- // Type has to be referenced as props.type for TS to be happy
107
- if (!type || type == 'text' || type == 'password' || type == 'email' || type == 'filter' || type == 'search') {
108
- return (
109
- <FieldContainer error={error} className={props.className}>
110
- {Icon}<input {...props} {...commonProps} type={inputType} />
111
- </FieldContainer>
112
- )
113
- } else if (type == 'textarea') {
114
- return (
115
- <FieldContainer error={error} className={props.className}>
116
- {Icon}<textarea {...props} {...commonProps} />
117
- </FieldContainer>
118
- )
119
- } else if (type == 'currency') {
120
- return (
121
- <FieldContainer error={error} className={props.className}>
122
- {Icon}<FieldCurrency {...props} {...commonProps} />
123
- </FieldContainer>
124
- )
125
- } else if (type == 'color') {
126
- return (
127
- <FieldContainer error={error} className={props.className}>
128
- <FieldColor {...props} {...commonProps} Icon={Icon} />
129
- </FieldContainer>
130
- )
131
- } else if (type == 'date') {
132
- return (
133
- <FieldContainer error={error} className={props.className}>
134
- <FieldDate {...props} {...commonProps} Icon={Icon} />
135
- </FieldContainer>
136
- )
137
- }
138
- }
139
-
140
- function FieldContainer({ children, className, error }: { children: React.ReactNode, className?: string, error?: Error }) {
141
- return (
142
- <div css={style} className={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after grid grid-cols-1 nitro-field ${className || ''}`)}>
143
- {children}
144
- {error && <div class="mt-1.5 text-xs text-danger-foreground nitro-error">{error.detail}</div>}
145
- </div>
146
- )
147
- }
148
-
149
- function getInputClasses({ error, Icon, iconPos, type }: { error?: Error, Icon?: React.ReactNode, iconPos: string, type?: string }) {
150
- // not twMerge
151
- const px = 'px-[12px]'
152
- const py = 'py-[9px] py-input-y'
153
- return (
154
- 'block col-start-1 row-start-1 w-full rounded-md bg-white text-input-base outline outline-1 -outline-offset-1 ' +
155
- 'placeholder:text-input-placeholder focus:outline focus:outline-2 focus:-outline-offset-2 ' + `${py} ${px} ` +
156
- (iconPos == 'right' && Icon ? 'pr-[32px] pr-input-x-icon pl-input-x ' : '') +
157
- (iconPos == 'left' && Icon ? 'pl-[32px] pl-input-x-icon pr-input-x ' : 'px-input-x ') +
158
- (iconPos == 'left' && Icon && type == 'color' ? 'indent-[5px] ' : '') +
159
- (error
160
- ? 'text-danger-foreground outline-danger focus:outline-danger '
161
- : 'text-input outline-input-border focus:outline-input-border-focus ') +
162
- (iconPos == 'right' ? 'justify-self-start ' : 'justify-self-end ') +
163
- 'nitro-input'
164
- )
165
- }
166
-
167
- function IconWrapper({ icon, iconPos, ...props }: IconWrapperProps) {
168
- return (
169
- !!icon &&
170
- <div
171
- {...props}
172
- className={
173
- 'z-[0] col-start-1 row-start-1 self-center text-[#c6c8ce] text-input-icon select-none [&>svg]:size-full ' +
174
- (iconPos == 'right' ? 'justify-self-end mr-[12px] mr-input-x ' : 'justify-self-start ml-[12px] ml-input-x ') +
175
- props.className || ''
176
- }
177
- >{icon}</div>
178
- )
179
- }
180
-
181
- function ColorSvg({ hex }: { hex?: string }) {
182
- return (
183
- <span class="block size-full rounded-md" style={{ backgroundColor: hex ? hex : '#f1f1f1' }}></span>
184
- )
185
- }
186
-
187
- export function isFieldCached(prev: IsFieldCachedProps, next: IsFieldCachedProps) {
188
- // Check if the field is cached, onChange/onInputChange doesn't affect the cache
189
- const path = prev.name
190
- const prevState = prev.state || {}
191
- const nextState = next.state || {}
192
- const errorTitle = next.errorTitle || path
193
-
194
- // Check if any prop has changed, except `onChange`/`onInputChange`
195
- const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)])
196
- for (const k of allKeys) {
197
- if (k === 'state' || k === 'onChange' || k === 'onInputChange') continue
198
- if (prev[k as keyof typeof prev] !== next[k as keyof typeof next]) {
199
- // console.log(4, 'changed', path, k)
200
- return false
201
- }
202
- }
203
-
204
- // If `deps` have changed, handy for onChange/onInputChange, re-render!
205
- if ((next.deps?.length !== prev.deps?.length) || next.deps?.some((v, i) => v !== prev.deps?.[i])) return false
206
-
207
- // If the state value has changed, re-render!
208
- if (deepFind(prevState, path) !== deepFind(nextState, path)) return false
209
-
210
- // If the state error has changed, re-render!
211
- if (getErrorFromState(prevState, errorTitle) !== getErrorFromState(nextState, errorTitle)) return false
212
-
213
- // All good, use cached version
214
- return true
215
- }
216
-
217
- const style = css`
218
- input {
219
- appearance: textfield;
220
- -moz-appearance: textfield;
221
- }
222
- input::-webkit-outer-spin-button,
223
- input::-webkit-inner-spin-button {
224
- -webkit-appearance: none;
225
- margin: 0;
226
- }
227
- /* tw4 we can use calc to determine the padding-left with css variables...
228
- .inputt {
229
- padding-left: calc(var(--input-x) * 2);
230
- } */
231
- `
@@ -1,27 +0,0 @@
1
- import { Errors } from 'nitro-web/types'
2
-
3
- type FormError = {
4
- state: { errors?: Errors },
5
- // display all errors except these field titles, e.g. ['name', 'address']
6
- fields?: Array<string>,
7
- className?: string,
8
- }
9
-
10
- export function FormError({ state, fields, className }: FormError) {
11
- // A catch all error element that should be placed next to the submit button
12
- let error: { title: string, detail: string } | undefined
13
- for (const item of state.errors || []) {
14
- if (!item.title || item.title.match(/^(error|invalid)$/i) || (fields && !fields.includes(item.title))) {
15
- error = item
16
- }
17
- }
18
- return (
19
- <>
20
- {error ? (
21
- <div class={`text-danger-foreground mt-1 text-sm nitro-error ${className||''}`}>
22
- {error.detail}
23
- </div>
24
- ) : null}
25
- </>
26
- )
27
- }