nitro-web 0.2.16 → 0.2.18

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)
@@ -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,
@@ -267,6 +275,10 @@ function SelectBase<IsMulti extends boolean = false>({
267
275
  outline: undefined,
268
276
  transition: 'none',
269
277
  }),
278
+ input: (base) => ({
279
+ ...base,
280
+ visibility: 'inherit', // RS hardcodes to visblr, inherit visibility from ancestor
281
+ }),
270
282
  }}
271
283
  // menuIsOpen={true}
272
284
  // isSearchable={false}
@@ -326,10 +338,85 @@ function Control({ children, ...props }: ControlProps) {
326
338
  }
327
339
 
328
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
329
406
  return (
330
- // <div class="cat-tre">
331
- <components.ValueContainer {...props}>{children}</components.ValueContainer>
332
- // </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>
333
420
  )
334
421
  }
335
422
 
@@ -389,6 +476,11 @@ const ClearIndicator = (props: ClearIndicatorProps) => {
389
476
  )
390
477
  }
391
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
+
392
484
  const MultiValueRemove = (props: MultiValueRemoveProps) => {
393
485
  return (
394
486
  <components.MultiValueRemove {...props}>
@@ -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.16",
3
+ "version": "0.2.18",
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 🚀",
package/util.js CHANGED
@@ -212,7 +212,7 @@ export function capitalise (str) {
212
212
  * @returns {string}
213
213
  */
214
214
  export function currency (cents, options) {
215
- const { currency, decimals, decimalsMinimum, prefix, suffix } = options || { currency: 'usd' }
215
+ const { currency='usd', decimals, decimalsMinimum, prefix, suffix } = options || { currency: 'usd' }
216
216
  const currencyObject = currencies[/**@type {keyof typeof currencies}*/(currency)]
217
217
 
218
218
  if (currency && !currencyObject) {