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' | 'currency' | 'percent' | 'date' | 'color' | 'file'
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?: string
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='', errorTitle, classNames: classNamesProp,
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 labelSearch = (option.data as {labelSearch?: string}).labelSearch
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?: string } })?._nitro
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 { labelControl?: string } & SelectOption
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?.labelControl
274
- ? <Fragment>{selectedOption.labelControl}</Fragment>
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?: string } })?._nitro
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.13",
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 🚀",