nitro-web 0.2.21 → 0.2.22
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.
|
@@ -7,13 +7,16 @@ type ModalProps = {
|
|
|
7
7
|
children: React.ReactNode
|
|
8
8
|
className?: string
|
|
9
9
|
rootClassName?: string
|
|
10
|
+
xClassName?: string
|
|
10
11
|
dismissable?: boolean
|
|
11
12
|
maxWidth?: string
|
|
12
13
|
minHeight?: string
|
|
13
14
|
[key: string]: unknown
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function Modal({
|
|
17
|
+
export function Modal({
|
|
18
|
+
show, setShow, children, maxWidth, minHeight, dismissable = true, className, rootClassName, xClassName,
|
|
19
|
+
}: ModalProps) {
|
|
17
20
|
const [state, setState] = useState(show ? 'open' : 'close')
|
|
18
21
|
const containerEl = useRef<HTMLDivElement>(null)
|
|
19
22
|
const isFirst = IsFirstRender()
|
|
@@ -76,7 +79,7 @@ export function Modal({ show, setShow, children, maxWidth, minHeight, dismissabl
|
|
|
76
79
|
class={`${twMerge('relative w-full mx-6 mt-4 mb-8 bg-white rounded-lg shadow-lg p-9', className)}`}
|
|
77
80
|
>
|
|
78
81
|
<div
|
|
79
|
-
class=
|
|
82
|
+
class={twMerge(`absolute top-0 right-0 p-3 m-1 cursor-pointer ${xClassName||''}`)}
|
|
80
83
|
onClick={() => { if (dismissable) { setShow(false) }}}
|
|
81
84
|
>
|
|
82
85
|
<SvgX1 />
|
|
@@ -230,10 +230,10 @@ export function isFieldCached(prev: IsFieldCachedProps, next: IsFieldCachedProps
|
|
|
230
230
|
const nextState = next.state || {}
|
|
231
231
|
const errorTitle = next.errorTitle || path
|
|
232
232
|
|
|
233
|
-
// Check if any prop has changed, except `onChange`/`onInputChange`
|
|
233
|
+
// Check if any prop has changed, except `onChange`/`onInputChange`/`onSearch`
|
|
234
234
|
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)])
|
|
235
235
|
for (const k of allKeys) {
|
|
236
|
-
if (k === 'state' || k === 'onChange' || k === 'onInputChange') continue
|
|
236
|
+
if (k === 'state' || k === 'onChange' || k === 'onInputChange' || k === 'onSearch') continue
|
|
237
237
|
if (prev[k as keyof typeof prev] !== next[k as keyof typeof next]) {
|
|
238
238
|
// console.log(4, 'changed', path, k)
|
|
239
239
|
return false
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { css } from 'twin.macro'
|
|
3
|
-
import { memo, useMemo, useState, useRef, useLayoutEffect, Fragment, FocusEvent, ReactNode } from 'react'
|
|
3
|
+
import { memo, useMemo, useState, useRef, useLayoutEffect, startTransition, Fragment, FocusEvent, ReactNode } from 'react'
|
|
4
4
|
import ReactSelect, {
|
|
5
5
|
components, ControlProps, createFilter, OptionProps, SingleValueProps, ClearIndicatorProps,
|
|
6
6
|
DropdownIndicatorProps, MultiValueRemoveProps, MultiValueGenericProps, // ClassNamesConfig,
|
|
7
7
|
ValueContainerProps,
|
|
8
8
|
MenuProps,
|
|
9
9
|
InputProps,
|
|
10
|
+
FilterOptionOption,
|
|
10
11
|
} from 'react-select'
|
|
11
12
|
import { CheckCircleIcon } from '@heroicons/react/20/solid'
|
|
12
13
|
import { ChevronsUpDownIcon, SearchIcon, XIcon } from 'lucide-react'
|
|
@@ -25,6 +26,7 @@ type GetSelectClassName = {
|
|
|
25
26
|
usePrefixes?: boolean
|
|
26
27
|
classNames?: ClassNames
|
|
27
28
|
}
|
|
29
|
+
|
|
28
30
|
export type SelectOption = {
|
|
29
31
|
value: unknown,
|
|
30
32
|
label: string | React.ReactNode,
|
|
@@ -64,8 +66,15 @@ export type SelectProps<IsMulti extends boolean = false> = {
|
|
|
64
66
|
options: SelectOption[]
|
|
65
67
|
/** The state object to get the value and check errors from **/
|
|
66
68
|
state?: { errors?: Errors, [key: string]: any } // was unknown|unknown[]
|
|
67
|
-
/** Select variations
|
|
68
|
-
|
|
69
|
+
/** Select variations, combobox is a free text input with a suggestions dropdown, search is a combobox that
|
|
70
|
+
* selects an option (the parent filters/fetches the options via `onSearch`) **/
|
|
71
|
+
mode?: 'combobox' | 'search'
|
|
72
|
+
/** Search only: the typed text, for the parent to filter/fetch `options` with **/
|
|
73
|
+
onSearch?: (text: string) => void
|
|
74
|
+
/** Search only: option used to render the value when its missing from `options` (e.g. a value loaded from the api) **/
|
|
75
|
+
selectedOption?: SelectedOption
|
|
76
|
+
/** Search only: always drop the typed text on blur (by default its kept until something is selected) **/
|
|
77
|
+
clearSearch?: boolean
|
|
69
78
|
/** Pass dependencies to break memoization, handy for onChange/onInputChange **/
|
|
70
79
|
deps?: unknown[]
|
|
71
80
|
/** title used to find related error messages */
|
|
@@ -91,52 +100,64 @@ export const Select = memo(SelectBase, (prev, next) => {
|
|
|
91
100
|
}) as <IsMulti extends boolean = false>(props: SelectProps<IsMulti>) => React.ReactElement | null
|
|
92
101
|
|
|
93
102
|
function SelectBase<IsMulti extends boolean = false>({
|
|
94
|
-
id, containerId, minMenuWidth, name, prefix='', onChange, options,
|
|
95
|
-
showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true,
|
|
103
|
+
id, containerId, minMenuWidth, name, prefix='', onChange, onSearch, options, selectedOption, clearSearch, state, mode,
|
|
104
|
+
errorTitle, classNames: classNamesProp, showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true,
|
|
105
|
+
hideDropdownIcon, maxLines,
|
|
96
106
|
...props
|
|
97
107
|
}: SelectProps<IsMulti>) {
|
|
98
108
|
let value: unknown|unknown[]
|
|
109
|
+
const isCombobox = mode === 'combobox'
|
|
110
|
+
const isSearch = mode === 'search'
|
|
111
|
+
const isTextInput = isCombobox || isSearch // both render the value inside the text input
|
|
112
|
+
const [typed, setTyped] = useState<string | null>(null)
|
|
113
|
+
const [focused, setFocused] = useState(false)
|
|
114
|
+
const [pickedFromMenu, setPickedFromMenu] = useState(false) // keeps the menu closed after a selection until typing
|
|
115
|
+
const picked = useRef<SelectedOption>(null) // last picked option, filtered options wont contain it
|
|
99
116
|
const error = getErrorFromState(state, errorTitle || name)
|
|
100
117
|
if (!name) throw new Error('Select component requires a `name` and `options` prop')
|
|
101
118
|
|
|
102
|
-
// Combobox: free text with a suggestions dropdown, the typed text is the value (full-width text-input feel)
|
|
103
|
-
const isCombobox = mode === 'combobox'
|
|
104
|
-
|
|
105
119
|
// Multi-selects collapse tags to 2 lines by default, pass 0 (or null) to turn it off
|
|
106
120
|
maxLines = maxLines === undefined ? (props.isMulti ? 2 : undefined) : maxLines
|
|
107
121
|
|
|
108
122
|
// Get value from value or state
|
|
109
123
|
if (typeof props.value !== 'undefined') value = props.value
|
|
110
124
|
else if (typeof state == 'object') value = deepFind(state, name)
|
|
125
|
+
const rawValue = value // Raw (unconverted) value, used as the combobox input text
|
|
111
126
|
|
|
112
|
-
//
|
|
113
|
-
const
|
|
127
|
+
// Search: the value may sit outside the filtered options, so also look at the last pick/selectedOption
|
|
128
|
+
const lookup = isSearch ? [...options, picked.current, selectedOption].filter(Boolean) as SelectOption[] : options
|
|
114
129
|
|
|
115
130
|
// If multi-select, filter options by value
|
|
116
|
-
if (Array.isArray(value)) value =
|
|
117
|
-
else value =
|
|
131
|
+
if (Array.isArray(value)) value = lookup.filter(o => (value as unknown[]).includes(o.value))
|
|
132
|
+
else value = lookup.find(o => value === o.value)
|
|
118
133
|
|
|
119
134
|
// Input is always controlled if state is passed in
|
|
120
135
|
if (typeof state == 'object' && typeof value == 'undefined') value = ''
|
|
121
136
|
else if (typeof value == 'undefined') value = null // new
|
|
122
137
|
|
|
123
|
-
// Combobox: input text (matched option's plain-string label, else raw typed text) + matches, to gate the menu
|
|
124
|
-
const [focused, setFocused] = useState(false)
|
|
125
|
-
const [pickedFromMenu, setPickedFromMenu] = useState(false) // keeps the menu closed after a selection until typing
|
|
126
138
|
const valueOption = typeof value == 'object' ? value as SelectOption | null : null
|
|
127
139
|
const comboLabel = valueOption?.labelInput ?? (typeof valueOption?.label == 'string' ? valueOption.label : undefined)
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
// Search shows the value as a normal chip, so the input only ever holds the typed text
|
|
141
|
+
const comboInput = typed ?? (isSearch ? '' : comboLabel ?? String(rawValue ?? ''))
|
|
142
|
+
|
|
143
|
+
// Search: with nothing typed the parent has no options, so fall back to the selection to open the menu on
|
|
144
|
+
const showSelected = isSearch && !comboInput && !!valueOption && !options.length
|
|
145
|
+
const menuOptions = showSelected ? [valueOption] : options
|
|
146
|
+
|
|
147
|
+
// Open on focus, past minLenForSearch, and (unless hideEmptyMenu is off) only when something matches
|
|
148
|
+
const comboMenuOpen = isTextInput && focused && !pickedFromMenu
|
|
149
|
+
&& (showSelected || comboInput.length >= minLenForSearch)
|
|
150
|
+
&& (isSearch ? menuOptions.length > 0 : !hideEmptyMenu || options.some((o) => {
|
|
151
|
+
const label = typeof o.label == 'string' ? o.label : (o.labelSearch || o.labelInput || '')
|
|
152
|
+
return filterFn({ label: label, value: String(o.value), data: o }, comboInput)
|
|
153
|
+
}))
|
|
133
154
|
|
|
134
155
|
// Merge class names (up to 1 level deep)
|
|
135
156
|
const classNames = useMemo(() => {
|
|
136
157
|
const merged = { ...selectClassNames }
|
|
137
|
-
// Combobox: input spans full width + stretches to full control height (cancels the valueContainer's vertical
|
|
158
|
+
// Combobox/search: input spans full width + stretches to full control height (cancels the valueContainer's vertical
|
|
138
159
|
// padding) so the whole field is an easy text target, and the text cursor sits on the input, not the control
|
|
139
|
-
if (
|
|
160
|
+
if (isTextInput) {
|
|
140
161
|
const m = merged as ClassNames
|
|
141
162
|
m.input = { ...m.input, base: twMerge(m.input?.base,
|
|
142
163
|
'w-full ![grid-template-columns:0_1fr] cursor-text hover:cursor-text '
|
|
@@ -158,14 +179,14 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
158
179
|
}
|
|
159
180
|
}
|
|
160
181
|
return merged
|
|
161
|
-
}, [classNamesProp,
|
|
182
|
+
}, [classNamesProp, isTextInput])
|
|
162
183
|
|
|
163
184
|
return (
|
|
164
185
|
<div
|
|
165
186
|
css={style}
|
|
166
187
|
class={'mt-2.5 mb-6 min-w-0 contain-inline-size ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
|
|
167
|
-
// Combobox: clicking the (already focused) control reopens the menu after a selection
|
|
168
|
-
onMouseDown={
|
|
188
|
+
// Combobox/search: clicking the (already focused) control reopens the menu after a selection
|
|
189
|
+
onMouseDown={isTextInput ? () => setPickedFromMenu(false) : undefined}>
|
|
169
190
|
<ReactSelect
|
|
170
191
|
/**
|
|
171
192
|
* react-select prop quick reference (https://react-select.com/props#api):
|
|
@@ -179,21 +200,19 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
179
200
|
* menuIsOpen={false}
|
|
180
201
|
*/
|
|
181
202
|
{...props}
|
|
182
|
-
_nitro={{ prefix: prefix, mode: mode, showSearchIcon: showSearchIcon ??
|
|
183
|
-
key={
|
|
203
|
+
_nitro={{ prefix: prefix, mode: mode, showSearchIcon: showSearchIcon ?? isTextInput, maxLines: maxLines }}
|
|
204
|
+
key={isTextInput ? name : value as string}
|
|
184
205
|
unstyled={true}
|
|
185
206
|
inputId={id || name}
|
|
186
207
|
id={containerId}
|
|
187
|
-
filterOption={
|
|
188
|
-
if ((option.data as {fixed?: boolean}).fixed) return true
|
|
189
|
-
const o = option.data as SelectOption
|
|
190
|
-
const labelSearch = o.labelSearch || o.labelInput
|
|
191
|
-
return filterFn(labelSearch ? { ...option, label: labelSearch } : option, searchText)
|
|
192
|
-
}}
|
|
208
|
+
filterOption={filterOption}
|
|
193
209
|
menuPlacement="auto"
|
|
194
210
|
minMenuHeight={250}
|
|
195
211
|
onChange={!onChange ? undefined : (o) => {
|
|
196
|
-
if (
|
|
212
|
+
if (isTextInput) {
|
|
213
|
+
setPickedFromMenu(true) // close the menu after picking an option
|
|
214
|
+
setTyped(null) // show the picked option's label, not the text that was typed to find it
|
|
215
|
+
}
|
|
197
216
|
// An array is returned for multi-select
|
|
198
217
|
type OptionType = IsMulti extends true ? SelectOption[] : SelectedOption
|
|
199
218
|
let value: unknown | unknown[] = []
|
|
@@ -211,15 +230,21 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
211
230
|
optionCopy = (isObject ? { ...o } : o) as OptionType
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
// Search: remember the pick (the filtered options wont contain it) and reset the parent's search
|
|
234
|
+
if (isSearch) {
|
|
235
|
+
picked.current = Array.isArray(optionCopy) ? null : optionCopy as SelectedOption
|
|
236
|
+
onSearch?.('')
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
return onChange(
|
|
215
|
-
{ target: { name: name, value: value }},
|
|
240
|
+
{ target: { name: name, value: value }},
|
|
216
241
|
optionCopy
|
|
217
242
|
)
|
|
218
243
|
}}
|
|
219
|
-
options={
|
|
244
|
+
options={menuOptions}
|
|
220
245
|
// maxLines hides overflow chips, so keep selected options in the menu to make them easy to unselect
|
|
221
246
|
{...(maxLines && !('hideSelectedOptions' in props) ? { hideSelectedOptions: false } : {})}
|
|
222
|
-
value={
|
|
247
|
+
value={isTextInput ? (typeof value == 'object' ? value : null) : value}
|
|
223
248
|
// @ts-expect-error
|
|
224
249
|
classNames={useMemo(() => ({
|
|
225
250
|
// Input container
|
|
@@ -256,14 +281,15 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
256
281
|
MultiValueRemove,
|
|
257
282
|
ValueContainer,
|
|
258
283
|
Menu,
|
|
259
|
-
...(
|
|
284
|
+
...(isTextInput ? { Input: ComboboxInput } : {}),
|
|
260
285
|
...(hideDropdownIcon ? { IndicatorsContainer: () => null } : {}),
|
|
261
286
|
...props.components as object,
|
|
262
287
|
}}
|
|
263
288
|
// menuIsOpen={!search ? false : undefined}
|
|
264
|
-
styles={{
|
|
265
|
-
menu: (base) => ({
|
|
266
|
-
...base,
|
|
289
|
+
styles={useMemo(() => ({
|
|
290
|
+
menu: (base) => ({
|
|
291
|
+
...base,
|
|
292
|
+
minWidth: minMenuWidth,
|
|
267
293
|
}),
|
|
268
294
|
// On mobile, the label will truncate automatically, so we want to
|
|
269
295
|
// override that behaviour.
|
|
@@ -281,7 +307,7 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
281
307
|
...base,
|
|
282
308
|
visibility: 'inherit', // RS hardcodes to visblr, inherit visibility from ancestor
|
|
283
309
|
}),
|
|
284
|
-
}}
|
|
310
|
+
}), [minMenuWidth])}
|
|
285
311
|
// menuIsOpen={true}
|
|
286
312
|
// isSearchable={false}
|
|
287
313
|
// isClearable={true}
|
|
@@ -289,26 +315,31 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
289
315
|
// isDisabled={true}
|
|
290
316
|
// maxMenuHeight={200}
|
|
291
317
|
// Combobox: bind the input text to the value (typed text is the value), options are just suggestions
|
|
292
|
-
|
|
318
|
+
// Search: the value renders as usual (react-select hides it while theres input text), typing filters via onSearch
|
|
319
|
+
{...(isTextInput ? {
|
|
293
320
|
inputValue: comboInput,
|
|
294
|
-
controlShouldRenderValue:
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
? props.menuIsOpen as boolean | undefined
|
|
299
|
-
: focused && !pickedFromMenu && comboInput.length >= minLenForSearch && (!hideEmptyMenu || comboMatches >= 1),
|
|
321
|
+
controlShouldRenderValue: isSearch,
|
|
322
|
+
// A menuIsOpen prop hard-overrides the gate above
|
|
323
|
+
menuIsOpen: 'menuIsOpen' in props ? props.menuIsOpen as boolean | undefined : comboMenuOpen,
|
|
324
|
+
...(isSearch ? { filterOption: null } : {}), // the parent has already filtered
|
|
300
325
|
onFocus: (e: FocusEvent<HTMLInputElement>) => {
|
|
301
326
|
setFocused(true); setPickedFromMenu(false);
|
|
302
327
|
(props.onFocus as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
|
|
303
328
|
},
|
|
304
329
|
onBlur: (e: FocusEvent<HTMLInputElement>) => {
|
|
305
|
-
setFocused(false)
|
|
306
|
-
|
|
330
|
+
setFocused(false)
|
|
331
|
+
// Combobox state is authoritative once focus leaves, search keeps the typed text for the next focus
|
|
332
|
+
if (isCombobox || clearSearch) { setTyped(null); if (isSearch) onSearch?.('') }
|
|
333
|
+
const onBlur = props.onBlur as ((e: FocusEvent<HTMLInputElement>) => void) | undefined
|
|
334
|
+
onBlur?.(e)
|
|
307
335
|
},
|
|
308
336
|
onInputChange: (v: string, meta: { action: string }) => {
|
|
309
337
|
if (meta.action !== 'input-change') return
|
|
338
|
+
setTyped(v) // urgent, so the character paints without waiting on the parent
|
|
310
339
|
setPickedFromMenu(false) // typing re-opens the menu
|
|
311
|
-
|
|
340
|
+
startTransition(() => isSearch
|
|
341
|
+
? onSearch?.(v)
|
|
342
|
+
: onChange?.({ target: { name: name, value: v } }, null as never))
|
|
312
343
|
},
|
|
313
344
|
} : {})}
|
|
314
345
|
/>
|
|
@@ -498,6 +529,14 @@ const MultiValueRemove = (props: MultiValueRemoveProps) => {
|
|
|
498
529
|
)
|
|
499
530
|
}
|
|
500
531
|
|
|
532
|
+
const filterOption = (option: FilterOptionOption<unknown>, searchText: string) => {
|
|
533
|
+
// Match on labelSearch/labelInput when the label isn't plain text, fixed options always show
|
|
534
|
+
if ((option.data as {fixed?: boolean}).fixed) return true
|
|
535
|
+
const o = option.data as SelectOption
|
|
536
|
+
const labelSearch = o.labelSearch || o.labelInput
|
|
537
|
+
return filterFn(labelSearch ? { ...option, label: labelSearch } : option, searchText)
|
|
538
|
+
}
|
|
539
|
+
|
|
501
540
|
const selectClassNames = {
|
|
502
541
|
// Based off https://www.jussivirtanen.fi/writing/styling-react-select-with-tailwind
|
|
503
542
|
// Input container
|
|
@@ -55,6 +55,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
55
55
|
const Button = elements?.Button || ButtonNitro
|
|
56
56
|
const [, setStore] = useTracked()
|
|
57
57
|
const [customerSearch, setCustomerSearch] = useState('')
|
|
58
|
+
const [countrySearch, setCountrySearch] = useState('')
|
|
58
59
|
const [showModal1, setShowModal1] = useState(false)
|
|
59
60
|
|
|
60
61
|
// Tip: handy when developing or updating components, you can hide/show the groups you want to see
|
|
@@ -69,6 +70,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
69
70
|
colorsMulti: ['blue', 'green', 'yellow', 'red', 'orange', 'purple'],
|
|
70
71
|
colorsMultiFlex: ['blue', 'green', 'yellow', 'red', 'orange', 'purple'],
|
|
71
72
|
country: 'cd',
|
|
73
|
+
countrySearch: 'nz',
|
|
72
74
|
currency: 'nzd',
|
|
73
75
|
percent: 1250,
|
|
74
76
|
customer: '1',
|
|
@@ -558,6 +560,25 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
558
560
|
], [])}
|
|
559
561
|
/>
|
|
560
562
|
</div>
|
|
563
|
+
<div>
|
|
564
|
+
<label for="countrySearch">Search (parent filters, value={state.countrySearch})</label>
|
|
565
|
+
<Select
|
|
566
|
+
name="countrySearch"
|
|
567
|
+
mode="search"
|
|
568
|
+
minLenForSearch={1}
|
|
569
|
+
state={state}
|
|
570
|
+
placeholder="Start typing to search for a country..."
|
|
571
|
+
isClearable={true}
|
|
572
|
+
onChange={(e) => onChange(e, setState)}
|
|
573
|
+
onSearch={setCountrySearch}
|
|
574
|
+
options={useMemo(() => (!countrySearch ? [] : countryOptions
|
|
575
|
+
.filter(o => o.label.toLowerCase().includes(countrySearch.toLowerCase()))
|
|
576
|
+
.slice(0, 5)), [countrySearch])}
|
|
577
|
+
// the filtered options wont contain the stored value, so resolve it here
|
|
578
|
+
selectedOption={useMemo(() => countryOptions.find(o => o.value === state.countrySearch) || null,
|
|
579
|
+
[state.countrySearch])}
|
|
580
|
+
/>
|
|
581
|
+
</div>
|
|
561
582
|
<div>
|
|
562
583
|
<label for="combobox">Combobox (value={state.combobox})</label>
|
|
563
584
|
<Select
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitro-web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"repository": "github:boycce/nitro-web",
|
|
5
5
|
"homepage": "https://boycce.github.io/nitro-web/",
|
|
6
6
|
"description": "Nitro is a battle-tested, modular base project to turbocharge your projects, styled using Tailwind 🚀",
|