@rettangoli/ui 1.8.1 → 1.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.
@@ -55,7 +55,7 @@ table {
55
55
 
56
56
  :root {
57
57
  --width-stretch: 100%;
58
- --scrollbar-size: 6px;
58
+ --scrollbar-size: var(--spacing-sm);
59
59
  --scrollbar-track: transparent;
60
60
  --scrollbar-thumb: var(--muted-foreground);
61
61
  --scrollbar-thumb-hover: var(--ring);
@@ -93,7 +93,6 @@ html {
93
93
  height: auto;
94
94
  -ms-overflow-style: none;
95
95
  scrollbar-width: none;
96
- scrollbar-gutter: stable both-edges;
97
96
  overflow-y: auto;
98
97
  }
99
98
 
@@ -114,7 +113,7 @@ body {
114
113
  html::-webkit-scrollbar,
115
114
  body::-webkit-scrollbar,
116
115
  ::-webkit-scrollbar {
117
- width: 4px !important;
116
+ width: var(--scrollbar-size, var(--spacing-sm)) !important;
118
117
  background: transparent !important;
119
118
  }
120
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/ui",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "A UI component library for building web interfaces.",
5
5
  "main": "dist/rettangoli-esm.min.js",
6
6
  "type": "module",
@@ -67,7 +67,7 @@ template:
67
67
  - $if field.type == "input-textarea":
68
68
  - rtgl-textarea#field${field._idx} data-field-name=${field.name} w=f rows=${field.rows} placeholder=${field.placeholder} ?disabled=${field._disabled}: null
69
69
  - $if field.type == "select":
70
- - rtgl-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled}: null
70
+ - rtgl-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled} ?searchable=${field.searchable} :searchPlaceholder=${field.searchPlaceholder} :emptySearchLabel=${field.emptySearchLabel}: null
71
71
  - $if field.type == "tag-select":
72
72
  - rtgl-tag-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} :selectedValues=${field._selectedValues} :placeholder=${field.placeholder} :addOption=${field.addOption} ?no-add=${field.noAdd} ?disabled=${field._disabled}: null
73
73
  - $if field.type == "segmented-control":
@@ -12,6 +12,50 @@ const getOptionType = (option = {}) => {
12
12
  return 'item';
13
13
  };
14
14
 
15
+ const focusSearchInput = (refs = {}) => {
16
+ const input = refs.searchInput;
17
+
18
+ if (!input) {
19
+ return;
20
+ }
21
+
22
+ if (typeof input.focus === "function") {
23
+ input.focus();
24
+ }
25
+
26
+ const innerInput = input.shadowRoot?.querySelector("input, textarea");
27
+ if (innerInput && typeof innerInput.focus === "function") {
28
+ innerInput.focus();
29
+ }
30
+ };
31
+
32
+ const focusSearchInputWhenReady = (refs = {}, remainingAttempts = 10) => {
33
+ const popover = refs.popover;
34
+
35
+ if (!popover?.hasAttribute?.("open")) {
36
+ return;
37
+ }
38
+
39
+ if (popover.hasAttribute("positioned") || remainingAttempts === 0) {
40
+ focusSearchInput(refs);
41
+ return;
42
+ }
43
+
44
+ setTimeout(() => focusSearchInputWhenReady(refs, remainingAttempts - 1), 16);
45
+ };
46
+
47
+ const refreshOpenPopover = (refs = {}) => {
48
+ refs?.popover?.refreshContent?.();
49
+ };
50
+
51
+ const getEventValue = (event = {}) => {
52
+ if (event.detail && Object.prototype.hasOwnProperty.call(event.detail, "value")) {
53
+ return event.detail.value;
54
+ }
55
+
56
+ return event.currentTarget?.value ?? "";
57
+ };
58
+
15
59
  export const handleBeforeMount = (deps) => {
16
60
  const { store, props, render } = deps;
17
61
 
@@ -47,11 +91,25 @@ export const handleOnUpdate = (deps, payload) => {
47
91
  shouldRefreshPopover = true;
48
92
  }
49
93
 
94
+ if (oldProps.searchable !== newProps.searchable) {
95
+ store.setSearchQuery?.({ query: "" });
96
+ shouldRender = true;
97
+ shouldRefreshPopover = true;
98
+ }
99
+
100
+ if (
101
+ oldProps.searchPlaceholder !== newProps.searchPlaceholder
102
+ || oldProps.emptySearchLabel !== newProps.emptySearchLabel
103
+ ) {
104
+ shouldRender = true;
105
+ shouldRefreshPopover = true;
106
+ }
107
+
50
108
  if (shouldRender) {
51
109
  render();
52
110
 
53
111
  if (shouldRefreshPopover && store.selectState?.().isOpen) {
54
- refs?.popover?.refreshContent?.();
112
+ refreshOpenPopover(refs);
55
113
  }
56
114
  }
57
115
  }
