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({ show, setShow, children, maxWidth, minHeight, dismissable = true, className, rootClassName }: ModalProps) {
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="absolute top-0 right-0 p-3 m-1 cursor-pointer"
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
- mode?: 'combobox'
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, state, mode, errorTitle, classNames: classNamesProp,
95
- showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true, hideDropdownIcon, maxLines,
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
- // Raw (unconverted) value, used as the combobox input text
113
- const rawValue = value
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 = options.filter(o => (value as unknown[]).includes(o.value))
117
- else value = options.find(o => value === o.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
- const comboInput = comboLabel ?? String(rawValue ?? '')
129
- const comboMatches = !isCombobox ? 0 : options.filter((o) => {
130
- const label = typeof o.label == 'string' ? o.label : (o.labelSearch || o.labelInput || '')
131
- return filterFn({ label: label, value: String(o.value), data: o }, comboInput)
132
- }).length
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 (isCombobox) {
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, isCombobox])
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={isCombobox ? () => setPickedFromMenu(false) : undefined}>
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 ?? isCombobox, maxLines: maxLines }}
183
- key={isCombobox ? name : value as string}
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={(option, searchText) => {
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 (isCombobox) setPickedFromMenu(true) // close the menu after picking an option
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={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={isCombobox ? (typeof value == 'object' ? value : null) : 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
- ...(isCombobox ? { Input: ComboboxInput } : {}),
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, minWidth: minMenuWidth,
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
- {...(isCombobox ? {
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: false,
295
- // Gate the menu: needs focus, min input length, and (optionally) a match to avoid the empty "No options" menu.
296
- // A menuIsOpen prop hard-overrides this.
297
- menuIsOpen: 'menuIsOpen' in props
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
- (props.onBlur as ((e: FocusEvent<HTMLInputElement>) => void) | undefined)?.(e)
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
- onChange?.({ target: { name: name, value: v } }, null as never)
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.21",
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 🚀",