@inglorious/ui 9.8.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "9.8.1",
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",
@@ -329,13 +329,13 @@
329
329
  "@inglorious/vite-plugin-jsx": "2.0.6",
330
330
  "@inglorious/vite-plugin-vue": "1.0.7",
331
331
  "@inglorious/web": "6.13.1",
332
- "@storybook/addon-docs": "^10.6.0",
333
- "@storybook/web-components-vite": "^10.6.0",
332
+ "@storybook/addon-docs": "^10.6.5",
333
+ "@storybook/web-components-vite": "^10.6.5",
334
334
  "happy-dom": "^20.14.0",
335
335
  "jsdom": "^30.0.1",
336
336
  "prettier": "^3.9.6",
337
337
  "remark-gfm": "^4.0.1",
338
- "storybook": "^10.6.0",
338
+ "storybook": "^10.6.5",
339
339
  "typescript": "~7.0.2",
340
340
  "vite": "^8.2.2",
341
341
  "vitest": "^5.0.0"
@@ -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 { filterOptions, formatOption, getOptionValue } from "./helpers.js"
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 = filterOptions(entity.options, entity.searchTerm)
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 = filterOptions(entity.options, entity.searchTerm)
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 = filterOptions(entity.options, entity.searchTerm)
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 = filterOptions(entity.options, entity.searchTerm)
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 = filterOptions(entity.options, entity.searchTerm)
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
- const label = getOptionLabel(option)
50
- return label.toLowerCase().includes(searchLower)
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
+ }
@@ -40,4 +40,5 @@ export const {
40
40
  getOptionLabel,
41
41
  getOptionValue,
42
42
  isOptionSelected,
43
+ visibleOptions,
43
44
  } = helpers
@@ -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) => event.stopPropagation()}
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: option ? getOptionLabel(option) : String(value),
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, options, searchTerm, isSearchable, isLoading } = props
274
+ const { id, isSearchable, isLoading } = props
273
275
 
274
- const filteredOptions = filterOptions(options, searchTerm)
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 { options, searchTerm } = props
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
- if (isDisabled) return
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 = filterOptions(options, searchTerm)
464
+ const filteredOptions = visibleOptions(props)
466
465
 
467
466
  switch (event.key) {
468
467
  case "ArrowDown":
@@ -60,10 +60,50 @@
60
60
  }
61
61
 
62
62
  .iw-accordion-panel {
63
- padding: 0 var(--iw-space-4) var(--iw-space-4);
63
+ min-block-size: 0;
64
+ overflow: hidden;
65
+ padding: 0 var(--iw-space-4);
64
66
  color: var(--iw-color-text-secondary, var(--iw-color-text));
65
67
  }
66
68
 
69
+ .iw-accordion-item[open] .iw-accordion-panel {
70
+ padding-block-end: var(--iw-space-4);
71
+ }
72
+
73
+ @media (prefers-reduced-motion: no-preference) {
74
+ .iw-accordion-item::details-content {
75
+ display: grid;
76
+ grid-template-rows: 0fr;
77
+ transition:
78
+ grid-template-rows var(--iw-transition-normal),
79
+ content-visibility var(--iw-transition-normal) allow-discrete;
80
+ }
81
+
82
+ .iw-accordion-item[open]::details-content {
83
+ grid-template-rows: 1fr;
84
+ }
85
+
86
+ .iw-accordion-panel {
87
+ transition: padding-block-end var(--iw-transition-normal);
88
+ }
89
+
90
+ /* WebKit hides content-visibility at once, which skips the closing animation.
91
+ Elsewhere keep content-visibility: find-in-page relies on it. */
92
+ @supports (font: -apple-system-body) {
93
+ .iw-accordion-item::details-content {
94
+ content-visibility: visible;
95
+ visibility: hidden;
96
+ transition:
97
+ grid-template-rows var(--iw-transition-normal),
98
+ visibility var(--iw-transition-normal);
99
+ }
100
+
101
+ .iw-accordion-item[open]::details-content {
102
+ visibility: visible;
103
+ }
104
+ }
105
+ }
106
+
67
107
  .iw-accordion-item-disabled {
68
108
  opacity: 0.6;
69
109
  }
@@ -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[]