nitro-web 0.2.17 → 0.2.19

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.
@@ -1,9 +1,9 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { css } from 'twin.macro'
3
- import { memo, useMemo, useState, Fragment, FocusEvent } from 'react'
3
+ import { memo, useMemo, useState, useRef, useLayoutEffect, Fragment, FocusEvent, ReactNode } from 'react'
4
4
  import ReactSelect, {
5
5
  components, ControlProps, createFilter, OptionProps, SingleValueProps, ClearIndicatorProps,
6
- DropdownIndicatorProps, MultiValueRemoveProps, // ClassNamesConfig,
6
+ DropdownIndicatorProps, MultiValueRemoveProps, MultiValueGenericProps, // ClassNamesConfig,
7
7
  ValueContainerProps,
8
8
  MenuProps,
9
9
  InputProps,
@@ -80,6 +80,8 @@ export type SelectProps<IsMulti extends boolean = false> = {
80
80
  hideEmptyMenu?: boolean
81
81
  /** Hide the right dropdown icon + its padding (text-field look) **/
82
82
  hideDropdownIcon?: boolean
83
+ /** Multi-select only: max lines of chips to show in the control, extras are hidden behind a "+N" counter (default 2) **/
84
+ maxLines?: number
83
85
  /** All other props are passed to react-select **/
84
86
  [key: string]: unknown
85
87
  }
@@ -90,7 +92,7 @@ export const Select = memo(SelectBase, (prev, next) => {
90
92
 
91
93
  function SelectBase<IsMulti extends boolean = false>({
92
94
  id, containerId, minMenuWidth, name, prefix='', onChange, options, state, mode, errorTitle, classNames: classNamesProp,
93
- showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true, hideDropdownIcon,
95
+ showSearchIcon, className, minLenForSearch = 0, hideEmptyMenu = true, hideDropdownIcon, maxLines,
94
96
  ...props
95
97
  }: SelectProps<IsMulti>) {
96
98
  let value: unknown|unknown[]
@@ -100,6 +102,9 @@ function SelectBase<IsMulti extends boolean = false>({
100
102
  // Combobox: free text with a suggestions dropdown, the typed text is the value (full-width text-input feel)
101
103
  const isCombobox = mode === 'combobox'
102
104
 
105
+ // Multi-selects collapse tags to 2 lines by default
106
+ maxLines = maxLines ?? (props.isMulti ? 2 : undefined)
107
+
103
108
  // Get value from value or state
104
109
  if (typeof props.value !== 'undefined') value = props.value
105
110
  else if (typeof state == 'object') value = deepFind(state, name)
@@ -156,7 +161,7 @@ function SelectBase<IsMulti extends boolean = false>({
156
161
  }, [classNamesProp, isCombobox])
157
162
 
158
163
  return (
159
- <div css={style} class={'mt-2.5 mb-6 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
164
+ <div css={style} class={'mt-2.5 mb-6 min-w-0 ' + twMerge(`mt-input-before mb-input-after nitro-select ${className || ''}`)}
160
165
  // Combobox: clicking the (already focused) control reopens the menu after a selection
161
166
  onMouseDown={isCombobox ? () => setPickedFromMenu(false) : undefined}>
162
167
  <ReactSelect
@@ -172,7 +177,7 @@ function SelectBase<IsMulti extends boolean = false>({
172
177
  * menuIsOpen={false}
173
178
  */
174
179
  {...props}
175
- _nitro={{ prefix: prefix, mode: mode, showSearchIcon: showSearchIcon ?? isCombobox }}
180
+ _nitro={{ prefix: prefix, mode: mode, showSearchIcon: showSearchIcon ?? isCombobox, maxLines: maxLines }}
176
181
  key={isCombobox ? name : value as string}
177
182
  unstyled={true}
178
183
  inputId={id || name}
@@ -210,6 +215,8 @@ function SelectBase<IsMulti extends boolean = false>({
210
215
  )
211
216
  }}
212
217
  options={options}
218
+ // maxLines hides overflow chips, so keep selected options in the menu to make them easy to unselect
219
+ {...(maxLines && !('hideSelectedOptions' in props) ? { hideSelectedOptions: false } : {})}
213
220
  value={isCombobox ? (typeof value == 'object' ? value : null) : value}
214
221
  // @ts-expect-error
215
222
  classNames={useMemo(() => ({
@@ -243,6 +250,7 @@ function SelectBase<IsMulti extends boolean = false>({
243
250
  Option,
244
251
  DropdownIndicator,
245
252
  ClearIndicator,
253
+ MultiValueLabel,
246
254
  MultiValueRemove,
247
255
  ValueContainer,
248
256
  Menu,
@@ -330,10 +338,85 @@ function Control({ children, ...props }: ControlProps) {
330
338
  }
331
339
 
332
340
  function ValueContainer({ children, ...props}: ValueContainerProps) {
341
+ const maxLines = (props.selectProps as { _nitro?: { maxLines?: number } })?._nitro?.maxLines
342
+ const kids = Array.isArray(children) ? children : [children]
343
+ const chips = kids[0] // array of chips for multi-select, else placeholder/single value
344
+
345
+ // No limit (or nothing to limit) renders as normal
346
+ if (!maxLines || !Array.isArray(chips)) {
347
+ return <components.ValueContainer {...props}>{children}</components.ValueContainer>
348
+ }
349
+ return <LimitedValueContainer chips={chips} rest={kids.slice(1)} maxLines={maxLines} {...props} />
350
+ }
351
+
352
+ // Multi-select: shows tags over up to maxLines rows with a "+N" counter for the rest. The rows above the
353
+ // last stay fixed; the last row is a single no-wrap line, so as the input grows it pushes that row's tags
354
+ // off to the left (for maxLines=1 the last row is the only row).
355
+ function LimitedValueContainer({ chips, rest, maxLines, ...props }:
356
+ Omit<ValueContainerProps, 'children'> & { chips: ReactNode[], rest: ReactNode[], maxLines: number }) {
357
+ const sentinel = useRef<HTMLSpanElement>(null) // reaches the value container to measure tag rows
358
+ const lastRow = useRef<HTMLDivElement>(null) // the scrolling last row
359
+ const widthRef = useRef(0)
360
+ // upper = tags on the fixed rows above the last; last = tags on the scrolling last row
361
+ const [split, setSplit] = useState<{ upper: number, last: number } | null>(null)
362
+
363
+ // Re-measure on width or chip-count changes
364
+ useLayoutEffect(() => {
365
+ const parent = sentinel.current?.parentElement
366
+ if (!parent) return
367
+ const ro = new ResizeObserver(([e]) => {
368
+ const w = e.contentRect.width
369
+ if (Math.abs(w - widthRef.current) > 1) { widthRef.current = w; setSplit(null) }
370
+ })
371
+ ro.observe(parent)
372
+ return () => ro.disconnect()
373
+ }, [])
374
+ useLayoutEffect(() => setSplit(null), [chips.length])
375
+
376
+ // Measure the natural wrap of all tags, keep maxLines rows: rows above the last stay fixed, the last row
377
+ // scrolls, and anything past maxLines rows becomes the "+N" count
378
+ useLayoutEffect(() => {
379
+ if (split !== null) return
380
+ const parent = sentinel.current?.parentElement
381
+ if (!parent) return
382
+ const nodes = Array.from(parent.children).slice(0, chips.length) as HTMLElement[]
383
+ if (!nodes.length) return setSplit({ upper: 0, last: 0 })
384
+ const tops = [...new Set(nodes.map((n) => Math.round(n.offsetTop)))].sort((a, b) => a - b)
385
+ const lastTop = tops[Math.min(maxLines, tops.length) - 1] // top of the last visible row
386
+ const upper = nodes.filter((n) => Math.round(n.offsetTop) < lastTop).length
387
+ const last = nodes.filter((n) => Math.round(n.offsetTop) === lastTop).length
388
+ setSplit({ upper, last })
389
+ }, [split, chips.length, maxLines])
390
+
391
+ // Keep the last row scrolled to the input while typing (pushes its tags left); left-aligned at rest
392
+ useLayoutEffect(() => {
393
+ const el = lastRow.current
394
+ if (el) el.scrollLeft = el.querySelector('input')?.value ? el.scrollWidth : 0
395
+ })
396
+
397
+ // Measuring pass: render all tags flat so we can read their rows
398
+ if (split === null) {
399
+ return (
400
+ <components.ValueContainer {...props}>
401
+ {chips}{rest}<span ref={sentinel} style={{ display: 'none' }} />
402
+ </components.ValueContainer>
403
+ )
404
+ }
405
+ const hidden = chips.length - split.upper - split.last
333
406
  return (
334
- // <div class="cat-tre">
335
- <components.ValueContainer {...props}>{children}</components.ValueContainer>
336
- // </div>
407
+ <components.ValueContainer {...props}>
408
+ {chips.slice(0, split.upper)}
409
+ <div ref={lastRow} class="w-full min-w-0 flex flex-nowrap items-center gap-1 overflow-hidden [&>*]:shrink-0">
410
+ {chips.slice(split.upper, split.upper + split.last)}
411
+ {hidden > 0 && (
412
+ <span class="self-stretch flex items-center rounded bg-gray-100 text-gray-500 text-xs px-2 whitespace-nowrap">
413
+ +{hidden}
414
+ </span>
415
+ )}
416
+ {rest}
417
+ </div>
418
+ <span ref={sentinel} style={{ display: 'none' }} />
419
+ </components.ValueContainer>
337
420
  )
338
421
  }
339
422
 
@@ -393,6 +476,11 @@ const ClearIndicator = (props: ClearIndicatorProps) => {
393
476
  )
394
477
  }
395
478
 
479
+ const MultiValueLabel = (props: MultiValueGenericProps) => {
480
+ const data = props.data as SelectOption
481
+ return <components.MultiValueLabel {...props}>{data.labelInput ?? props.children}</components.MultiValueLabel>
482
+ }
483
+
396
484
  const MultiValueRemove = (props: MultiValueRemoveProps) => {
397
485
  return (
398
486
  <components.MultiValueRemove {...props}>
@@ -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) {
@@ -66,7 +66,7 @@ export function Styleguide({ className, elements, children, currencies, groups }
66
66
  address: '',
67
67
  amount: 100,
68
68
  brandColor: '#F3CA5F',
69
- colorsMulti: ['blue', 'green'],
69
+ colorsMulti: ['blue', 'green', 'yellow', 'red', 'orange', 'purple'],
70
70
  country: 'cd',
71
71
  currency: 'nzd',
72
72
  percent: 1250,
@@ -437,9 +437,10 @@ export function Styleguide({ className, elements, children, currencies, groups }
437
437
  </div>
438
438
  <div>
439
439
  <label for="colorsMulti">Mutli Select</label>
440
- <Select
440
+ <Select
441
441
  name="colorsMulti"
442
442
  isMulti={true}
443
+ // maxLines={1}
443
444
  state={state}
444
445
  options={useMemo(() => [
445
446
  { value: 'blue', label: 'Blue' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitro-web",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
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 🚀",