nitro-web 0.2.13 → 0.2.14
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'
|
|
@@ -59,7 +60,7 @@ export type SelectProps<IsMulti extends boolean = false> = {
|
|
|
59
60
|
/** The state object to get the value and check errors from **/
|
|
60
61
|
state?: { errors?: Errors, [key: string]: any } // was unknown|unknown[]
|
|
61
62
|
/** Select variations **/
|
|
62
|
-
mode?:
|
|
63
|
+
mode?: 'combobox'
|
|
63
64
|
/** Pass dependencies to break memoization, handy for onChange/onInputChange **/
|
|
64
65
|
deps?: unknown[]
|
|
65
66
|
/** title used to find related error messages */
|
|
@@ -68,6 +69,12 @@ export type SelectProps<IsMulti extends boolean = false> = {
|
|
|
68
69
|
classNames?: ClassNames
|
|
69
70
|
/** Show a search icon instead of the dropdown arrow **/
|
|
70
71
|
showSearchIcon?: boolean
|
|
72
|
+
/** Combobox only: min input length before the menu opens (0 = disabled) **/
|
|
73
|
+
minLenForSearch?: number
|
|
74
|
+
/** Combobox only: hide the menu when nothing matches instead of showing "No options" (default true) **/
|
|
75
|
+
hideEmptyMenu?: boolean
|
|
76
|
+
/** Hide the right dropdown icon + its padding (text-field look) **/
|
|
77
|
+
hideDropdownIcon?: boolean
|
|
71
78
|
/** All other props are passed to react-select **/
|
|
72
79
|
[key: string]: unknown
|
|
73
80
|
}
|
|
@@ -77,18 +84,24 @@ export const Select = memo(SelectBase, (prev, next) => {
|
|
|
77
84
|
}) as <IsMulti extends boolean = false>(props: SelectProps<IsMulti>) => React.ReactElement | null
|
|
78
85
|
|
|
79
86
|
function SelectBase<IsMulti extends boolean = false>({
|
|
80
|
-
id, containerId, minMenuWidth, name, prefix='', onChange, options, state, mode
|
|
81
|
-
showSearchIcon, className,
|
|
87
|
+
id, containerId, minMenuWidth, name, prefix='', onChange, options, state, mode, errorTitle, classNames: classNamesProp,
|
|
88
|
+
showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true, hideDropdownIcon,
|
|
82
89
|
...props
|
|
83
90
|
}: SelectProps<IsMulti>) {
|
|
84
91
|
let value: unknown|unknown[]
|
|
85
92
|
const error = getErrorFromState(state, errorTitle || name)
|
|
86
93
|
if (!name) throw new Error('Select component requires a `name` and `options` prop')
|
|
87
94
|
|
|
95
|
+
// Combobox: free text with a suggestions dropdown, the typed text is the value (full-width text-input feel)
|
|
96
|
+
const isCombobox = mode === 'combobox'
|
|
97
|
+
|
|
88
98
|
// Get value from value or state
|
|
89
99
|
if (typeof props.value !== 'undefined') value = props.value
|
|
90
100
|
else if (typeof state == 'object') value = deepFind(state, name)
|
|
91
101
|
|
|
102
|
+
// Raw (unconverted) value, used as the combobox input text
|
|
103
|
+
const rawValue = value
|
|
104
|
+
|
|
92
105
|
// If multi-select, filter options by value
|
|
93
106
|
if (Array.isArray(value)) value = options.filter(o => (value as unknown[]).includes(o.value))
|
|
94
107
|
else value = options.find(o => value === o.value)
|
|
@@ -97,9 +110,28 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
97
110
|
if (typeof state == 'object' && typeof value == 'undefined') value = ''
|
|
98
111
|
else if (typeof value == 'undefined') value = null // new
|
|
99
112
|
|
|
113
|
+
// Combobox: input text (option label when matched, else raw typed text) + matches, to gate the menu
|
|
114
|
+
const [focused, setFocused] = useState(false)
|
|
115
|
+
const [pickedFromMenu, setPickedFromMenu] = useState(false) // keeps the menu closed after a selection until typing
|
|
116
|
+
const comboInput = typeof value == 'object' && value && typeof (value as SelectOption).label == 'string'
|
|
117
|
+
? (value as SelectOption).label as string
|
|
118
|
+
: String(rawValue ?? '')
|
|
119
|
+
const comboMatches = !isCombobox ? 0 : options.filter((o) => {
|
|
120
|
+
const label = typeof o.label == 'string' ? o.label : (o.labelSearch || '')
|
|
121
|
+
return filterFn({ label: label, value: String(o.value), data: o }, comboInput)
|
|
122
|
+
}).length
|
|
123
|
+
|
|
100
124
|
// Merge class names (up to 1 level deep)
|
|
101
125
|
const classNames = useMemo(() => {
|
|
102
126
|
const merged = { ...selectClassNames }
|
|
127
|
+
// Combobox: input spans full width + stretches to full control height (cancels the valueContainer's vertical
|
|
128
|
+
// padding) so the whole field is an easy text target, and the text cursor sits on the input, not the control
|
|
129
|
+
if (isCombobox) {
|
|
130
|
+
const m = merged as ClassNames
|
|
131
|
+
m.input = { ...m.input, base: twMerge(m.input?.base,
|
|
132
|
+
'w-full ![grid-template-columns:0_1fr] cursor-text hover:cursor-text '
|
|
133
|
+
+ '(-my-input-y -my-[9px]) ([&>input]:!py-input-y [&>input]:!py-[9px])') }
|
|
134
|
+
}
|
|
103
135
|
for (const key in classNamesProp) {
|
|
104
136
|
const value = classNamesProp[key as keyof ClassNames]
|
|
105
137
|
if (typeof value == 'object') {
|
|
@@ -116,10 +148,12 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
116
148
|
}
|
|
117
149
|
}
|
|
118
150
|
return merged
|
|
119
|
-
}, [classNamesProp])
|
|
151
|
+
}, [classNamesProp, isCombobox])
|
|
120
152
|
|
|
121
153
|
return (
|
|
122
|
-
<div css={style} class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
|
|
154
|
+
<div css={style} class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
|
|
155
|
+
// Combobox: clicking the (already focused) control reopens the menu after a selection
|
|
156
|
+
onMouseDown={isCombobox ? () => setPickedFromMenu(false) : undefined}>
|
|
123
157
|
<ReactSelect
|
|
124
158
|
/**
|
|
125
159
|
* react-select prop quick reference (https://react-select.com/props#api):
|
|
@@ -133,8 +167,8 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
133
167
|
* menuIsOpen={false}
|
|
134
168
|
*/
|
|
135
169
|
{...props}
|
|
136
|
-
_nitro={{ prefix, mode, showSearchIcon }}
|
|
137
|
-
key={value as string}
|
|
170
|
+
_nitro={{ prefix, mode, showSearchIcon: showSearchIcon ?? isCombobox }}
|
|
171
|
+
key={isCombobox ? name : value as string}
|
|
138
172
|
unstyled={true}
|
|
139
173
|
inputId={id || name}
|
|
140
174
|
id={containerId}
|
|
@@ -146,6 +180,7 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
146
180
|
menuPlacement="auto"
|
|
147
181
|
minMenuHeight={250}
|
|
148
182
|
onChange={!onChange ? undefined : (o) => {
|
|
183
|
+
if (isCombobox) setPickedFromMenu(true) // close the menu after picking an option
|
|
149
184
|
// An array is returned for multi-select
|
|
150
185
|
type OptionType = IsMulti extends true ? SelectOption[] : SelectedOption
|
|
151
186
|
let value: unknown | unknown[] = []
|
|
@@ -169,7 +204,7 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
169
204
|
)
|
|
170
205
|
}}
|
|
171
206
|
options={options}
|
|
172
|
-
value={value}
|
|
207
|
+
value={isCombobox ? (typeof value == 'object' ? value : null) : value}
|
|
173
208
|
// @ts-expect-error
|
|
174
209
|
classNames={useMemo(() => ({
|
|
175
210
|
// Input container
|
|
@@ -205,6 +240,8 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
205
240
|
MultiValueRemove,
|
|
206
241
|
ValueContainer,
|
|
207
242
|
Menu,
|
|
243
|
+
...(isCombobox ? { Input: ComboboxInput } : {}),
|
|
244
|
+
...(hideDropdownIcon ? { IndicatorsContainer: () => null } : {}),
|
|
208
245
|
...props.components as object,
|
|
209
246
|
}}
|
|
210
247
|
// menuIsOpen={!search ? false : undefined}
|
|
@@ -231,6 +268,29 @@ function SelectBase<IsMulti extends boolean = false>({
|
|
|
231
268
|
// isMulti={true}
|
|
232
269
|
// isDisabled={true}
|
|
233
270
|
// maxMenuHeight={200}
|
|
271
|
+
// Combobox: bind the input text to the value (typed text is the value), options are just suggestions
|
|
272
|
+
{...(isCombobox ? {
|
|
273
|
+
inputValue: comboInput,
|
|
274
|
+
controlShouldRenderValue: false,
|
|
275
|
+
// Gate the menu: needs focus, min input length, and (optionally) a match to avoid the empty "No options" menu.
|
|
276
|
+
// A menuIsOpen prop hard-overrides this.
|
|
277
|
+
menuIsOpen: 'menuIsOpen' in props
|
|
278
|
+
? props.menuIsOpen as boolean | undefined
|
|
279
|
+
: focused && !pickedFromMenu && comboInput.length >= minLenForSearch && (!hideEmptyMenu || comboMatches >= 1),
|
|
280
|
+
onFocus: (e: FocusEvent<HTMLInputElement>) => {
|
|
281
|
+
setFocused(true); setPickedFromMenu(false);
|
|
282
|
+
(props.onFocus as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
|
|
283
|
+
},
|
|
284
|
+
onBlur: (e: FocusEvent<HTMLInputElement>) => {
|
|
285
|
+
setFocused(false);
|
|
286
|
+
(props.onBlur as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
|
|
287
|
+
},
|
|
288
|
+
onInputChange: (v: string, meta: { action: string }) => {
|
|
289
|
+
if (meta.action !== 'input-change') return
|
|
290
|
+
setPickedFromMenu(false) // typing re-opens the menu
|
|
291
|
+
onChange?.({ target: { name: name, value: v } }, null as never)
|
|
292
|
+
},
|
|
293
|
+
} : {})}
|
|
234
294
|
/>
|
|
235
295
|
{error && <div class="mt-1.5 text-xs text-danger-foreground">{error.detail}</div>}
|
|
236
296
|
</div>
|
|
@@ -241,9 +301,14 @@ function Menu(props: MenuProps) {
|
|
|
241
301
|
return props.options.length === 0 ? null : <components.Menu {...props} />
|
|
242
302
|
}
|
|
243
303
|
|
|
304
|
+
// Combobox: keep the input visible after selecting (react-select hides it to show a value chip we don't render)
|
|
305
|
+
function ComboboxInput(props: InputProps) {
|
|
306
|
+
return <components.Input {...props} isHidden={false} />
|
|
307
|
+
}
|
|
308
|
+
|
|
244
309
|
function Control({ children, ...props }: ControlProps) {
|
|
245
310
|
// const selectedOption = props.getValue()[0]
|
|
246
|
-
const _nitro = (props.selectProps as { _nitro?: { prefix?: string, mode?:
|
|
311
|
+
const _nitro = (props.selectProps as { _nitro?: { prefix?: string, mode?: 'combobox' } })?._nitro
|
|
247
312
|
return (
|
|
248
313
|
<components.Control {...props}>
|
|
249
314
|
{_nitro?.prefix
|
|
@@ -287,7 +352,7 @@ function SingleValue({ children, ...props }: SingleValueProps) {
|
|
|
287
352
|
|
|
288
353
|
function Option(props: OptionProps) {
|
|
289
354
|
const data = props.data as SelectOption
|
|
290
|
-
// const _nitro = (props.selectProps as { _nitro?: { mode?:
|
|
355
|
+
// const _nitro = (props.selectProps as { _nitro?: { mode?: 'combobox' } })?._nitro
|
|
291
356
|
// @ts-expect-error
|
|
292
357
|
const flagClassName = props.getClassNames('flag')
|
|
293
358
|
return (
|
|
@@ -10,20 +10,20 @@ import React from 'react'
|
|
|
10
10
|
|
|
11
11
|
const perPage = 10
|
|
12
12
|
const allGroups = [
|
|
13
|
-
'Links',
|
|
14
|
-
'Dropdowns',
|
|
15
|
-
'Filters',
|
|
16
|
-
'Button Colors & Sizes',
|
|
17
|
-
'Button Icons',
|
|
18
|
-
'Loading Elements',
|
|
19
|
-
'Varients',
|
|
13
|
+
// 'Links',
|
|
14
|
+
// 'Dropdowns',
|
|
15
|
+
// 'Filters',
|
|
16
|
+
// 'Button Colors & Sizes',
|
|
17
|
+
// 'Button Icons',
|
|
18
|
+
// 'Loading Elements',
|
|
19
|
+
// 'Varients',
|
|
20
20
|
'Selects',
|
|
21
|
-
'Inputs',
|
|
22
|
-
'Date Inputs',
|
|
23
|
-
'File Inputs & Calendar & Time',
|
|
24
|
-
'Tables',
|
|
25
|
-
'Modals & Notifications',
|
|
26
|
-
'Custom Components',
|
|
21
|
+
// 'Inputs',
|
|
22
|
+
// 'Date Inputs',
|
|
23
|
+
// 'File Inputs & Calendar & Time',
|
|
24
|
+
// 'Tables',
|
|
25
|
+
// 'Modals & Notifications',
|
|
26
|
+
// 'Custom Components',
|
|
27
27
|
] as const
|
|
28
28
|
|
|
29
29
|
const statusColors = function(status: string) {
|
|
@@ -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: 'blue',
|
|
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: 'green',
|
|
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,30 @@ export function Styleguide({ className, elements, children, currencies, groups }
|
|
|
535
536
|
], [])}
|
|
536
537
|
/>
|
|
537
538
|
</div>
|
|
539
|
+
<div>
|
|
540
|
+
<label for="combobox">Combobox (Autocomplete /w or free text)</label>
|
|
541
|
+
<Select
|
|
542
|
+
name="combobox"
|
|
543
|
+
mode="combobox"
|
|
544
|
+
minLenForSearch={1}
|
|
545
|
+
state={state}
|
|
546
|
+
onChange={(e) => onChange(e, setState)}
|
|
547
|
+
placeholder="Start typing to search for a color..."
|
|
548
|
+
// hideDropdownIcon={true}
|
|
549
|
+
options={useMemo(() => [
|
|
550
|
+
{ value: 'blue', label: 'Blue' },
|
|
551
|
+
{ value: 'green', label: 'Green' },
|
|
552
|
+
{ value: 'yellow', label: 'Yellow' },
|
|
553
|
+
{ value: 'red', label: 'Red' },
|
|
554
|
+
{ value: 'orange', label: 'Orange' },
|
|
555
|
+
{ value: 'purple', label: 'Purple' },
|
|
556
|
+
{ value: 'pink', label: 'Pink' },
|
|
557
|
+
{ value: 'gray', label: 'Gray' },
|
|
558
|
+
{ value: 'black', label: 'Black' },
|
|
559
|
+
{ value: 'white', label: 'White' },
|
|
560
|
+
], [])}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
538
563
|
</div>
|
|
539
564
|
</div>
|
|
540
565
|
)}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitro-web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.14",
|
|
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 🚀",
|