@@ -82,6 +140,10 @@ export const handleButtonClick = (deps, payload) => {
82
140
  selectedIndex
83
141
  })
84
142
  render();
143
+
144
+ if (props.searchable) {
145
+ focusSearchInputWhenReady(refs);
146
+ }
85
147
  }
86
148
 
87
149
  export const handleButtonKeyDown = (deps, payload) => {
@@ -100,6 +162,32 @@ export const handleClickOptionsPopoverOverlay = (deps) => {
100
162
  render();
101
163
  }
102
164
 
165
+ export const handleSearchInput = (deps, payload) => {
166
+ const { store, render } = deps;
167
+ const event = payload._event;
168
+
169
+ event.stopPropagation();
170
+ store.setSearchQuery({ query: getEventValue(event) });
171
+ render();
172
+ // The popover observes size changes itself. Resyncing here re-slots the
173
+ // active search field and causes Chromium to drop focus while typing.
174
+ };
175
+
176
+ export const handleSearchKeyDown = (deps, payload) => {
177
+ const { store, render } = deps;
178
+ const event = payload._event;
179
+
180
+ event.stopPropagation();
181
+
182
+ if (event.key !== "Escape") {
183
+ return;
184
+ }
185
+
186
+ event.preventDefault();
187
+ store.closeOptionsPopover({});
188
+ render();
189
+ };
190
+
103
191
  export const handleOptionClick = (deps, payload) => {
104
192
  const { render, dispatchEvent, props, store } = deps;
105
193
  if (props.disabled) return;
@@ -40,6 +40,12 @@ propsSchema:
40
40
  type: string
41
41
  disabled:
42
42
  type: boolean
43
+ searchable:
44
+ type: boolean
45
+ searchPlaceholder:
46
+ type: string
47
+ emptySearchLabel:
48
+ type: string
43
49
  w:
44
50
  type: string
45
51
  events:
@@ -15,6 +15,9 @@ const blacklistedProps = [
15
15
  "noClear",
16
16
  "addOption",
17
17
  "disabled",
18
+ "searchable",
19
+ "searchPlaceholder",
20
+ "emptySearchLabel",
18
21
  ];
19
22
 
20
23
  const stringifyProps = (props = {}) => {
@@ -54,6 +57,120 @@ const getOptionSuffixText = (option = {}) => {
54
57
  return '';
55
58
  };
56
59
 
60
+ const normalizeSearchQuery = (query) => {
61
+ if (query === undefined || query === null) {
62
+ return "";
63
+ }
64
+
65
+ return String(query).trim().toLowerCase();
66
+ };
67
+
68
+ const optionLabelMatches = (option = {}, query = "") => {
69
+ if (!query) {
70
+ return true;
71
+ }
72
+
73
+ return String(option.label ?? "").toLowerCase().includes(query);
74
+ };
75
+
76
+ const collectOptionGroups = (options = []) => {
77
+ const groups = [];
78
+ let currentGroup = {
79
+ section: null,
80
+ entries: [],
81
+ };
82
+
83
+ options.forEach((option, index) => {
84
+ const entry = {
85
+ option,
86
+ index,
87
+ type: getOptionType(option),
88
+ };
89
+
90
+ if (entry.type === "section") {
91
+ if (currentGroup.section || currentGroup.entries.length > 0) {
92
+ groups.push(currentGroup);
93
+ }
94
+
95
+ currentGroup = {
96
+ section: entry,
97
+ entries: [],
98
+ };
99
+ return;
100
+ }
101
+
102
+ currentGroup.entries.push(entry);
103
+ });
104
+
105
+ if (currentGroup.section || currentGroup.entries.length > 0) {
106
+ groups.push(currentGroup);
107
+ }
108
+
109
+ return groups;
110
+ };
111
+
112
+ const filterOptionEntriesBySearch = (options = [], rawQuery = "") => {
113
+ const query = normalizeSearchQuery(rawQuery);
114
+
115
+ if (!query) {
116
+ return options.map((option, index) => ({
117
+ option,
118
+ index,
119
+ }));
120
+ }
121
+
122
+ return collectOptionGroups(options).flatMap((group) => {
123
+ const sectionMatches = group.section ? optionLabelMatches(group.section.option, query) : false;
124
+ const itemVisibility = group.entries.map((entry) => {
125
+ if (entry.type !== "item") {
126
+ return false;
127
+ }
128
+
129
+ return sectionMatches || optionLabelMatches(entry.option, query);
130
+ });
131
+ const hasVisibleItem = itemVisibility.some(Boolean);
132
+
133
+ if (!sectionMatches && !hasVisibleItem) {
134
+ return [];
135
+ }
136
+
137
+ const visibleEntries = [];
138
+
139
+ if (group.section) {
140
+ visibleEntries.push(group.section);
141
+ }
142
+
143
+ group.entries.forEach((entry, entryIndex) => {
144
+ if (entry.type === "item") {
145
+ if (itemVisibility[entryIndex]) {
146
+ visibleEntries.push(entry);
147
+ }
148
+ return;
149
+ }
150
+
151
+ if (entry.type !== "separator") {
152
+ return;
153
+ }
154
+
155
+ const hasVisibleItemBefore = itemVisibility
156
+ .slice(0, entryIndex)
157
+ .some(Boolean);
158
+ const hasVisibleItemAfter = itemVisibility
159
+ .slice(entryIndex + 1)
160
+ .some(Boolean);
161
+
162
+ if (hasVisibleItemBefore && hasVisibleItemAfter) {
163
+ visibleEntries.push(entry);
164
+ }
165
+ });
166
+
167
+ return visibleEntries.map(({ option, index }) => ({
168
+ option,
169
+ index,
170
+ }));
171
+ });
172
+ };
173
+
57
174
  const normalizeOption = (option = {}, index, currentValue, hoveredOptionId, hasIconColumn) => {
58
175
  const type = getOptionType(option);
59
176
  const isSection = type === 'section';
@@ -116,6 +233,7 @@ export const createInitialState = () => Object.freeze({
116
233
  selectedValue: null,
117
234
  hoveredOptionId: null,
118
235
  hoveredAddOption: false,
236
+ searchQuery: "",
119
237
  });
120
238
 
121
239
  export const selectViewData = ({ state, props }) => {
@@ -139,10 +257,24 @@ export const selectViewData = ({ state, props }) => {
139
257
  }
140
258
 
141
259
  const hasIconColumn = options.some((option) => isSelectableOption(option) && hasOwnProp(option, 'icon'));
142
- const optionsWithSelection = options.map((option, index) => {
143
- return normalizeOption(option, index, currentValue, state.hoveredOptionId, hasIconColumn);
260
+ const visibleOptionEntries = filterOptionEntriesBySearch(
261
+ options,
262
+ props.searchable ? state.searchQuery : "",
263
+ );
264
+ const visibleOptionIndexes = new Set(visibleOptionEntries.map(({ index }) => index));
265
+ const renderOptions = options.map((option, index) => {
266
+ return {
267
+ ...normalizeOption(option, index, currentValue, state.hoveredOptionId, hasIconColumn),
268
+ isSearchHidden: !visibleOptionIndexes.has(index),
269
+ };
144
270
  });
145
- const selectedOptionView = optionsWithSelection.find((option) => option.isItem && option.isSelected);
271
+ const optionsWithSelection = renderOptions.filter((option) => !option.isSearchHidden);
272
+ const selectedOptionIcon = getOptionIcon(selectedOption);
273
+ const selectedOptionSuffixText = getOptionSuffixText(selectedOption);
274
+ const searchQuery = props.searchable ? state.searchQuery : "";
275
+ const hasSearchQuery = normalizeSearchQuery(searchQuery).length > 0;
276
+ const hasVisibleSelectableOptions = optionsWithSelection.some((option) => option.isItem);
277
+ const showEmptySearch = !!props.searchable && hasSearchQuery && !hasVisibleSelectableOptions;
146
278
 
147
279
  return {
148
280
  containerAttrString,
@@ -150,14 +282,15 @@ export const selectViewData = ({ state, props }) => {
150
282
  isOpen: state.isOpen,
151
283
  position: state.position,
152
284
  options: optionsWithSelection,
285
+ renderOptions,
153
286
  selectedValue: currentValue,
154
287
  selectedLabel: displayLabel,
155
288
  selectedLabelColor: isPlaceholderLabel ? "mu-fg" : "fg",
156
- selectedIcon: selectedOptionView?.icon || "",
157
- hasSelectedIcon: !!selectedOptionView?.hasIcon,
289
+ selectedIcon: selectedOptionIcon,
290
+ hasSelectedIcon: selectedOptionIcon.length > 0,
158
291
  selectedIconColor: isPlaceholderLabel ? "mu-fg" : "fg",
159
- selectedSuffixText: selectedOptionView?.suffixText || "",
160
- hasSelectedSuffixText: !!selectedOptionView?.hasSuffixText,
292
+ selectedSuffixText: selectedOptionSuffixText,
293
+ hasSelectedSuffixText: selectedOptionSuffixText.length > 0,
161
294
  selectedSuffixTextColor: "mu-fg",
162
295
  selectButtonCursor: isDisabled ? "not-allowed" : "pointer",
163
296
  selectButtonHoverBorderColor: isDisabled ? "bo" : "ac",
@@ -167,6 +300,12 @@ export const selectViewData = ({ state, props }) => {
167
300
  showAddOption: !isDisabled && !!props.addOption,
168
301
  addOptionLabel: props.addOption?.label ? `+ ${props.addOption.label}` : "+ Add",
169
302
  addOptionBgc: state.hoveredAddOption ? "ac" : "",
303
+ showSearch: !isDisabled && !!props.searchable,
304
+ searchQuery,
305
+ searchPlaceholder: props.searchPlaceholder || "Search options",
306
+ showEmptySearch,
307
+ hideEmptySearch: !showEmptySearch,
308
+ emptySearchLabel: props.emptySearchLabel || "No matching options",
170
309
  };
171
310
  };
172
311
 
@@ -182,6 +321,7 @@ export const openOptionsPopover = ({ state }, payload = {}) => {
182
321
  const { position, selectedIndex } = payload;
183
322
  state.position = position;
184
323
  state.isOpen = true;
324
+ state.searchQuery = "";
185
325
  // Set hoveredOptionId to the selected option's index if available
186
326
  if (selectedIndex !== undefined && selectedIndex !== null) {
187
327
  state.hoveredOptionId = selectedIndex;
@@ -190,11 +330,13 @@ export const openOptionsPopover = ({ state }, payload = {}) => {
190
330
 
191
331
  export const closeOptionsPopover = ({ state }) => {
192
332
  state.isOpen = false;
333
+ state.searchQuery = "";
193
334
  };
194
335
 
195
336
  export const updateSelectedValue = ({ state }, payload = {}) => {
196
337
  state.selectedValue = payload.value;
197
338
  state.isOpen = false;
339
+ state.searchQuery = "";
198
340
  };
199
341
 
200
342
  export const resetSelection = ({ state }) => {
@@ -216,3 +358,10 @@ export const clearSelectedValue = ({ state }) => {
216
358
  export const setHoveredAddOption = ({ state }, payload = {}) => {
217
359
  state.hoveredAddOption = !!payload.isHovered;
218
360
  };
361
+
362
+ export const setSearchQuery = ({ state }, payload = {}) => {
363
+ state.searchQuery = payload.query === undefined || payload.query === null
364
+ ? ""
365
+ : String(payload.query);
366
+ state.hoveredOptionId = null;
367
+ };
@@ -9,6 +9,14 @@ refs:
9
9
  eventListeners:
10
10
  click:
11
11
  handler: handleClearClick
12
+ searchInput:
13
+ eventListeners:
14
+ value-input:
15
+ handler: handleSearchInput
16
+ stopPropagation: true
17
+ keydown:
18
+ handler: handleSearchKeyDown
19
+ stopPropagation: true
12
20
  popover:
13
21
  eventListeners:
14
22
  close:
@@ -46,13 +54,16 @@ template:
46
54
  - $if showClear:
47
55
  - rtgl-svg#clearButton svg=x wh=16 c=mu-fg cur=pointer data-testid="select-clear-button": null
48
56
  - rtgl-svg svg=chevronDown wh=16 c=mu-fg: null
49
- - rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=rs content-wh=300 content-g=xs content-sv=true content-pv=sm:
50
- - $for option, i in options:
57
+ - rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=rs content-wh=300 content-g=xs content-sv=true ?content-hsb=${showSearch} content-pv=sm:
58
+ - $if showSearch:
59
+ - rtgl-view w=f mt=md mh=md mb=xs:
60
+ - 'rtgl-input#searchInput w=f s=sm value=${searchQuery} placeholder="${searchPlaceholder}"': null
61
+ - $for option, i in renderOptions:
51
62
  - $if option.isSection:
52
- - rtgl-view w=f p=md:
63
+ - rtgl-view w=f p=md ?hide=${option.isSearchHidden}:
53
64
  - rtgl-text s=xs c=mu-fg: ${option.label}
54
65
  $elif option.isItem:
55
- - rtgl-view#option${option.index} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId}:
66
+ - rtgl-view#option${option.index} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId} ?hide=${option.isSearchHidden}:
56
67
  - rtgl-view d=h av=c w=f g=md:
57
68
  - rtgl-view d=h av=c g=md w=1fg:
58
69
  - $if option.hasIconSlot:
@@ -64,7 +75,10 @@ template:
64
75
  - $if option.hasSuffixText:
65
76
  - rtgl-text s=xs c=${option.suffixTextColor} ta=e: ${option.suffixText}
66
77
  $elif option.isSeparator:
67
- - rtgl-view w=f h=1 bgc=mu mv=sm: null
78
+ - rtgl-view w=f h=1 bgc=mu mv=sm ?hide=${option.isSearchHidden}: null
79
+ - $if showSearch:
80
+ - rtgl-view w=f ph=lg pv=md ?hide=${hideEmptySearch}:
81
+ - rtgl-text s=sm c=mu-fg: ${emptySearchLabel}
68
82
  - $if showAddOption:
69
83
  - rtgl-view w=f bw=xs bc=mu bwt=sm: null
70
84
  - rtgl-view#optionAdd w=f ph=lg pv=md cur=pointer bgc=${addOptionBgc} data-testid="select-add-option":
@@ -185,13 +185,13 @@ class RettangoliGridElement extends HTMLElement {
185
185
 
186
186
  if (scrollHorizontal && scrollVertical) {
187
187
  this._styles[size].overflow = "auto";
188
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
188
+ this._styles[size]["scrollbar-gutter"] = "stable";
189
189
  } else if (scrollHorizontal) {
190
190
  this._styles[size]["overflow-x"] = "auto";
191
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
191
+ this._styles[size]["scrollbar-gutter"] = "stable";
192
192
  } else if (scrollVertical) {
193
193
  this._styles[size]["overflow-y"] = "auto";
194
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
194
+ this._styles[size]["scrollbar-gutter"] = "stable";
195
195
  }
196
196
 
197
197
  if (overflow === "hidden") {
@@ -188,6 +188,7 @@ class RettangoliPopoverElement extends HTMLElement {
188
188
  "content-wh",
189
189
  "content-g",
190
190
  "content-sv",
191
+ "content-hsb",
191
192
  "content-ph",
192
193
  "content-pv",
193
194
  "content-bgc",
@@ -375,6 +376,7 @@ class RettangoliPopoverElement extends HTMLElement {
375
376
  ["content-wh", "wh"],
376
377
  ["content-g", "g"],
377
378
  ["content-sv", "sv"],
379
+ ["content-hsb", "hsb"],
378
380
  ];
379
381
 
380
382
  for (const [sourceAttr, targetAttr] of attrs) {
@@ -314,15 +314,15 @@ class RettangoliViewElement extends HTMLElement {
314
314
 
315
315
  if (scrollHorizontal && scrollVertical) {
316
316
  this._styles[size]["overflow"] = "auto";
317
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
317
+ this._styles[size]["scrollbar-gutter"] = "stable";
318
318
  this._styles[size]["flex-wrap"] = "nowrap";
319
319
  } else if (scrollHorizontal) {
320
320
  this._styles[size]["overflow-x"] = "auto";
321
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
321
+ this._styles[size]["scrollbar-gutter"] = "stable";
322
322
  this._styles[size]["flex-wrap"] = "nowrap";
323
323
  } else if (scrollVertical) {
324
324
  this._styles[size]["overflow-y"] = "auto";
325
- this._styles[size]["scrollbar-gutter"] = "stable both-edges";
325
+ this._styles[size]["scrollbar-gutter"] = "stable";
326
326
  this._styles[size]["flex-wrap"] = "nowrap";
327
327
  }
328
328
 
@@ -21,14 +21,14 @@ export default css`
21
21
  :host([sh]),
22
22
  :host([sv]) {
23
23
  -ms-overflow-style: auto;
24
- scrollbar-gutter: stable both-edges;
24
+ scrollbar-gutter: stable;
25
25
  scrollbar-width: thin;
26
26
  scrollbar-color: var(--scrollbar-thumb, var(--muted-foreground)) var(--scrollbar-track, transparent);
27
27
  }
28
28
  :host([sh])::-webkit-scrollbar,
29
29
  :host([sv])::-webkit-scrollbar {
30
- width: var(--scrollbar-size, 6px);
31
- height: var(--scrollbar-size, 6px);
30
+ width: var(--scrollbar-size, var(--spacing-sm));
31
+ height: var(--scrollbar-size, var(--spacing-sm));
32
32
  background: var(--scrollbar-track, transparent);
33
33
  }
34
34
  :host([sh])::-webkit-scrollbar-track,
@@ -55,7 +55,7 @@ table {
55
55
 
56
56
  :root {
57
57
  --width-stretch: 100%;
58
- --scrollbar-size: 6px;
58
+ --scrollbar-size: var(--spacing-sm);
59
59
  --scrollbar-track: transparent;
60
60
  --scrollbar-thumb: var(--muted-foreground);
61
61
  --scrollbar-thumb-hover: var(--ring);
@@ -93,7 +93,6 @@ html {
93
93
  height: auto;
94
94
  -ms-overflow-style: none;
95
95
  scrollbar-width: none;
96
- scrollbar-gutter: stable both-edges;
97
96
  overflow-y: auto;
98
97
  }
99
98
 
@@ -114,7 +113,7 @@ body {
114
113
  html::-webkit-scrollbar,
115
114
  body::-webkit-scrollbar,
116
115
  ::-webkit-scrollbar {
117
- width: 4px !important;
116
+ width: var(--scrollbar-size, var(--spacing-sm)) !important;
118
117
  background: transparent !important;
119
118
  }
120
119