nitro-web 0.2.13 → 0.2.15
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.
|
@@ -9,7 +9,8 @@ import { Errors, type Error } from 'nitro-web/types'
|
|
|
9
9
|
import { MailIcon, CalendarIcon, FunnelIcon, SearchIcon, EyeIcon, EyeOffIcon, ClockIcon, PaperclipIcon } from 'lucide-react'
|
|
10
10
|
import { memo, useState } from 'react'
|
|
11
11
|
|
|
12
|
-
type FieldType = 'text' | 'number' | 'password' | 'email' | 'filter' | 'search' | 'textarea'
|
|
12
|
+
type FieldType = 'text' | 'number' | 'password' | 'email' | 'filter' | 'search' | 'textarea'
|
|
13
|
+
| 'currency' | 'percent' | 'date' | 'color' | 'file'
|
|
13
14
|
type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
|
14
15
|
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
|
15
16
|
type FieldExtraProps = {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { css } from 'twin.macro'
|
|
3
|
-
import { memo, useMemo, Fragment } from 'react'
|
|
3
|
+
import { memo, useMemo, useState, Fragment, FocusEvent } from 'react'
|
|
4
4
|
import ReactSelect, {
|
|
5
5
|
components, ControlProps, createFilter, OptionProps, SingleValueProps, ClearIndicatorProps,
|
|
6
6
|
DropdownIndicatorProps, MultiValueRemoveProps, // ClassNamesConfig,
|
|
7
7
|
ValueContainerProps,
|
|
8
8
|
MenuProps,
|
|
9
|
+
InputProps,
|
|
9
10
|
} from 'react-select'
|
|
10
11
|
import { CheckCircleIcon } from '@heroicons/react/20/solid'
|
|
11
12
|
import { ChevronsUpDownIcon, SearchIcon, XIcon } from 'lucide-react'
|
|
@@ -27,6 +28,8 @@ type GetSelectClassName = {
|
|
|
27
28
|
export type SelectOption = {
|
|
28
29
|
value: unknown,
|
|
29
30
|
label: string | React.ReactNode,
|
|
31
|
+
/** Plain-string label shown in the input when selected (needed when `label` is not a string) **/
|
|
32
|
+
labelInput?: string,
|
|
30
33
|
labelSearch?: string,
|
|
31
34
|
noTruncate?: boolean,
|
|
32
35
|
fixed?: boolean,
|
|
@@ -38,6 +41,9 @@ export type SelectOption = {
|
|
|
38
41
|
|
|
39
42
|
export type SelectedOption = SelectOption | null
|
|
40
43
|
|
|
44
|
+
// Options/Control
|
|
45
|
+
// 1. Control value comes from value or state (so if its the option it will be the option.labe;)
|
|
46
|
+
|
|
41
47
|
/** Select (all other props are passed to react-select) **/
|
|
42
48
|
export type SelectProps<IsMulti extends boolean = false> = {
|
|
43
49
|
/** field name or path on state (used to match errors), e.g. 'date', 'company.email' **/
|
|
@@ -59,7 +65,7 @@ export type SelectProps<IsMulti extends boolean = false> = {
|
|
|
59
65
|
/** The state object to get the value and check errors from **/
|
|
60
66
|
state?: { errors?: Errors, [key: string]: any } // was unknown|unknown[]
|
|
61
67
|
/** Select variations **/
|
|
62
|
-
mode?:
|
|
68
|
+
mode?: 'combobox'
|
|
63
69
|
/** Pass dependencies to break memoization, handy for onChange/onInputChange **/
|
|
64
70
|
deps?: unknown[]
|
|
65
71
|
/** title used to find related error messages */
|
|
@@ -68,6 +74,12 @@ export type SelectProps<IsMulti extends boolean = false> = {
|
|
|
68
74
|
classNames?: ClassNames
|
|
69
75
|
/** Show a search icon instead of the dropdown arrow **/
|
|
70
76
|
showSearchIcon?: boolean
|
|
77
|
+
/** Combobox only: min input length before the menu opens (0 = disabled) **/
|
|
78
|
+
minLenForSearch?: number
|
|
79
|
+
/** Combobox only: hide the menu when nothing matches instead of showing "No options" (default true) **/
|
|
80
|
+
hideEmptyMenu?: boolean
|
|
81
|
+
/** Hide the right dropdown icon + its padding (text-field look) **/
|
|
82
|
+
hideDropdownIcon?: boolean
|
|
71
83
|
/** All other props are passed to react-select **/
|
|
72
84
|
[key: string]: unknown
|
|
73
85
|
}
|
|
@@ -77,18 +89,24 @@ export const Select = memo(SelectBase, (prev, next) => {
|
|
|
77
89
|
}) as <IsMulti extends boolean = false>(props: SelectProps<IsMulti>) => React.ReactElement | null
|
|
78
90
|
|
|
79
91
|
function SelectBase<IsMulti extends boolean = false>({
|
|
80
|
-
id, containerId, minMenuWidth, name, prefix='', onChange, options, state, mode
|
|
81
|
-
showSearchIcon, className,
|
|
92
|
+
id, containerId, minMenuWidth, name, prefix='', onChange, options, state, mode, errorTitle, classNames: classNamesProp,
|
|
93
|
+
showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true, hideDropdownIcon,
|
|
82
94
|
...props
|
|
83
95
|
}: SelectProps<IsMulti>) {
|
|
84
96
|
let value: unknown|unknown[]
|
|
85
97
|
const error = getErrorFromState(state, errorTitle || name)
|
|
86
98
|
if (!name) throw new Error('Select component requires a `name` and `options` prop')
|
|
87
99
|
|
|
100
|
+
// Combobox: free text with a suggestions dropdown, the typed text is the value (full-width text-input feel)
|
|
101
|
+
const isCombobox = mode === 'combobox'
|
|
102
|
+
|
|
88
103
|
// Get value from value or state
|
|
89
104
|
if (typeof props.value !== 'undefined') value = props.value
|
|
90
105
|
else if (typeof state == 'object') value = deepFind(state, name)
|
|
91
106
|
|
|
107
|
+
// Raw (unconverted) value, used as the combobox input text
|
|
108
|
+
const rawValue = value
|
|
109
|
+
|
|
92
110
|
// If multi-select, filter options by value
|
|
93
111
|
if (Array.isArray(value)) value = options.filter(o => (value as unknown[]).includes(o.value))
|
|
94
112
|
else value = options.find(o => value === o.value)
|
|
@@ -97,9 +115,28 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
97
115
|
if (typeof state == 'object' && typeof value == 'undefined') value = ''
|
|
98
116
|
else if (typeof value == 'undefined') value = null // new
|
|
99
117
|
|
|
118
|
+
// Combobox: input text (matched option's plain-string label, else raw typed text) + matches, to gate the menu
|
|
119
|
+
const [focused, setFocused] = useState(false)
|
|
120
|
+
const [pickedFromMenu, setPickedFromMenu] = useState(false) // keeps the menu closed after a selection until typing
|
|
121
|
+
const valueOption = typeof value == 'object' ? value as SelectOption | null : null
|
|
122
|
+
const comboLabel = valueOption?.labelInput ?? (typeof valueOption?.label == 'string' ? valueOption.label : undefined)
|
|
123
|
+
const comboInput = comboLabel ?? String(rawValue ?? '')
|
|
124
|
+
const comboMatches = !isCombobox ? 0 : options.filter((o) => {
|
|
125
|
+
const label = typeof o.label == 'string' ? o.label : (o.labelSearch || o.labelInput || '')
|
|
126
|
+
return filterFn({ label: label, value: String(o.value), data: o }, comboInput)
|
|
127
|
+
}).length
|
|
128
|
+
|
|
100
129
|
// Merge class names (up to 1 level deep)
|
|
101
130
|
const classNames = useMemo(() => {
|
|
102
131
|
const merged = { ...selectClassNames }
|
|
132
|
+
// Combobox: input spans full width + stretches to full control height (cancels the valueContainer's vertical
|
|
133
|
+
// padding) so the whole field is an easy text target, and the text cursor sits on the input, not the control
|
|
134
|
+
if (isCombobox) {
|
|
135
|
+
const m = merged as ClassNames
|
|
136
|
+
m.input = { ...m.input, base: twMerge(m.input?.base,
|
|
137
|
+
'w-full ![grid-template-columns:0_1fr] cursor-text hover:cursor-text '
|
|
138
|
+
+ '(-my-input-y -my-[9px]) ([&>input]:!py-input-y [&>input]:!py-[9px])') }
|
|
139
|
+
}
|
|
103
140
|
for (const key in classNamesProp) {
|
|
104
141
|
const value = classNamesProp[key as keyof ClassNames]
|
|
105
142
|
if (typeof value == 'object') {
|
|
@@ -116,10 +153,12 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
116
153
|
}
|
|
117
154
|
}
|
|
118
155
|
return merged
|
|
119
|
-
}, [classNamesProp])
|
|
156
|
+
}, [classNamesProp, isCombobox])
|
|
120
157
|
|
|
121
158
|
return (
|
|
122
|
-
<div css={style} class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
|
|
159
|
+
<div css={style} class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
|
|
160
|
+
// Combobox: clicking the (already focused) control reopens the menu after a selection
|
|
161
|
+
onMouseDown={isCombobox ? () => setPickedFromMenu(false) : undefined}>
|
|
123
162
|
<ReactSelect
|
|
124
163
|
/**
|
|
125
164
|
* react-select prop quick reference (https://react-select.com/props#api):
|
|
@@ -133,19 +172,21 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
133
172
|
* menuIsOpen={false}
|
|
134
173
|
*/
|
|
135
174
|
{...props}
|
|
136
|
-
_nitro={{ prefix, mode, showSearchIcon }}
|
|
137
|
-
key={value as string}
|
|
175
|
+
_nitro={{ prefix: prefix, mode: mode, showSearchIcon: showSearchIcon ?? isCombobox }}
|
|
176
|
+
key={isCombobox ? name : value as string}
|
|
138
177
|
unstyled={true}
|
|
139
178
|
inputId={id || name}
|
|
140
179
|
id={containerId}
|
|
141
180
|
filterOption={(option, searchText) => {
|
|
142
181
|
if ((option.data as {fixed?: boolean}).fixed) return true
|
|
143
|
-
const
|
|
182
|
+
const o = option.data as SelectOption
|
|
183
|
+
const labelSearch = o.labelSearch || o.labelInput
|
|
144
184
|
return filterFn(labelSearch ? { ...option, label: labelSearch } : option, searchText)
|
|
145
185
|
}}
|
|
146
186
|
menuPlacement="auto"
|
|
147
187
|
minMenuHeight={250}
|
|
148
188
|
onChange={!onChange ? undefined : (o) => {
|
|
189
|
+
if (isCombobox) setPickedFromMenu(true) // close the menu after picking an option
|
|
149
190
|
// An array is returned for multi-select
|
|
150
191
|
type OptionType = IsMulti extends true ? SelectOption[] : SelectedOption
|
|
151
192
|
let value: unknown | unknown[] = []
|
|
@@ -169,7 +210,7 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
169
210
|
)
|
|
170
211
|
}}
|
|
171
212
|
options={options}
|
|
172
|
-
value={value}
|
|
213
|
+
value={isCombobox ? (typeof value == 'object' ? value : null) : value}
|
|
173
214
|
// @ts-expect-error
|
|
174
215
|
classNames={useMemo(() => ({
|
|
175
216
|
// Input container
|
|
@@ -205,6 +246,8 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
205
246
|
MultiValueRemove,
|
|
206
247
|
ValueContainer,
|
|
207
248
|
Menu,
|
|
249
|
+
...(isCombobox ? { Input: ComboboxInput } : {}),
|
|
250
|
+
...(hideDropdownIcon ? { IndicatorsContainer: () => null } : {}),
|
|
208
251
|
...props.components as object,
|
|
209
252
|
}}
|
|
210
253
|
// menuIsOpen={!search ? false : undefined}
|
|
@@ -231,6 +274,29 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
231
274
|
// isMulti={true}
|
|
232
275
|
// isDisabled={true}
|
|
233
276
|
// maxMenuHeight={200}
|
|
277
|
+
// Combobox: bind the input text to the value (typed text is the value), options are just suggestions
|
|
278
|
+
{...(isCombobox ? {
|
|
279
|
+
inputValue: comboInput,
|
|
280
|
+
controlShouldRenderValue: false,
|
|
281
|
+
// Gate the menu: needs focus, min input length, and (optionally) a match to avoid the empty "No options" menu.
|
|
282
|
+
// A menuIsOpen prop hard-overrides this.
|
|
283
|
+
menuIsOpen: 'menuIsOpen' in props
|
|
284
|
+
? props.menuIsOpen as boolean | undefined
|
|
285
|
+
: focused && !pickedFromMenu && comboInput.length >= minLenForSearch && (!hideEmptyMenu || comboMatches >= 1),
|
|
286
|
+
onFocus: (e: FocusEvent<HTMLInputElement>) => {
|
|
287
|
+
setFocused(true); setPickedFromMenu(false);
|
|
288
|
+
(props.onFocus as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
|
|
289
|
+
},
|
|
290
|
+
onBlur: (e: FocusEvent<HTMLInputElement>) => {
|
|
291
|
+
setFocused(false);
|
|
292
|
+
(props.onBlur as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
|
|
293
|
+
},
|
|
294
|
+
onInputChange: (v: string, meta: { action: string }) => {
|
|
295
|
+
if (meta.action !== 'input-change') return
|
|
296
|
+
setPickedFromMenu(false) // typing re-opens the menu
|
|
297
|
+
onChange?.({ target: { name: name, value: v } }, null as never)
|
|
298
|
+
},
|
|
299
|
+
} : {})}
|
|
234
300
|
/>
|
|
235
301
|
{error && <div class="mt-1.5 text-xs text-danger-foreground">{error.detail}</div>}
|
|
236
302
|
</div>
|
|
@@ -241,9 +307,14 @@ function Menu(props: MenuProps) {
|
|
|
241
307
|
return props.options.length === 0 ? null : <components.Menu {...props} />
|
|
242
308
|
}
|
|
243
309
|
|
|
310
|
+
// Combobox: keep the input visible after selecting (react-select hides it to show a value chip we don't render)
|
|
311
|
+
function ComboboxInput(props: InputProps) {
|
|
312
|
+
return <components.Input {...props} isHidden={false} />
|
|
313
|
+
}
|
|
314
|
+
|
|
244
315
|
function Control({ children, ...props }: ControlProps) {
|
|
245
316
|
// const selectedOption = props.getValue()[0]
|
|
246
|
-
const _nitro = (props.selectProps as { _nitro?: { prefix?: string, mode?:
|
|
317
|
+
const _nitro = (props.selectProps as { _nitro?: { prefix?: string, mode?: 'combobox' } })?._nitro
|
|
247
318
|
return (
|
|
248
319
|
<components.Control {...props}>
|
|
249
320
|
{_nitro?.prefix
|
|
@@ -263,15 +334,15 @@ function ValueContainer({ children, ...props}: ValueContainerProps) {
|
|
|
263
334
|
}
|
|
264
335
|
|
|
265
336
|
function SingleValue({ children, ...props }: SingleValueProps) {
|
|
266
|
-
const selectedOption = props.getValue()[0] as
|
|
337
|
+
const selectedOption = props.getValue()[0] as SelectOption
|
|
267
338
|
// @ts-expect-error
|
|
268
339
|
const flagClassName = props.getClassNames('flag')
|
|
269
340
|
|
|
270
341
|
return (
|
|
271
342
|
<components.SingleValue {...props}>
|
|
272
343
|
{
|
|
273
|
-
selectedOption?.
|
|
274
|
-
? <Fragment>{selectedOption.
|
|
344
|
+
selectedOption?.labelInput
|
|
345
|
+
? <Fragment>{selectedOption.labelInput}</Fragment>
|
|
275
346
|
: <Fragment>
|
|
276
347
|
{selectedOption?.flag && <span className={flagClassName}>{selectedOption.flag}</span>}
|
|
277
348
|
{selectedOption?.IconLeft}
|
|
@@ -287,7 +358,7 @@ function SingleValue({ children, ...props }: SingleValueProps) {
|
|
|
287
358
|
|
|
288
359
|
function Option(props: OptionProps) {
|
|
289
360
|
const data = props.data as SelectOption
|
|
290
|
-
// const _nitro = (props.selectProps as { _nitro?: { mode?:
|
|
361
|
+
// const _nitro = (props.selectProps as { _nitro?: { mode?: 'combobox' } })?._nitro
|
|
291
362
|
// @ts-expect-error
|
|
292
363
|
const flagClassName = props.getClassNames('flag')
|
|
293
364
|
return (
|
|
@@ -80,6 +80,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
80
80
|
calendarSingle: Date.now(),
|
|
81
81
|
calendarRange: [Date.now(), Date.now() + 1000 * 60 * 60 * 24 * 8],
|
|
82
82
|
firstName: 'Bruce',
|
|
83
|
+
combobox: '11',
|
|
83
84
|
tableFilter: '',
|
|
84
85
|
errors: [
|
|
85
86
|
{ title: 'address', detail: 'Address is required' },
|
|
@@ -102,6 +103,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
102
103
|
calendarRange: [Date.now(), Date.now() + 1000 * 60 * 60 * 24 * 3.2],
|
|
103
104
|
firstName: 'John',
|
|
104
105
|
tableFilter: '',
|
|
106
|
+
combobox: '22',
|
|
105
107
|
errors: [
|
|
106
108
|
{ title: 'address', detail: 'Address is required' },
|
|
107
109
|
],
|
|
@@ -472,8 +474,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
472
474
|
<Select
|
|
473
475
|
// menuIsOpen={true}
|
|
474
476
|
placeholder="Select or add customer..."
|
|
475
|
-
name="customer"
|
|
476
|
-
mode="customer"
|
|
477
|
+
name="customer"
|
|
477
478
|
state={state}
|
|
478
479
|
onChange={onCustomerInputChange}
|
|
479
480
|
onInputChange={onCustomerSearch}
|
|
@@ -535,6 +536,43 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
535
536
|
], [])}
|
|
536
537
|
/>
|
|
537
538
|
</div>
|
|
539
|
+
<div>
|
|
540
|
+
<label for="combobox">JSX label + custom search</label>
|
|
541
|
+
<Select
|
|
542
|
+
name="jsxlabel"
|
|
543
|
+
state={state}
|
|
544
|
+
onChange={(e) => onChange(e, setState)}
|
|
545
|
+
menuIsOpen={true}
|
|
546
|
+
options={useMemo(() => [
|
|
547
|
+
{ value: '11', label: <div class="inline-block bg-blue-300">Blue</div>, labelSearch: 'BL - blue', noTruncate: true },
|
|
548
|
+
{ value: '22', label: <div class="inline-block bg-green-300">Green</div>, labelSearch: 'GR - green', noTruncate: true },
|
|
549
|
+
{ value: '33', label: <div class="inline-block bg-yellow-300">Yellow</div>, labelSearch: 'YL - yellow', noTruncate: true },
|
|
550
|
+
{ value: '44', label: <div class="inline-block bg-red-300">Red</div>, labelSearch: 'RD - red', noTruncate: true },
|
|
551
|
+
{ value: '55', label: <div class="inline-block bg-orange-300">Orange</div>, labelSearch: 'OR - orange', noTruncate: true },
|
|
552
|
+
{ value: '66', label: <div class="inline-block bg-purple-300">Purple</div>, labelSearch: 'PU - purple', noTruncate: true },
|
|
553
|
+
], [])}
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
<div>
|
|
557
|
+
<label for="combobox">Combobox (value={state.combobox})</label>
|
|
558
|
+
<Select
|
|
559
|
+
name="combobox"
|
|
560
|
+
mode="combobox"
|
|
561
|
+
minLenForSearch={1}
|
|
562
|
+
state={state}
|
|
563
|
+
onChange={(e) => onChange(e, setState)}
|
|
564
|
+
placeholder="Start typing to search for a color..."
|
|
565
|
+
// hideDropdownIcon={true}
|
|
566
|
+
options={useMemo(() => [
|
|
567
|
+
{ value: '11', label: <div class="inline-block bg-blue-300">Blue</div>, labelInput: 'BL - blue' },
|
|
568
|
+
{ value: '22', label: <div class="inline-block bg-green-300">Green</div>, labelInput: 'GR - green' },
|
|
569
|
+
{ value: '33', label: <div class="inline-block bg-yellow-300">Yellow</div>, labelInput: 'YL - yellow' },
|
|
570
|
+
{ value: '44', label: <div class="inline-block bg-red-300">Red</div>, labelInput: 'RD - red' },
|
|
571
|
+
{ value: '55', label: <div class="inline-block bg-orange-300">Orange</div>, labelInput: 'OR - orange' },
|
|
572
|
+
{ value: '66', label: <div class="inline-block bg-purple-300">Purple</div>, labelInput: 'PU - purple' },
|
|
573
|
+
], [])}
|
|
574
|
+
/>
|
|
575
|
+
</div>
|
|
538
576
|
</div>
|
|
539
577
|
</div>
|
|
540
578
|
)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitro-web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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 🚀",
|