@inglorious/ui 9.8.2 → 9.9.0
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.
- package/package.json +1 -1
- package/src/controls/combobox/combobox.stories.js +14 -0
- package/src/controls/combobox/handlers.js +11 -6
- package/src/controls/combobox/helpers.js +50 -4
- package/src/controls/combobox/index.js +1 -0
- package/src/controls/combobox/template.js +13 -14
- package/types/controls/combobox.d.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ui",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.9.0",
|
|
4
4
|
"description": "A design system for Inglorious Web with render-function components and customizable themes",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,10 @@ export default {
|
|
|
29
29
|
},
|
|
30
30
|
isSearchable: { control: "boolean", description: "Show search input." },
|
|
31
31
|
isClearable: { control: "boolean", description: "Show clear button." },
|
|
32
|
+
isCreatable: {
|
|
33
|
+
control: "boolean",
|
|
34
|
+
description: "Offer to add a searched term that matches no option.",
|
|
35
|
+
},
|
|
32
36
|
isDisabled: { control: "boolean", description: "Disable the control." },
|
|
33
37
|
isLoading: { control: "boolean", description: "Show loading state." },
|
|
34
38
|
searchTerm: { control: "text", description: "Current search filter." },
|
|
@@ -95,6 +99,16 @@ MultiOpen.args = {
|
|
|
95
99
|
isOpen: true,
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
export const Creatable = {}
|
|
103
|
+
Creatable.args = {
|
|
104
|
+
...Default.args,
|
|
105
|
+
isMulti: true,
|
|
106
|
+
isCreatable: true,
|
|
107
|
+
selectedValue: ["it", "Atlantis"],
|
|
108
|
+
searchTerm: "Narnia",
|
|
109
|
+
isOpen: true,
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
export const WithHint = {}
|
|
99
113
|
WithHint.args = {
|
|
100
114
|
...Default.args,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @typedef {import('../../../types/controls/select.js').SelectOption} SelectOption
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { formatOption, getOptionValue, visibleOptions } from "./helpers.js"
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Initialize missing select state defaults.
|
|
@@ -39,7 +39,7 @@ export function open(entity) {
|
|
|
39
39
|
if (entity.isDisabled) return
|
|
40
40
|
entity.isOpen = true
|
|
41
41
|
|
|
42
|
-
const filtered =
|
|
42
|
+
const filtered = visibleOptions(entity)
|
|
43
43
|
entity.focusedIndex = filtered.length ? 0 : -1
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -66,6 +66,11 @@ export function optionSelect(entity, option) {
|
|
|
66
66
|
const normalizedOption = formatOption(option)
|
|
67
67
|
const optionValue = getOptionValue(normalizedOption)
|
|
68
68
|
|
|
69
|
+
if (normalizedOption.isCreated) {
|
|
70
|
+
entity.searchTerm = ""
|
|
71
|
+
entity.focusedIndex = -1
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
if (entity.isMulti) {
|
|
70
75
|
const values = Array.isArray(entity.selectedValue)
|
|
71
76
|
? entity.selectedValue
|
|
@@ -98,13 +103,13 @@ export function clear(entity) {
|
|
|
98
103
|
*/
|
|
99
104
|
export function searchChange(entity, searchTerm) {
|
|
100
105
|
entity.searchTerm = searchTerm
|
|
101
|
-
const filtered =
|
|
106
|
+
const filtered = visibleOptions(entity)
|
|
102
107
|
entity.focusedIndex = filtered.length ? 0 : -1
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** @param {SelectEntity} entity */
|
|
106
111
|
export function focusNext(entity) {
|
|
107
|
-
const filtered =
|
|
112
|
+
const filtered = visibleOptions(entity)
|
|
108
113
|
if (!filtered.length) return
|
|
109
114
|
entity.focusedIndex = Math.min(entity.focusedIndex + 1, filtered.length - 1)
|
|
110
115
|
}
|
|
@@ -116,13 +121,13 @@ export function focusPrev(entity) {
|
|
|
116
121
|
|
|
117
122
|
/** @param {SelectEntity} entity */
|
|
118
123
|
export function focusFirst(entity) {
|
|
119
|
-
const filtered =
|
|
124
|
+
const filtered = visibleOptions(entity)
|
|
120
125
|
if (filtered.length) entity.focusedIndex = 0
|
|
121
126
|
}
|
|
122
127
|
|
|
123
128
|
/** @param {SelectEntity} entity */
|
|
124
129
|
export function focusLast(entity) {
|
|
125
|
-
const filtered =
|
|
130
|
+
const filtered = visibleOptions(entity)
|
|
126
131
|
if (filtered.length) entity.focusedIndex = filtered.length - 1
|
|
127
132
|
}
|
|
128
133
|
|
|
@@ -45,10 +45,45 @@ export function filterOptions(options, searchTerm) {
|
|
|
45
45
|
|
|
46
46
|
const searchLower = String(searchTerm).toLowerCase().trim()
|
|
47
47
|
|
|
48
|
-
return options.filter((option) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
return options.filter((option) =>
|
|
49
|
+
optionTexts(option).some((text) => text.includes(searchLower)),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The options the dropdown lists: those matching the search, then, on a
|
|
55
|
+
* creatable combobox, one to add the typed term unless it is already known.
|
|
56
|
+
* @param {{ options?: SelectOption[], searchTerm?: string, selectedValue?: string|number|(string|number)[]|null, isCreatable?: boolean, formatCreateLabel?: (term: string) => string }} props
|
|
57
|
+
* @returns {SelectOption[]}
|
|
58
|
+
*/
|
|
59
|
+
export function visibleOptions({
|
|
60
|
+
options = [],
|
|
61
|
+
searchTerm,
|
|
62
|
+
selectedValue,
|
|
63
|
+
isCreatable = false,
|
|
64
|
+
formatCreateLabel = (term) => `Add "${term}"`,
|
|
65
|
+
}) {
|
|
66
|
+
const filtered = filterOptions(options, searchTerm)
|
|
67
|
+
const term = String(searchTerm ?? "").trim()
|
|
68
|
+
if (!isCreatable || !term) return filtered
|
|
69
|
+
|
|
70
|
+
const key = term.toLowerCase()
|
|
71
|
+
const isKnown =
|
|
72
|
+
[selectedValue]
|
|
73
|
+
.flat()
|
|
74
|
+
.some((value) => String(value).toLowerCase() === key) ||
|
|
75
|
+
options.some(
|
|
76
|
+
(option) =>
|
|
77
|
+
!formatOption(option).isDisabled &&
|
|
78
|
+
(optionTexts(option).includes(key) ||
|
|
79
|
+
String(getOptionValue(option)).toLowerCase() === key),
|
|
80
|
+
)
|
|
81
|
+
if (isKnown) return filtered
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
...filtered,
|
|
85
|
+
{ value: term, label: formatCreateLabel(term), isCreated: true },
|
|
86
|
+
]
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
/**
|
|
@@ -89,3 +124,14 @@ export function formatOption(option) {
|
|
|
89
124
|
|
|
90
125
|
return { value: String(option), label: String(option) }
|
|
91
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What a search looks through: the label and the option's keywords, lowercased.
|
|
130
|
+
* @param {SelectOption} option
|
|
131
|
+
* @returns {string[]}
|
|
132
|
+
*/
|
|
133
|
+
function optionTexts(option) {
|
|
134
|
+
return [getOptionLabel(option), ...(option?.keywords ?? [])].map((text) =>
|
|
135
|
+
text.toLowerCase(),
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -14,11 +14,11 @@ import { Chip } from "../../data-display/chip/index.js"
|
|
|
14
14
|
import { fallbackLabel } from "../../shared/fallback-label.js"
|
|
15
15
|
import { syncPopover } from "../../shared/top-layer.js"
|
|
16
16
|
import {
|
|
17
|
-
filterOptions,
|
|
18
17
|
formatOption,
|
|
19
18
|
getOptionLabel,
|
|
20
19
|
getOptionValue,
|
|
21
20
|
isOptionSelected,
|
|
21
|
+
visibleOptions,
|
|
22
22
|
} from "./helpers.js"
|
|
23
23
|
|
|
24
24
|
const NO_FOCUSED_INDEX = -1
|
|
@@ -234,18 +234,20 @@ export const Combobox = {
|
|
|
234
234
|
*/
|
|
235
235
|
renderMultiValueTag(props, value) {
|
|
236
236
|
const { id, options, size, onOptionSelect, onChange } = props
|
|
237
|
+
const option = options.find((opt) => getOptionValue(opt) === value) ?? value
|
|
237
238
|
|
|
238
|
-
const option = options.find((opt) => getOptionValue(opt) === value)
|
|
239
239
|
const tagId = id ? `${id}-tag-${String(value)}` : undefined
|
|
240
240
|
const chipSize = size === "lg" ? "md" : "sm"
|
|
241
241
|
|
|
242
242
|
return html`<span
|
|
243
243
|
class="iw-combobox-multi-value-tag"
|
|
244
|
-
@click=${(event) =>
|
|
244
|
+
@click=${(event) => {
|
|
245
|
+
if (event.target.closest(".iw-chip-remove")) event.stopPropagation()
|
|
246
|
+
}}
|
|
245
247
|
>
|
|
246
248
|
${Chip.render({
|
|
247
249
|
id: tagId,
|
|
248
|
-
children:
|
|
250
|
+
children: getOptionLabel(option),
|
|
249
251
|
isRemovable: true,
|
|
250
252
|
size: chipSize,
|
|
251
253
|
shape: "rounded",
|
|
@@ -269,9 +271,9 @@ export const Combobox = {
|
|
|
269
271
|
* @returns {TemplateResult}
|
|
270
272
|
*/
|
|
271
273
|
renderDropdown(props) {
|
|
272
|
-
const { id,
|
|
274
|
+
const { id, isSearchable, isLoading } = props
|
|
273
275
|
|
|
274
|
-
const filteredOptions =
|
|
276
|
+
const filteredOptions = visibleOptions(props)
|
|
275
277
|
|
|
276
278
|
return html`<div
|
|
277
279
|
id=${id ? `${id}-control-listbox` : nothing}
|
|
@@ -328,9 +330,7 @@ export const Combobox = {
|
|
|
328
330
|
* @returns {TemplateResult}
|
|
329
331
|
*/
|
|
330
332
|
renderOptions(props) {
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const filteredOptions = filterOptions(options, searchTerm)
|
|
333
|
+
const filteredOptions = visibleOptions(props)
|
|
334
334
|
|
|
335
335
|
return html`<div class="iw-combobox-dropdown-options">
|
|
336
336
|
${repeat(
|
|
@@ -380,7 +380,7 @@ export const Combobox = {
|
|
|
380
380
|
@mouseenter=${() => onFocusSet?.(index)}
|
|
381
381
|
>
|
|
382
382
|
${when(
|
|
383
|
-
isMulti,
|
|
383
|
+
isMulti && !normalized.isCreated,
|
|
384
384
|
() =>
|
|
385
385
|
html`<input
|
|
386
386
|
type="checkbox"
|
|
@@ -428,7 +428,8 @@ export const Combobox = {
|
|
|
428
428
|
function handleControlKeyDown(props, event) {
|
|
429
429
|
const { isDisabled, isOpen, onToggle } = props
|
|
430
430
|
|
|
431
|
-
|
|
431
|
+
// The keys of a tag's remove button or the clear button are their own.
|
|
432
|
+
if (isDisabled || event.target.closest?.("button")) return
|
|
432
433
|
|
|
433
434
|
if (isOpen) {
|
|
434
435
|
handleKeyDown(props, event)
|
|
@@ -450,8 +451,6 @@ function handleControlKeyDown(props, event) {
|
|
|
450
451
|
*/
|
|
451
452
|
function handleKeyDown(props, event) {
|
|
452
453
|
const {
|
|
453
|
-
options,
|
|
454
|
-
searchTerm,
|
|
455
454
|
focusedIndex,
|
|
456
455
|
onFocusFirst,
|
|
457
456
|
onFocusPrev,
|
|
@@ -462,7 +461,7 @@ function handleKeyDown(props, event) {
|
|
|
462
461
|
onClose,
|
|
463
462
|
} = props
|
|
464
463
|
|
|
465
|
-
const filteredOptions =
|
|
464
|
+
const filteredOptions = visibleOptions(props)
|
|
466
465
|
|
|
467
466
|
switch (event.key) {
|
|
468
467
|
case "ArrowDown":
|
|
@@ -10,6 +10,10 @@ export type ComboboxOption =
|
|
|
10
10
|
error?: string
|
|
11
11
|
isDisabled?: boolean
|
|
12
12
|
isRequired?: boolean
|
|
13
|
+
/** Other names a search finds this option by. */
|
|
14
|
+
keywords?: string[]
|
|
15
|
+
/** Set on the option that adds a searched term. */
|
|
16
|
+
isCreated?: boolean
|
|
13
17
|
[key: string]: unknown
|
|
14
18
|
}
|
|
15
19
|
|
|
@@ -30,6 +34,10 @@ export interface ComboboxProps {
|
|
|
30
34
|
isDisabled?: boolean
|
|
31
35
|
isSearchable?: boolean
|
|
32
36
|
isClearable?: boolean
|
|
37
|
+
/** Offers to add a searched term that matches no option. Needs `isSearchable`. */
|
|
38
|
+
isCreatable?: boolean
|
|
39
|
+
/** Text of the option that adds the term. Defaults to `Add "term"`. */
|
|
40
|
+
formatCreateLabel?: (term: string) => string
|
|
33
41
|
|
|
34
42
|
placeholder?: string
|
|
35
43
|
/** Placeholder of the search box in the panel. Defaults to "Search...". */
|
|
@@ -83,3 +91,13 @@ export declare function filterOptions(
|
|
|
83
91
|
options: ComboboxOption[],
|
|
84
92
|
searchTerm: string,
|
|
85
93
|
): ComboboxOption[]
|
|
94
|
+
export declare function visibleOptions(
|
|
95
|
+
props: Pick<
|
|
96
|
+
ComboboxProps,
|
|
97
|
+
| "options"
|
|
98
|
+
| "searchTerm"
|
|
99
|
+
| "selectedValue"
|
|
100
|
+
| "isCreatable"
|
|
101
|
+
| "formatCreateLabel"
|
|
102
|
+
>,
|
|
103
|
+
): ComboboxOption[]
|