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' | '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'
@@ -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?: string
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='', errorTitle, classNames: classNamesProp,
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?: string } })?._nitro
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?: string } })?._nitro
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.13",
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 🚀",