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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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.
|
|
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) {
|