@internetarchive/collection-browser 3.4.1-alpha-webdev7761.2 → 3.4.1-alpha-webdev7761.4

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.
Files changed (208) hide show
  1. package/dist/src/app-root.js +19 -28
  2. package/dist/src/app-root.js.map +1 -1
  3. package/dist/src/collection-browser.d.ts +14 -10
  4. package/dist/src/collection-browser.js +870 -886
  5. package/dist/src/collection-browser.js.map +1 -1
  6. package/dist/src/collection-facets/facet-row.js +3 -4
  7. package/dist/src/collection-facets/facet-row.js.map +1 -1
  8. package/dist/src/collection-facets/models.js.map +1 -1
  9. package/dist/src/collection-facets/more-facets-content.js +145 -156
  10. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  11. package/dist/src/collection-facets/more-facets-pagination.js +6 -10
  12. package/dist/src/collection-facets/more-facets-pagination.js.map +1 -1
  13. package/dist/src/collection-facets/smart-facets/heuristics/wikidata/wikidata-heuristic.js +16 -21
  14. package/dist/src/collection-facets/smart-facets/heuristics/wikidata/wikidata-heuristic.js.map +1 -1
  15. package/dist/src/collection-facets/smart-facets/smart-facet-bar.js +7 -10
  16. package/dist/src/collection-facets/smart-facets/smart-facet-bar.js.map +1 -1
  17. package/dist/src/collection-facets/smart-facets/smart-facet-button.js +3 -2
  18. package/dist/src/collection-facets/smart-facets/smart-facet-button.js.map +1 -1
  19. package/dist/src/collection-facets/smart-facets/smart-facet-dropdown.js +9 -11
  20. package/dist/src/collection-facets/smart-facets/smart-facet-dropdown.js.map +1 -1
  21. package/dist/src/collection-facets/smart-facets/smart-facet-heuristics.js +7 -7
  22. package/dist/src/collection-facets/smart-facets/smart-facet-heuristics.js.map +1 -1
  23. package/dist/src/collection-facets/toggle-switch.js +4 -6
  24. package/dist/src/collection-facets/toggle-switch.js.map +1 -1
  25. package/dist/src/collection-facets.js +34 -50
  26. package/dist/src/collection-facets.js.map +1 -1
  27. package/dist/src/combo-box/caret-closed.js +5 -11
  28. package/dist/src/combo-box/caret-closed.js.map +1 -1
  29. package/dist/src/combo-box/caret-open.js +5 -11
  30. package/dist/src/combo-box/caret-open.js.map +1 -1
  31. package/dist/src/combo-box/clear.d.ts +2 -0
  32. package/dist/src/combo-box/clear.js +11 -0
  33. package/dist/src/combo-box/clear.js.map +1 -0
  34. package/dist/src/combo-box/ia-combo-box.d.ts +40 -9
  35. package/dist/src/combo-box/ia-combo-box.js +363 -272
  36. package/dist/src/combo-box/ia-combo-box.js.map +1 -1
  37. package/dist/src/combo-box/models.d.ts +14 -0
  38. package/dist/src/combo-box/models.js +32 -1
  39. package/dist/src/combo-box/models.js.map +1 -1
  40. package/dist/src/data-source/collection-browser-data-source.js +35 -47
  41. package/dist/src/data-source/collection-browser-data-source.js.map +1 -1
  42. package/dist/src/empty-placeholder.js +19 -18
  43. package/dist/src/empty-placeholder.js.map +1 -1
  44. package/dist/src/expanded-date-picker.js +6 -10
  45. package/dist/src/expanded-date-picker.js.map +1 -1
  46. package/dist/src/language-code-handler/language-code-handler.js +2 -2
  47. package/dist/src/language-code-handler/language-code-handler.js.map +1 -1
  48. package/dist/src/manage/manage-bar.js +86 -92
  49. package/dist/src/manage/manage-bar.js.map +1 -1
  50. package/dist/src/manage/remove-items-modal-content.js +2 -2
  51. package/dist/src/manage/remove-items-modal-content.js.map +1 -1
  52. package/dist/src/models.js +36 -40
  53. package/dist/src/models.js.map +1 -1
  54. package/dist/src/restoration-state-handler.js +9 -10
  55. package/dist/src/restoration-state-handler.js.map +1 -1
  56. package/dist/src/sort-filter-bar/alpha-bar.js +9 -14
  57. package/dist/src/sort-filter-bar/alpha-bar.js.map +1 -1
  58. package/dist/src/sort-filter-bar/sort-filter-bar.js +14 -24
  59. package/dist/src/sort-filter-bar/sort-filter-bar.js.map +1 -1
  60. package/dist/src/tiles/base-tile-component.js +1 -2
  61. package/dist/src/tiles/base-tile-component.js.map +1 -1
  62. package/dist/src/tiles/grid/account-tile.js +36 -38
  63. package/dist/src/tiles/grid/account-tile.js.map +1 -1
  64. package/dist/src/tiles/grid/collection-tile.js +79 -82
  65. package/dist/src/tiles/grid/collection-tile.js.map +1 -1
  66. package/dist/src/tiles/grid/item-tile.js +154 -164
  67. package/dist/src/tiles/grid/item-tile.js.map +1 -1
  68. package/dist/src/tiles/grid/search-tile.js +42 -43
  69. package/dist/src/tiles/grid/search-tile.js.map +1 -1
  70. package/dist/src/tiles/grid/styles/tile-grid-shared-styles.js +119 -119
  71. package/dist/src/tiles/grid/styles/tile-grid-shared-styles.js.map +1 -1
  72. package/dist/src/tiles/grid/tile-stats.js +2 -3
  73. package/dist/src/tiles/grid/tile-stats.js.map +1 -1
  74. package/dist/src/tiles/hover/hover-pane-controller.js +42 -49
  75. package/dist/src/tiles/hover/hover-pane-controller.js.map +1 -1
  76. package/dist/src/tiles/hover/tile-hover-pane.js +113 -114
  77. package/dist/src/tiles/hover/tile-hover-pane.js.map +1 -1
  78. package/dist/src/tiles/image-block.js +5 -8
  79. package/dist/src/tiles/image-block.js.map +1 -1
  80. package/dist/src/tiles/item-image.js +12 -19
  81. package/dist/src/tiles/item-image.js.map +1 -1
  82. package/dist/src/tiles/list/tile-list-compact.js +114 -122
  83. package/dist/src/tiles/list/tile-list-compact.js.map +1 -1
  84. package/dist/src/tiles/list/tile-list.js +326 -347
  85. package/dist/src/tiles/list/tile-list.js.map +1 -1
  86. package/dist/src/tiles/overlay/icon-overlay.js +1 -2
  87. package/dist/src/tiles/overlay/icon-overlay.js.map +1 -1
  88. package/dist/src/tiles/overlay/text-overlay.js +2 -4
  89. package/dist/src/tiles/overlay/text-overlay.js.map +1 -1
  90. package/dist/src/tiles/text-snippet-block.js +2 -4
  91. package/dist/src/tiles/text-snippet-block.js.map +1 -1
  92. package/dist/src/tiles/tile-dispatcher.js +233 -241
  93. package/dist/src/tiles/tile-dispatcher.js.map +1 -1
  94. package/dist/src/tiles/tile-display-value-provider.js +5 -9
  95. package/dist/src/tiles/tile-display-value-provider.js.map +1 -1
  96. package/dist/src/tiles/tile-mediatype-icon.js +12 -19
  97. package/dist/src/tiles/tile-mediatype-icon.js.map +1 -1
  98. package/dist/src/utils/collapse-repeated-quotes.js +1 -1
  99. package/dist/src/utils/collapse-repeated-quotes.js.map +1 -1
  100. package/dist/src/utils/facet-utils.js +3 -5
  101. package/dist/src/utils/facet-utils.js.map +1 -1
  102. package/dist/src/utils/format-count.js +10 -10
  103. package/dist/src/utils/format-count.js.map +1 -1
  104. package/dist/src/utils/format-date.js.map +1 -1
  105. package/dist/src/utils/resolve-mediatype.js +2 -3
  106. package/dist/src/utils/resolve-mediatype.js.map +1 -1
  107. package/dist/test/collection-browser.test.js +131 -185
  108. package/dist/test/collection-browser.test.js.map +1 -1
  109. package/dist/test/collection-facets/facet-row.test.js +60 -75
  110. package/dist/test/collection-facets/facet-row.test.js.map +1 -1
  111. package/dist/test/collection-facets/facets-template.test.js +17 -23
  112. package/dist/test/collection-facets/facets-template.test.js.map +1 -1
  113. package/dist/test/collection-facets/more-facets-content.test.js +22 -32
  114. package/dist/test/collection-facets/more-facets-content.test.js.map +1 -1
  115. package/dist/test/collection-facets/more-facets-pagination.test.js +16 -22
  116. package/dist/test/collection-facets/more-facets-pagination.test.js.map +1 -1
  117. package/dist/test/collection-facets/toggle-switch.test.js +22 -19
  118. package/dist/test/collection-facets/toggle-switch.test.js.map +1 -1
  119. package/dist/test/collection-facets.test.js +80 -97
  120. package/dist/test/collection-facets.test.js.map +1 -1
  121. package/dist/test/empty-placeholder.test.js +11 -17
  122. package/dist/test/empty-placeholder.test.js.map +1 -1
  123. package/dist/test/expanded-date-picker.test.js +8 -14
  124. package/dist/test/expanded-date-picker.test.js.map +1 -1
  125. package/dist/test/icon-overlay.test.js +7 -6
  126. package/dist/test/icon-overlay.test.js.map +1 -1
  127. package/dist/test/image-block.test.js +16 -26
  128. package/dist/test/image-block.test.js.map +1 -1
  129. package/dist/test/item-image.test.js +23 -32
  130. package/dist/test/item-image.test.js.map +1 -1
  131. package/dist/test/manage/manage-bar.test.js +21 -33
  132. package/dist/test/manage/manage-bar.test.js.map +1 -1
  133. package/dist/test/manage/remove-items-modal-content.test.js +10 -15
  134. package/dist/test/manage/remove-items-modal-content.test.js.map +1 -1
  135. package/dist/test/mocks/mock-search-service.js +2 -3
  136. package/dist/test/mocks/mock-search-service.js.map +1 -1
  137. package/dist/test/restoration-state-handler.test.js +13 -21
  138. package/dist/test/restoration-state-handler.test.js.map +1 -1
  139. package/dist/test/review-block.test.js +16 -18
  140. package/dist/test/review-block.test.js.map +1 -1
  141. package/dist/test/sort-filter-bar/alpha-bar-tooltip.test.js +2 -3
  142. package/dist/test/sort-filter-bar/alpha-bar-tooltip.test.js.map +1 -1
  143. package/dist/test/sort-filter-bar/alpha-bar.test.js +18 -24
  144. package/dist/test/sort-filter-bar/alpha-bar.test.js.map +1 -1
  145. package/dist/test/sort-filter-bar/sort-filter-bar.test.js +178 -180
  146. package/dist/test/sort-filter-bar/sort-filter-bar.test.js.map +1 -1
  147. package/dist/test/text-overlay.test.js +16 -15
  148. package/dist/test/text-overlay.test.js.map +1 -1
  149. package/dist/test/text-snippet-block.test.js +14 -19
  150. package/dist/test/text-snippet-block.test.js.map +1 -1
  151. package/dist/test/tile-stats.test.js +73 -34
  152. package/dist/test/tile-stats.test.js.map +1 -1
  153. package/dist/test/tiles/grid/account-tile.test.js +25 -25
  154. package/dist/test/tiles/grid/account-tile.test.js.map +1 -1
  155. package/dist/test/tiles/grid/collection-tile.test.js +13 -19
  156. package/dist/test/tiles/grid/collection-tile.test.js.map +1 -1
  157. package/dist/test/tiles/grid/item-tile.test.js +141 -168
  158. package/dist/test/tiles/grid/item-tile.test.js.map +1 -1
  159. package/dist/test/tiles/grid/search-tile.test.js +9 -13
  160. package/dist/test/tiles/grid/search-tile.test.js.map +1 -1
  161. package/dist/test/tiles/hover/hover-pane-controller.test.js +50 -62
  162. package/dist/test/tiles/hover/hover-pane-controller.test.js.map +1 -1
  163. package/dist/test/tiles/hover/tile-hover-pane.test.js +12 -16
  164. package/dist/test/tiles/hover/tile-hover-pane.test.js.map +1 -1
  165. package/dist/test/tiles/list/tile-list-compact.test.js +104 -118
  166. package/dist/test/tiles/list/tile-list-compact.test.js.map +1 -1
  167. package/dist/test/tiles/list/tile-list.test.js +202 -231
  168. package/dist/test/tiles/list/tile-list.test.js.map +1 -1
  169. package/dist/test/tiles/tile-dispatcher.test.js +97 -110
  170. package/dist/test/tiles/tile-dispatcher.test.js.map +1 -1
  171. package/dist/test/tiles/tile-mediatype-icon.test.js +12 -24
  172. package/dist/test/tiles/tile-mediatype-icon.test.js.map +1 -1
  173. package/dist/test/utils/format-date.test.js.map +1 -1
  174. package/index.html +1 -1
  175. package/package.json +5 -3
  176. package/src/collection-browser.ts +3060 -3030
  177. package/src/collection-facets/models.ts +10 -10
  178. package/src/collection-facets/more-facets-content.ts +639 -639
  179. package/src/collection-facets.ts +1 -1
  180. package/src/combo-box/caret-closed.ts +5 -11
  181. package/src/combo-box/caret-open.ts +5 -11
  182. package/src/combo-box/clear.ts +11 -0
  183. package/src/combo-box/ia-combo-box.ts +1288 -1180
  184. package/src/combo-box/models.ts +31 -1
  185. package/src/manage/manage-bar.ts +247 -247
  186. package/src/restoration-state-handler.ts +5 -1
  187. package/src/tiles/base-tile-component.ts +65 -65
  188. package/src/tiles/grid/account-tile.ts +113 -113
  189. package/src/tiles/grid/collection-tile.ts +163 -163
  190. package/src/tiles/grid/item-tile.ts +340 -340
  191. package/src/tiles/grid/search-tile.ts +90 -90
  192. package/src/tiles/grid/styles/tile-grid-shared-styles.ts +130 -130
  193. package/src/tiles/hover/hover-pane-controller.ts +613 -613
  194. package/src/tiles/hover/tile-hover-pane.ts +184 -184
  195. package/src/tiles/list/tile-list-compact.ts +239 -239
  196. package/src/tiles/list/tile-list.ts +700 -700
  197. package/src/tiles/tile-dispatcher.ts +517 -517
  198. package/src/utils/format-date.ts +62 -62
  199. package/test/collection-facets/facet-row.test.ts +375 -375
  200. package/test/collection-facets.test.ts +928 -928
  201. package/test/tiles/grid/item-tile.test.ts +520 -520
  202. package/test/tiles/hover/hover-pane-controller.test.ts +418 -418
  203. package/test/tiles/list/tile-list-compact.test.ts +282 -282
  204. package/test/tiles/list/tile-list.test.ts +552 -552
  205. package/test/tiles/tile-dispatcher.test.ts +283 -283
  206. package/test/utils/format-date.test.ts +89 -89
  207. package/tsconfig.json +8 -3
  208. package/vite.config.ts +29 -22
@@ -1,1180 +1,1288 @@
1
- import {
2
- html,
3
- LitElement,
4
- nothing,
5
- TemplateResult,
6
- CSSResultGroup,
7
- css,
8
- PropertyValues,
9
- } from 'lit';
10
- import { customElement, property, state, query } from 'lit/decorators.js';
11
- import { classMap } from 'lit/directives/class-map.js';
12
- import { ifDefined } from 'lit/directives/if-defined.js';
13
- import { live } from 'lit/directives/live.js';
14
- import { when } from 'lit/directives/when.js';
15
- import { msg } from '@lit/localize';
16
-
17
- import {
18
- hasAnyOf,
19
- type IAComboBoxBehavior,
20
- type IAComboBoxFilterFunction,
21
- type IAComboBoxFilterOption,
22
- type IAComboBoxFilterPreset,
23
- type IAComboBoxOption,
24
- } from './models';
25
-
26
- import caretClosed from './caret-closed';
27
- import caretOpen from './caret-open';
28
-
29
- /**
30
- * Tests whether the given `haystack` string has the given `needle` as a subsequence.
31
- * Returns `true` if the characters of `needle` appear in order within `haystack`,
32
- * regardless of whether they are contiguous. Returns `false` otherwise.
33
- *
34
- * E.g., `ace` is a subsequence of `archive` (but not a contiguous substring).
35
- *
36
- * Note: The empty string is a subsequence of any string, including itself.
37
- *
38
- * @param needle The potential subsequence to check for inside `haystack`.
39
- * @param haystack The string to be tested for containing the `needle` subsequence.
40
- * @returns Whether `haystack` has `needle` as a subsequence.
41
- */
42
- const isSubsequence = (needle: string, haystack: string): boolean => {
43
- const needleLen = needle.length;
44
- const haystackLen = haystack.length;
45
- if (needleLen === 0) return true;
46
-
47
- let needleIdx = 0;
48
- let haystackIdx = 0;
49
- while (haystackIdx < haystackLen) {
50
- if (haystack[haystackIdx] === needle[needleIdx]) needleIdx += 1;
51
- if (needleIdx >= needleLen) return true;
52
- haystackIdx += 1;
53
- }
54
- return false;
55
- };
56
-
57
- /**
58
- * Map from filter preset keys to their associated filtering function.
59
- * @see {@linkcode IAComboBox.filter}
60
- */
61
- const FILTER_PRESETS: Record<IAComboBoxFilterPreset, IAComboBoxFilterFunction> =
62
- {
63
- all: () => true,
64
- prefix: (filterText, optionText) => optionText.startsWith(filterText),
65
- suffix: (filterText, optionText) => optionText.endsWith(filterText),
66
- substring: (filterText, optionText) => optionText.includes(filterText),
67
- subsequence: isSubsequence,
68
- };
69
-
70
- const DEFAULT_BEHAVIOR: IAComboBoxBehavior = 'list';
71
- const DEFAULT_FILTER_PRESET: IAComboBoxFilterPreset = 'substring';
72
-
73
- const STRING_IDENTITY_FN = (str: string): string => str;
74
- const STRING_LOWER_CASE_FN = (str: string): string => str.toLocaleLowerCase();
75
-
76
- /**
77
- * A flexible component combining the features of a dropdown menu and a text input,
78
- * allowing users to either select from a predefined list of options or type
79
- * freeform text to filter down & find specific options.
80
- */
81
- @customElement('ia-combo-box')
82
- export class IAComboBox extends LitElement {
83
- /**
84
- * Array of options representing values that this combo box can take.
85
- *
86
- * If this combo box's `behavior` property is `select-only` or `list`, these options
87
- * represent _all_ of the allowed values that the user may choose from.
88
- *
89
- * If this combo box's `behavior` property is `freeform`, this array simply represents
90
- * a non-exhaustive list of _suggested_ values, which the user may either choose from
91
- * or enter their own value.
92
- * @see {@linkcode IAComboBox.behavior}
93
- */
94
- @property({ type: Array }) options: IAComboBoxOption[] = [];
95
-
96
- /**
97
- * Optional placeholder text to display in the combo box when no option is selected.
98
- */
99
- @property({ type: String }) placeholder?: string;
100
-
101
- /**
102
- * What style of behavior to use for the combo box.
103
- *
104
- * Currently, the options are
105
- * - `select-only` (behaving similar to a `<select>`, disallowing text entry and only
106
- * allowing selection from the predefined, unfiltered options)
107
- * - `list` (the default, allowing text entry to filter the dropdown list, but still
108
- * requiring values to be set from the predefined options)
109
- * - `freeform` (allows text entry to filter the dropdown list, and also allows setting
110
- * custom values not included in the list)
111
- */
112
- @property({ type: String }) behavior: IAComboBoxBehavior = DEFAULT_BEHAVIOR;
113
-
114
- /**
115
- * Maximum number of options to include in the autocomplete menu when filtering.
116
- *
117
- * Default value is `Infinity`, presenting all matching options regardless of count.
118
- */
119
- @property({ type: Number, attribute: 'max-autocomplete-entries' })
120
- maxAutocompleteEntries = Number.POSITIVE_INFINITY;
121
-
122
- /**
123
- * Specifies how the options should be filtered when text is entered into the combo box.
124
- * Has no effect if `behavior` is `select-only`, as the component is not text-editable.
125
- *
126
- * Default is `substring`, showing only options whose `text` contains the value entered
127
- * as a substring at any position. The possible preset options are:
128
- * - `all`: Does not filter the dropdown entries. They will always all be shown (up to
129
- * the limit specified by `maxAutocompleteEntries`).
130
- * - `prefix`: Only includes options whose text _starts_ with the entered value.
131
- * - `suffix`: Only includes options whose text _ends_ with the entered value.
132
- * - `substring`: Only includes options whose text contains the entered value as a
133
- * contiguous substring.
134
- * - `subsequence`: Only shows options whose text has the characters of the entered
135
- * value in order, though they do not need to be contiguous.
136
- * E.g., `ace` is a subsequence of `archive` (but not a substring).
137
- *
138
- * If custom filtering outside of these presets is desired, an arbitrary filtering function
139
- * may instead be bound to this property directly.
140
- *
141
- * @see {@link IAComboBox.caseSensitive} to enable case-sensitive filtering.
142
- */
143
- @property({ type: String }) filter: IAComboBoxFilterOption =
144
- DEFAULT_FILTER_PRESET;
145
-
146
- /**
147
- * Whether filtering should be case-sensitive. Default is `false`, performing only
148
- * case-insensitive filtering.
149
- */
150
- @property({ type: Boolean, reflect: true, attribute: 'case-sensitive' })
151
- caseSensitive = false;
152
-
153
- /**
154
- * Whether the filtered options should be listed in lexicographically-sorted order.
155
- * Default is `false`, displaying them in the same order as the provided options array.
156
- */
157
- @property({ type: Boolean, reflect: true }) sort = false;
158
-
159
- /**
160
- * Whether the combo box allows multiple options to be selected at once.
161
- * Default is `false`, allowing only a single option to be selected.
162
- */
163
- // @property({ type: Boolean, reflect: true }) multiple = false; // TODO
164
-
165
- /**
166
- * Whether pressing the Up/Down arrow keys should wrap focus back to the text input box
167
- * while focus is already on the first/last option, respectively.
168
- *
169
- * Default is `false`, doing nothing upon such key presses.
170
- */
171
- @property({ type: Boolean, reflect: true, attribute: 'wrap-arrow-keys' })
172
- wrapArrowKeys = false;
173
-
174
- /**
175
- * Whether the options list should remain open after an option is selected.
176
- *
177
- * Default is `false`, closing the options list when a selection is made.
178
- */
179
- @property({ type: Boolean, reflect: true, attribute: 'stay-open' })
180
- stayOpen = false;
181
-
182
- /**
183
- * Whether the combo box's option menu is currently expanded. Default is `false`.
184
- */
185
- @property({ type: Boolean, reflect: true }) open = false;
186
-
187
- /**
188
- * Whether the combo box should be rendered in its disabled state, preventing all
189
- * interactions such as editing the text field or opening the options menu.
190
- * Default is `false`.
191
- */
192
- @property({ type: Boolean, reflect: true }) disabled = false;
193
-
194
- /**
195
- * If used within a form, whether this combo box is required to have a value selected
196
- * before the form may be submitted. Default is `false`.
197
- */
198
- @property({ type: Boolean, reflect: true }) required = false;
199
-
200
- /**
201
- * For `select-only` or `list` behavior, this value is the ID of the selected option,
202
- * or `null` if there is no selection.
203
- *
204
- * For `freeform` behavior, this may be any string entered into the text field, and
205
- * need not correspond to an option ID.
206
- */
207
- @property({ type: String }) value: string | null = null;
208
-
209
- /**
210
- * Whether any part of this component currently has focus.
211
- */
212
- @state() private hasFocus = false;
213
-
214
- /**
215
- * Which option in the dropdown menu is currently highlighted by the user. Not to be
216
- * confused with the _selected_ option that has been committed, this field only
217
- * represents the user's current navigation state within the menu, which they may
218
- * subsequently select by, e.g., hitting Enter.
219
- */
220
- @state() private highlightedOption: IAComboBoxOption | null = null;
221
-
222
- /**
223
- * The text than has been entered into the text input box.
224
- */
225
- @state() private enteredText: string = '';
226
-
227
- /**
228
- * The text that we are currently using the filter the dropdown menu options.
229
- */
230
- @state() private filterText: string = '';
231
-
232
- @query('#main-widget-row') private mainWidgetRow!: HTMLDivElement;
233
-
234
- @query('#text-input') private textInput!: HTMLInputElement;
235
-
236
- @query('#caret-button') private caretButton!: HTMLInputElement;
237
-
238
- @query('#options-list') private optionsList!: HTMLUListElement;
239
-
240
- static formAssociated = true;
241
-
242
- private internals: ElementInternals;
243
-
244
- /**
245
- * Set when part of the component blurs, and cleared when any part of it is focused.
246
- * If still set on the next tick after a blur event, this serves as a signal that focus
247
- * has moved away from the component as a whole and should be closed.
248
- */
249
- private losingFocus = false;
250
-
251
- /**
252
- * A cache of the mapping from option IDs to their corresponding options, so that
253
- * we can more efficiently look up options by ID.
254
- */
255
- private optionsByID: Map<string, IAComboBoxOption> = new Map();
256
-
257
- /**
258
- * A cache of the values against which each option should be filtered, to minimize
259
- * the work needed at filter time to handle case-insensitivity etc.
260
- */
261
- private optionFilteringValues: Map<IAComboBoxOption, string> = new Map();
262
-
263
- /**
264
- * A cache of the current set of options that is pre-sorted if the component's
265
- * `sort` flag is set, or as provided otherwise. Just ensures we don't have to
266
- * sort all the options again every time we filter them.
267
- */
268
- private optionsRespectingSortFlag: IAComboBoxOption[] = [];
269
-
270
- /**
271
- * A cache of the current set of filtered options, so that we don't have to
272
- * recalculate it unnecessarily whenever the component is opened/closed/etc.
273
- */
274
- private filteredOptions: IAComboBoxOption[] = [];
275
-
276
- constructor() {
277
- super();
278
- this.internals = this.attachInternals();
279
- }
280
-
281
- render(): TemplateResult | typeof nothing {
282
- return html`
283
- <div
284
- id="container"
285
- class=${classMap({ focused: this.hasFocus })}
286
- part="container"
287
- >
288
- ${this.labelTemplate}
289
- <div
290
- id="main-widget-row"
291
- class=${classMap({ disabled: this.disabled })}
292
- part="combo-box"
293
- >
294
- ${this.textInputTemplate} ${this.caretButtonTemplate}
295
- </div>
296
- ${this.optionsListTemplate}
297
- </div>
298
- `;
299
- }
300
-
301
- willUpdate(changed: PropertyValues): void {
302
- if (changed.has('options') || changed.has('caseSensitive')) {
303
- // Need to update the cached values against which our filters are matched
304
- this.rebuildOptionFilteringValues();
305
- }
306
-
307
- if (changed.has('options')) {
308
- // Need to update the cached mapping of IDs to options
309
- this.rebuildOptionIDMap();
310
- }
311
-
312
- if (changed.has('options') || changed.has('sort')) {
313
- // Sort the options upfront if needed
314
- this.rebuildSortedOptions();
315
- }
316
-
317
- if (
318
- hasAnyOf(changed, [
319
- 'options',
320
- 'behavior',
321
- 'maxAutocompleteEntries',
322
- 'filter',
323
- 'filterText',
324
- 'caseSensitive',
325
- 'sort',
326
- ])
327
- ) {
328
- // If anything about the options or how they are filtered has changed, we need to
329
- // update our cache of the filtered options
330
- this.rebuildFilteredOptions();
331
- }
332
-
333
- if (changed.has('value')) {
334
- this.handleValueChanged();
335
- }
336
-
337
- if (changed.has('open')) {
338
- if (this.open) {
339
- // Highlight selection on open, if possible
340
- if (this.value) this.setHighlightedOption(this.selectedOption);
341
- } else {
342
- // Clear highlight on close
343
- this.setHighlightedOption(null);
344
- }
345
- }
346
-
347
- if (changed.has('required')) {
348
- this.updateFormValidity();
349
- }
350
- }
351
-
352
- updated(changed: PropertyValues): void {
353
- if (changed.has('open')) {
354
- if (this.open) {
355
- this.positionOptionsMenu();
356
- this.optionsList.showPopover?.();
357
- this.optionsList.classList.add('visible');
358
- } else {
359
- this.optionsList.hidePopover?.();
360
- this.optionsList.classList.remove('visible');
361
- }
362
- }
363
- }
364
-
365
- //
366
- // TEMPLATES
367
- //
368
-
369
- /**
370
- * Template for the main label for the combo box.
371
- *
372
- * Uses the contents of the default (unnamed) slot as the label text.
373
- */
374
- private get labelTemplate(): TemplateResult {
375
- return html`<label id="label" for="text-input"><slot></slot></label>`;
376
- }
377
-
378
- /**
379
- * Template for the text input field that users can edit to filter the available
380
- * options or (if freeform behavior) to enter a custom value.
381
- */
382
- private get textInputTemplate(): TemplateResult {
383
- const textInputClasses = classMap({
384
- editable: this.behavior !== 'select-only',
385
- });
386
-
387
- return html`
388
- <input
389
- type="text"
390
- id="text-input"
391
- class=${textInputClasses}
392
- .value=${live(this.enteredText)}
393
- placeholder=${ifDefined(this.placeholder)}
394
- part="text-input"
395
- role="combobox"
396
- autocomplete="off"
397
- aria-autocomplete="list"
398
- aria-controls="options-list"
399
- aria-expanded=${this.open}
400
- aria-activedescendant=${ifDefined(this.highlightedOption?.id)}
401
- ?disabled=${this.disabled}
402
- ?required=${this.required}
403
- @click=${this.handleComboBoxClick}
404
- @keydown=${this.handleComboBoxKeyDown}
405
- @input=${this.handleTextBoxInput}
406
- @focus=${this.handleFocus}
407
- @blur=${this.handleBlur}
408
- />
409
- `;
410
- }
411
-
412
- /**
413
- * Template for the caret open/closed icons to show beside the text input.
414
- * The icons are wrapped in named slots to allow consumers to override them.
415
- */
416
- private get caretTemplate(): TemplateResult {
417
- return html`
418
- <slot name="caret-closed" ?hidden=${this.open}> ${caretClosed} </slot>
419
- <slot name="caret-open" ?hidden=${!this.open}> ${caretOpen} </slot>
420
- `;
421
- }
422
-
423
- /**
424
- * Template for the caret button to be shown beside the text input.
425
- */
426
- private get caretButtonTemplate(): TemplateResult {
427
- return html`
428
- <button
429
- type="button"
430
- id="caret-button"
431
- part="caret-button"
432
- tabindex="-1"
433
- aria-controls="options-list"
434
- aria-expanded=${this.open}
435
- ?disabled=${this.disabled}
436
- @click=${this.handleComboBoxClick}
437
- @keydown=${this.handleComboBoxKeyDown}
438
- @focus=${this.handleFocus}
439
- @blur=${this.handleBlur}
440
- >
441
- ${this.caretTemplate}
442
- </button>
443
- `;
444
- }
445
-
446
- /**
447
- * Template for the options list that is displayed when the combo box is open.
448
- */
449
- private get optionsListTemplate(): TemplateResult {
450
- return html`
451
- <ul
452
- id="options-list"
453
- part="options-list"
454
- role="listbox"
455
- popover
456
- ?hidden=${!this.open}
457
- @focus=${this.handleFocus}
458
- @blur=${this.handleBlur}
459
- >
460
- <slot name="options-list-top"></slot>
461
- ${when(this.open, () => this.optionTemplates)}
462
- <slot name="options-list-bottom"></slot>
463
- </ul>
464
- `;
465
- }
466
-
467
- /**
468
- * Array of templates for all of the filtered options to be shown in the options menu.
469
- */
470
- private get optionTemplates(): TemplateResult[] {
471
- // If there are no options matching the filter, just show a message saying as much.
472
- if (this.filteredOptions.length === 0 && this.maxAutocompleteEntries > 0) {
473
- return [this.emptyOptionsTemplate];
474
- }
475
-
476
- // Otherwise build a list item for each filtered option
477
- return this.filteredOptions.map((opt) => {
478
- const optionClasses = classMap({
479
- option: true,
480
- highlight: opt === this.highlightedOption,
481
- });
482
-
483
- return html`
484
- <li
485
- id=${opt.id}
486
- class=${optionClasses}
487
- part="option"
488
- tabindex="-1"
489
- @pointerenter=${this.handleOptionPointerEnter}
490
- @pointermove=${this.handleOptionPointerMove}
491
- @click=${this.handleOptionClick}
492
- @focus=${this.handleFocus}
493
- @blur=${this.handleBlur}
494
- >
495
- ${opt.content ?? opt.text}
496
- </li>
497
- `;
498
- });
499
- }
500
-
501
- /**
502
- * Info message shown in the listbox when no options match the entered text.
503
- * Renders within an `empty-options` named slot so that its content can be customized.
504
- */
505
- private get emptyOptionsTemplate(): TemplateResult {
506
- return html`
507
- <li id="empty-options" part="empty-options">
508
- <slot name="empty-options">${msg('No matching options')}</slot>
509
- </li>
510
- `;
511
- }
512
-
513
- //
514
- // EVENT HANDLERS & MUTATORS
515
- //
516
-
517
- /**
518
- * Handler for when the pointer device enters an option element in the dropdown.
519
- */
520
- private handleOptionPointerEnter(e: PointerEvent): void {
521
- this.handleOptionPointerMove(e);
522
- }
523
-
524
- /**
525
- * Handler for when the pointer device is moved within an option in the dropdown.
526
- */
527
- private handleOptionPointerMove(e: PointerEvent): void {
528
- const target = e.target as HTMLLIElement;
529
- const option = this.getOptionFor(target.id);
530
- if (option) this.setHighlightedOption(option);
531
- }
532
-
533
- /**
534
- * Handler for when the user clicks on an option in the dropdown.
535
- */
536
- private handleOptionClick(e: PointerEvent): void {
537
- const target = e.target as HTMLLIElement;
538
- const option = this.getOptionFor(target.id);
539
- if (option) {
540
- this.setSelectedOption(option.id);
541
- this.closeOptionsMenu();
542
- }
543
- }
544
-
545
- /**
546
- * Handler for `keydown` events on various special keys.
547
- */
548
- private handleComboBoxKeyDown(e: KeyboardEvent): void {
549
- switch (e.key) {
550
- case 'Enter':
551
- this.handleEnterPressed();
552
- break;
553
- case 'Escape':
554
- this.handleEscapePressed();
555
- break;
556
- case 'ArrowUp':
557
- if (e.altKey) {
558
- this.handleAltUpArrowPressed();
559
- } else {
560
- this.handleUpArrowPressed();
561
- }
562
- break;
563
- case 'ArrowDown':
564
- if (e.altKey) {
565
- this.handleAltDownArrowPressed();
566
- } else {
567
- this.handleDownArrowPressed();
568
- }
569
- break;
570
- case 'Tab':
571
- this.textInput.focus();
572
- return;
573
- default:
574
- // Do nothing and allow propagation otherwise
575
- return;
576
- }
577
-
578
- e.stopPropagation();
579
- e.preventDefault();
580
- }
581
-
582
- /**
583
- * Handler for `input` events in the text input box.
584
- */
585
- private async handleTextBoxInput(): Promise<void> {
586
- this.enteredText = this.textInput.value;
587
- this.setFilterText(this.textInput.value);
588
- this.openOptionsMenu();
589
-
590
- await this.updateComplete;
591
- this.highlightFirstOption();
592
- }
593
-
594
- /**
595
- * Handler for when the Enter key is pressed
596
- */
597
- private handleEnterPressed(): void {
598
- // Just open the options menu if it's currently closed
599
- if (!this.open) {
600
- this.openOptionsMenu();
601
- return;
602
- }
603
-
604
- if (this.highlightedOption) {
605
- // If an option is highlighted, select it
606
- this.setSelectedOption(this.highlightedOption.id);
607
- } else if (this.behavior === 'freeform') {
608
- // Otherwise, in the freeform behavior we just accept the current value regardless
609
- this.setValue(this.enteredText);
610
- }
611
-
612
- // Close the options list if needed
613
- if (!this.stayOpen) this.open = false;
614
- }
615
-
616
- /**
617
- * Handler for when the Escape key is pressed
618
- */
619
- private handleEscapePressed(): void {
620
- // Close the options menu if it's currently open
621
- if (this.open) {
622
- this.closeOptionsMenu();
623
- return;
624
- }
625
-
626
- // Otherwise, deselect any selected option & clear any filter text
627
- this.clearSelectedOption();
628
- }
629
-
630
- /**
631
- * Handler for when the Up Arrow key is pressed (without modifiers).
632
- * @see {@linkcode handleAltUpArrowPressed()} for behavior under the Alt modifier.
633
- */
634
- private handleUpArrowPressed(): void {
635
- if (!this.open) this.openOptionsMenu();
636
- this.highlightPreviousOption();
637
- }
638
-
639
- /**
640
- * Handler for when the Down Arrow key is pressed (without modifiers).
641
- * @see {@linkcode handleAltDownArrowPressed()} for behavior under the Alt modifier.
642
- */
643
- private handleDownArrowPressed(): void {
644
- if (!this.open) this.openOptionsMenu();
645
- this.highlightNextOption();
646
- }
647
-
648
- /**
649
- * Handler for when the Alt + Up Arrow key combo is pressed
650
- */
651
- private handleAltUpArrowPressed(): void {
652
- this.closeOptionsMenu();
653
- }
654
-
655
- /**
656
- * Handler for when the Alt + Down Arrow key combo is pressed
657
- */
658
- private handleAltDownArrowPressed(): void {
659
- this.openOptionsMenu();
660
- }
661
-
662
- /**
663
- * Handler for clicks on the combo box input field or caret button.
664
- */
665
- private handleComboBoxClick(): void {
666
- this.toggleOptionsMenu();
667
- }
668
-
669
- /**
670
- * Handler for when any part of the combo box receives focus.
671
- */
672
- private handleFocus(): void {
673
- if (this.behavior === 'select-only') {
674
- this.caretButton.focus();
675
- } else {
676
- this.textInput.focus();
677
- }
678
- this.hasFocus = true;
679
- this.losingFocus = false;
680
- }
681
-
682
- /**
683
- * Handler for when any part of the combo box loses focus.
684
- */
685
- private handleBlur(): void {
686
- this.hasFocus = false;
687
- this.losingFocus = true;
688
- setTimeout(() => {
689
- if (this.losingFocus && !this.shadowRoot?.activeElement) {
690
- this.losingFocus = false;
691
- this.closeOptionsMenu();
692
- if (this.behavior === 'freeform') this.setValue(this.enteredText);
693
- }
694
- }, 0);
695
- }
696
-
697
- /**
698
- * Handler for when the `value` of this component is changed externally
699
- */
700
- private handleValueChanged(): void {
701
- if (this.behavior !== 'freeform' && this.value !== null) {
702
- // The value must correspond to a valid option or null
703
- if (!this.getOptionFor(this.value)) this.clearSelectedOption();
704
- }
705
- }
706
-
707
- /**
708
- * Highlights the first option shown in the filtered list.
709
- */
710
- private highlightFirstOption(): void {
711
- this.setHighlightedOption(this.firstFilteredOption);
712
- }
713
-
714
- /**
715
- * Highlights the last option shown in the filtered list.
716
- */
717
- private highlightLastOption(): void {
718
- this.setHighlightedOption(this.lastFilteredOption);
719
- }
720
-
721
- /**
722
- * Highlights the option before the currently highlighted one in the list, or the last one
723
- * if none is highlighted.
724
- */
725
- private highlightPreviousOption(): void {
726
- const { filteredOptions, lastFilteredIndex } = this;
727
-
728
- // If no option is highlighted yet, highlight the last one
729
- if (!this.highlightedOption) {
730
- this.highlightLastOption();
731
- return;
732
- }
733
-
734
- // Otherwise, move to the previous option (wrapping if needed)
735
- const { highlightedIndex } = this;
736
- const previousIndex =
737
- this.wrapArrowKeys && highlightedIndex === 0
738
- ? lastFilteredIndex // Wrap around to the end
739
- : Math.max(highlightedIndex - 1, 0);
740
- this.setHighlightedOption(filteredOptions[previousIndex]);
741
- }
742
-
743
- /**
744
- * Highlights the option after the currently highlighted one in the list, or the first one
745
- * if none is highlighted.
746
- */
747
- private highlightNextOption(): void {
748
- const { filteredOptions, lastFilteredIndex } = this;
749
-
750
- // If no option is highlighted yet, highlight the first one
751
- if (!this.highlightedOption) {
752
- this.highlightFirstOption();
753
- return;
754
- }
755
-
756
- // Otherwise, move to the next option (wrapping if needed)
757
- const { highlightedIndex } = this;
758
- const nextIndex =
759
- this.wrapArrowKeys && highlightedIndex === lastFilteredIndex
760
- ? 0 // Wrap back to the start
761
- : Math.min(highlightedIndex + 1, lastFilteredIndex);
762
- this.setHighlightedOption(filteredOptions[nextIndex]);
763
- }
764
-
765
- /**
766
- * Highlights the given option and scrolls it into view if necessary.
767
- * If `null` is provided, any current highlight will be cleared.
768
- */
769
- private async setHighlightedOption(
770
- option: IAComboBoxOption | null,
771
- ): Promise<void> {
772
- this.highlightedOption = option;
773
- await this.updateComplete;
774
-
775
- const { optionsList, highlightedElement } = this;
776
- if (!highlightedElement) return;
777
-
778
- // TODO: Not ideal as this will trigger a reflow...
779
- // But may not be an issue in practice given the highlight isn't changing in a hot loop.
780
- // If we have issues with this let's see if we can hook up an IntersectionObserver.
781
- const elmtRect = highlightedElement.getBoundingClientRect();
782
- const listRect = optionsList.getBoundingClientRect();
783
- if (elmtRect.top < listRect.top || elmtRect.bottom > listRect.bottom) {
784
- highlightedElement.scrollIntoView({ block: 'nearest' });
785
- }
786
- }
787
-
788
- /**
789
- * Changes this combo box's selected option to the one matching the specified ID.
790
- *
791
- * Throws a `RangeError` if given an ID that does not correspond to any option
792
- * held by this combo box.
793
- */
794
- setSelectedOption(id: string): void {
795
- const option = this.getOptionFor(id);
796
- if (!option) throw new RangeError('Unknown option ID');
797
-
798
- const prevValue = this.value;
799
- this.value = option.id;
800
- this.internals.setFormValue(this.value);
801
- this.setTextValue(option.text);
802
- if (this.value !== prevValue) this.emitChangeEvent();
803
-
804
- // Invoke the option's select callback if defined
805
- option.onSelected?.(option);
806
- }
807
-
808
- /**
809
- * Clears any currently selected option from this combo box, setting it to null
810
- * and clearing any value in the text box.
811
- */
812
- clearSelectedOption(): void {
813
- const prevValue = this.value;
814
- this.value = null;
815
- this.internals.setFormValue(this.value);
816
- this.setTextValue('');
817
- if (this.value !== prevValue) this.emitChangeEvent();
818
- }
819
-
820
- /**
821
- * Set this combo box's value to the given string if possible.
822
- *
823
- * In `freeform` behavior, this always succeeds.
824
- *
825
- * In other behavior modes, this method is identical to `setSelectedOption`,
826
- * interpreting the input as an option ID and throwing an error if no such
827
- * option exists.
828
- *
829
- * @see {@linkcode IAComboBox.setSelectedOption}
830
- */
831
- setValue(value: string): void {
832
- if (this.behavior === 'freeform') {
833
- const prevValue = this.value;
834
- this.value = value;
835
- this.internals.setFormValue(this.value);
836
- this.setTextValue(value);
837
- if (this.value !== prevValue) this.emitChangeEvent();
838
- } else {
839
- this.setSelectedOption(value);
840
- }
841
- }
842
-
843
- /**
844
- * Changes the value of the text input box, and updates the filter accordingly.
845
- */
846
- private setTextValue(value: string): void {
847
- this.textInput.value = value;
848
- this.enteredText = value;
849
- this.setFilterText(value);
850
- }
851
-
852
- openOptionsMenu(): void {
853
- this.open = true;
854
- this.emitToggleEvent();
855
- }
856
-
857
- closeOptionsMenu(): void {
858
- this.open = false;
859
- this.emitToggleEvent();
860
- }
861
-
862
- toggleOptionsMenu(): void {
863
- this.open = !this.open;
864
- this.emitToggleEvent();
865
- }
866
-
867
- private updateFormValidity(): void {
868
- if (this.required && !this.value) {
869
- this.internals.setValidity(
870
- { valueMissing: true },
871
- msg('A value is required'),
872
- );
873
- } else {
874
- // All good
875
- this.internals.setValidity({});
876
- }
877
- }
878
-
879
- private emitChangeEvent(): void {
880
- this.dispatchEvent(
881
- new CustomEvent<string | null>('change', {
882
- detail: this.value,
883
- }),
884
- );
885
- }
886
-
887
- private emitToggleEvent(): void {
888
- this.dispatchEvent(
889
- new CustomEvent<boolean>('toggle', {
890
- detail: this.open,
891
- }),
892
- );
893
- }
894
-
895
- //
896
- // HELPERS
897
- //
898
-
899
- /**
900
- * Sets the size and position of the options menu to match the size and position of
901
- * the combo box widget. Prefers to position below the main widget, but will flip
902
- * to the top if needed & if there's more room above.
903
- */
904
- private positionOptionsMenu(): void {
905
- const { mainWidgetRow, optionsList } = this;
906
- const mainWidgetRect = mainWidgetRow.getBoundingClientRect();
907
-
908
- const { innerHeight, scrollX, scrollY } = window;
909
- const usableHeightAbove = mainWidgetRect.top;
910
- const usableHeightBelow = innerHeight - mainWidgetRect.bottom;
911
-
912
- // We still want to respect any CSS var specified by the consumer
913
- const maxHeightVar = 'var(--comboBoxListMaxHeight, 250px)';
914
-
915
- const optionsListStyles: Record<string, string> = {
916
- top: `${mainWidgetRect.bottom + scrollY}px`,
917
- left: `${mainWidgetRect.left + scrollX}px`,
918
- width: `var(--comboBoxListWidth, ${mainWidgetRect.width}px)`,
919
- maxHeight: `min(${usableHeightBelow}px, ${maxHeightVar})`,
920
- };
921
-
922
- Object.assign(optionsList.style, optionsListStyles);
923
-
924
- // Wait a tick for it to appear, then check if we should flip it upwards instead
925
- setTimeout(() => {
926
- const listRect = optionsList.getBoundingClientRect();
927
- const overflowingViewport = listRect.bottom >= innerHeight;
928
- const moreSpaceAbove = usableHeightAbove > usableHeightBelow;
929
- if (overflowingViewport && moreSpaceAbove) {
930
- optionsList.style.top = 'auto';
931
- optionsList.style.bottom = `${innerHeight - mainWidgetRect.top - scrollY}px`;
932
- optionsList.style.maxHeight = `min(${usableHeightAbove}px, ${maxHeightVar})`;
933
- }
934
- }, 0);
935
- }
936
-
937
- /**
938
- * A function to transform option & filter text based on the combo box's
939
- * current case sensitivity.
940
- */
941
- private get caseTransform(): (text: string) => string {
942
- return this.caseSensitive ? STRING_IDENTITY_FN : STRING_LOWER_CASE_FN;
943
- }
944
-
945
- /**
946
- * Sets the current filter text based on the provided string. The resulting filter
947
- * text might not exactly match the provided value, depending on the current case
948
- * sensitivity.
949
- */
950
- private setFilterText(baseFilterText: string): void {
951
- const { caseTransform } = this;
952
- this.filterText = caseTransform(baseFilterText);
953
- }
954
-
955
- /**
956
- * Returns the combo box option having the given ID, or null if none exists.
957
- */
958
- private getOptionFor(id: string): IAComboBoxOption | null {
959
- return this.optionsByID.get(id) ?? null;
960
- }
961
-
962
- /**
963
- * Clears any previously-cached mapping of IDs to options, and rebuilds the
964
- * map based on the current set of options.
965
- */
966
- private rebuildOptionIDMap(): void {
967
- this.optionsByID.clear();
968
- for (const option of this.options) {
969
- this.optionsByID.set(option.id, option);
970
- }
971
- }
972
-
973
- /**
974
- * Applies any required sort to the options and caches the result to be used
975
- * at filter/display time.
976
- */
977
- private rebuildSortedOptions(): void {
978
- if (this.sort) {
979
- this.optionsRespectingSortFlag = this.options.sort((a, b) => {
980
- const aValue = this.optionFilteringValues.get(a) as string;
981
- const bValue = this.optionFilteringValues.get(b) as string;
982
- return aValue.localeCompare(bValue);
983
- });
984
- } else {
985
- this.optionsRespectingSortFlag = this.options;
986
- }
987
- }
988
-
989
- /**
990
- * Clears any previously-cached option filtering values, and rebuilds the
991
- * map based on the current component properties.
992
- */
993
- private rebuildOptionFilteringValues(): void {
994
- this.optionFilteringValues.clear();
995
-
996
- const { caseTransform } = this;
997
- for (const option of this.options) {
998
- const filteringValue = caseTransform(option.text);
999
- this.optionFilteringValues.set(option, filteringValue);
1000
- }
1001
- }
1002
-
1003
- /**
1004
- * Returns the filtered array of options by applying the specified filter function
1005
- * with the current filter text, up to the limit specified by `maxAutocompleteEntries`.
1006
- */
1007
- private rebuildFilteredOptions(): void {
1008
- // We don't want to filter the results in select-only mode
1009
- const resolvedFilterOption =
1010
- this.behavior === 'select-only' ? 'all' : this.filter;
1011
-
1012
- const filterFn =
1013
- typeof resolvedFilterOption === 'string'
1014
- ? FILTER_PRESETS[resolvedFilterOption]
1015
- : resolvedFilterOption;
1016
-
1017
- const filtered = this.optionsRespectingSortFlag
1018
- .filter((opt) => {
1019
- const optionFilteringValue = this.optionFilteringValues.get(opt);
1020
- if (!optionFilteringValue) return false;
1021
-
1022
- return filterFn(this.filterText, optionFilteringValue, opt);
1023
- })
1024
- .slice(0, this.maxAutocompleteEntries);
1025
-
1026
- this.filteredOptions = filtered;
1027
- }
1028
-
1029
- /**
1030
- * The first option appearing in the filtered list, or null if there are none.
1031
- */
1032
- private get firstFilteredOption(): IAComboBoxOption | null {
1033
- return this.filteredOptions[0] ?? null;
1034
- }
1035
-
1036
- /**
1037
- * The last option appearing in the filtered list, or null if there are none.
1038
- */
1039
- private get lastFilteredOption(): IAComboBoxOption | null {
1040
- return this.filteredOptions[this.lastFilteredIndex] ?? null;
1041
- }
1042
-
1043
- /**
1044
- * The index of the last filtered option, or -1 if no options match the filter.
1045
- */
1046
- private get lastFilteredIndex(): number {
1047
- return this.filteredOptions.length - 1;
1048
- }
1049
-
1050
- /**
1051
- * The IAComboBoxOption that is currently selected, or null if none is selected.
1052
- */
1053
- get selectedOption(): IAComboBoxOption | null {
1054
- if (this.value == null) return null;
1055
- return this.getOptionFor(this.value);
1056
- }
1057
-
1058
- /**
1059
- * The index of the currently highlighted option in the filtered list, or -1 if
1060
- * no option is currently highlighted.
1061
- */
1062
- private get highlightedIndex(): number {
1063
- if (!this.highlightedOption) return -1;
1064
- return this.filteredOptions.indexOf(this.highlightedOption);
1065
- }
1066
-
1067
- /**
1068
- * The HTML element representing the currently-highlighted option, or null if
1069
- * no option is highlighted.
1070
- */
1071
- private get highlightedElement(): HTMLElement | null {
1072
- if (!this.highlightedOption) return null;
1073
- return this.shadowRoot!.getElementById(this.highlightedOption.id);
1074
- }
1075
-
1076
- static get styles(): CSSResultGroup {
1077
- return css`
1078
- #label {
1079
- display: block;
1080
- width: fit-content;
1081
- }
1082
-
1083
- #main-widget-row {
1084
- display: inline-flex;
1085
- align-items: stretch;
1086
- flex-wrap: nowrap;
1087
- background: white;
1088
- border: 1px solid black;
1089
- }
1090
-
1091
- .focused #main-widget-row {
1092
- outline: black auto 1px;
1093
- outline-offset: 3px;
1094
- }
1095
-
1096
- #text-input {
1097
- appearance: none;
1098
- background: transparent;
1099
- border: none;
1100
- padding: var(--comboBoxPadding, 5px);
1101
- width: 100%;
1102
- font-size: inherit;
1103
- outline: none;
1104
- }
1105
-
1106
- #text-input:not(.editable) {
1107
- cursor: pointer;
1108
- }
1109
-
1110
- #caret-button {
1111
- display: inline-flex;
1112
- align-items: center;
1113
- appearance: none;
1114
- background: transparent;
1115
- border: none;
1116
- padding: var(--comboBoxPadding, 5px);
1117
- outline: none;
1118
- cursor: pointer;
1119
- }
1120
-
1121
- #options-list {
1122
- position: absolute;
1123
- list-style-type: none;
1124
- margin: 1px 0 0;
1125
- border: none;
1126
- padding: 0;
1127
- background: white;
1128
- max-height: 400px;
1129
- box-shadow: 0 0 1px 1px #ddd;
1130
- opacity: 0;
1131
- transition: opacity 0.125s ease;
1132
- }
1133
-
1134
- #options-list.visible {
1135
- opacity: 1;
1136
- }
1137
-
1138
- #empty-options {
1139
- padding: 5px;
1140
- color: #606060;
1141
- font-style: italic;
1142
- text-align: center;
1143
- }
1144
-
1145
- .caret {
1146
- width: 14px;
1147
- height: 14px;
1148
- }
1149
-
1150
- .option {
1151
- padding: 5px;
1152
- cursor: pointer;
1153
- }
1154
-
1155
- .highlight {
1156
- background-color: #dbe0ff;
1157
- }
1158
-
1159
- .disabled,
1160
- .disabled * {
1161
- cursor: not-allowed !important;
1162
- }
1163
-
1164
- .sr-only {
1165
- position: absolute !important;
1166
- width: 1px !important;
1167
- height: 1px !important;
1168
- margin: -1px !important;
1169
- padding: 0 !important;
1170
- border: 0 !important;
1171
- overflow: hidden !important;
1172
- white-space: nowrap !important;
1173
- clip: rect(1px, 1px, 1px, 1px) !important;
1174
- -webkit-clip-path: inset(50%) !important;
1175
- clip-path: inset(50%) !important;
1176
- user-select: none !important;
1177
- }
1178
- `;
1179
- }
1180
- }
1
+ import {
2
+ html,
3
+ LitElement,
4
+ nothing,
5
+ TemplateResult,
6
+ CSSResultGroup,
7
+ css,
8
+ PropertyValues,
9
+ } from 'lit';
10
+ import { customElement, property, state, query } from 'lit/decorators.js';
11
+ import { classMap } from 'lit/directives/class-map.js';
12
+ import { ifDefined } from 'lit/directives/if-defined.js';
13
+ import { live } from 'lit/directives/live.js';
14
+ import { when } from 'lit/directives/when.js';
15
+ import { msg } from '@lit/localize';
16
+
17
+ import {
18
+ hasAnyOf,
19
+ isSubsequence,
20
+ type IAComboBoxBehavior,
21
+ type IAComboBoxFilterFunction,
22
+ type IAComboBoxFilterOption,
23
+ type IAComboBoxFilterPreset,
24
+ type IAComboBoxOption,
25
+ } from './models';
26
+
27
+ import caretClosedIcon from './caret-closed';
28
+ import caretOpenIcon from './caret-open';
29
+ import clearIcon from './clear';
30
+
31
+ /**
32
+ * Map from filter preset keys to their associated filtering function.
33
+ * @see {@linkcode IAComboBox.filter}
34
+ */
35
+ const FILTER_PRESETS: Record<IAComboBoxFilterPreset, IAComboBoxFilterFunction> =
36
+ {
37
+ all: () => true,
38
+ prefix: (filterText, optionText) => optionText.startsWith(filterText),
39
+ suffix: (filterText, optionText) => optionText.endsWith(filterText),
40
+ substring: (filterText, optionText) => optionText.includes(filterText),
41
+ subsequence: isSubsequence,
42
+ };
43
+
44
+ const DEFAULT_BEHAVIOR: IAComboBoxBehavior = 'list';
45
+ const DEFAULT_FILTER_PRESET: IAComboBoxFilterPreset = 'substring';
46
+
47
+ const STRING_IDENTITY_FN = (str: string): string => str;
48
+ const STRING_LOWER_CASE_FN = (str: string): string => str.toLocaleLowerCase();
49
+
50
+ /**
51
+ * A flexible component combining the features of a dropdown menu and a text input,
52
+ * allowing users to either select from a predefined list of options or type
53
+ * freeform text to filter down & find specific options.
54
+ */
55
+ @customElement('ia-combo-box')
56
+ export class IAComboBox extends LitElement {
57
+ /**
58
+ * Array of options representing values that this combo box can take.
59
+ *
60
+ * If this combo box's `behavior` property is `select-only` or `list`, these options
61
+ * represent _all_ of the allowed values that the user may choose from.
62
+ *
63
+ * If this combo box's `behavior` property is `freeform`, this array simply represents
64
+ * a non-exhaustive list of _suggested_ values, which the user may either choose from
65
+ * or enter their own value.
66
+ * @see {@linkcode IAComboBox.behavior}
67
+ */
68
+ @property({ type: Array }) options: IAComboBoxOption[] = [];
69
+
70
+ /**
71
+ * Optional placeholder text to display in the combo box when no option is selected.
72
+ */
73
+ @property({ type: String }) placeholder?: string;
74
+
75
+ /**
76
+ * What style of behavior to use for the combo box.
77
+ *
78
+ * Currently, the options are
79
+ * - `select-only` (behaving similar to a `<select>`, disallowing text entry and only
80
+ * allowing selection from the predefined, unfiltered options)
81
+ * - `list` (the default, allowing text entry to filter the dropdown list, but still
82
+ * requiring values to be set from the predefined options)
83
+ * - `freeform` (allows text entry to filter the dropdown list, and also allows setting
84
+ * custom values not included in the list)
85
+ */
86
+ @property({ type: String }) behavior: IAComboBoxBehavior = DEFAULT_BEHAVIOR;
87
+
88
+ /**
89
+ * Maximum number of options to include in the autocomplete menu when filtering.
90
+ *
91
+ * Default value is `Infinity`, presenting all matching options regardless of count.
92
+ */
93
+ @property({ type: Number, attribute: 'max-autocomplete-entries' })
94
+ maxAutocompleteEntries = Number.POSITIVE_INFINITY;
95
+
96
+ /**
97
+ * Specifies how the options should be filtered when text is entered into the combo box.
98
+ * Has no effect if `behavior` is `select-only`, as the component is not text-editable.
99
+ *
100
+ * Default is `substring`, showing only options whose `text` contains the value entered
101
+ * as a substring at any position. The possible preset options are:
102
+ * - `all`: Does not filter the dropdown entries. They will always all be shown (up to
103
+ * the limit specified by `maxAutocompleteEntries`).
104
+ * - `prefix`: Only includes options whose text _starts_ with the entered value.
105
+ * - `suffix`: Only includes options whose text _ends_ with the entered value.
106
+ * - `substring`: Only includes options whose text contains the entered value as a
107
+ * contiguous substring.
108
+ * - `subsequence`: Only shows options whose text has the characters of the entered
109
+ * value in order, though they do not need to be contiguous.
110
+ * E.g., `ace` is a subsequence of `archive` (but not a substring).
111
+ *
112
+ * If custom filtering outside of these presets is desired, an arbitrary filtering function
113
+ * may instead be bound to this property directly.
114
+ *
115
+ * @see {@link IAComboBox.caseSensitive} to enable case-sensitive filtering.
116
+ */
117
+ @property({ type: String }) filter: IAComboBoxFilterOption =
118
+ DEFAULT_FILTER_PRESET;
119
+
120
+ /**
121
+ * Whether filtering should be case-sensitive. Default is `false`, performing only
122
+ * case-insensitive filtering.
123
+ */
124
+ @property({ type: Boolean, reflect: true, attribute: 'case-sensitive' })
125
+ caseSensitive = false;
126
+
127
+ /**
128
+ * Whether the filtered options should be listed in lexicographically-sorted order,
129
+ * respecting the current `caseSensitive` setting.
130
+ * Default is `false`, displaying them in the same order as the provided options array.
131
+ */
132
+ @property({ type: Boolean, reflect: true }) sort = false;
133
+
134
+ /**
135
+ * Whether the combo box allows multiple options to be selected at once.
136
+ * Default is `false`, allowing only a single option to be selected.
137
+ */
138
+ // @property({ type: Boolean, reflect: true }) multiple = false; // TODO
139
+
140
+ /**
141
+ * Whether pressing the Up/Down arrow keys should wrap focus back to the text input box
142
+ * while focus is already on the first/last option, respectively.
143
+ *
144
+ * Default is `false`, doing nothing upon such key presses.
145
+ */
146
+ @property({ type: Boolean, reflect: true, attribute: 'wrap-arrow-keys' })
147
+ wrapArrowKeys = false;
148
+
149
+ /**
150
+ * Whether the options list should remain open after an option is selected.
151
+ *
152
+ * Default is `false`, closing the options list when a selection is made.
153
+ */
154
+ @property({ type: Boolean, reflect: true, attribute: 'stay-open' })
155
+ stayOpen = false;
156
+
157
+ /**
158
+ * Whether the combo box shows a clear button when a value is selected.
159
+ * Default is `false`.
160
+ */
161
+ @property({ type: Boolean, reflect: true }) clearable = false;
162
+
163
+ /**
164
+ * Whether the combo box's option menu is currently expanded. Default is `false`.
165
+ */
166
+ @property({ type: Boolean, reflect: true }) open = false;
167
+
168
+ /**
169
+ * Whether the combo box should be rendered in its disabled state, preventing all
170
+ * interactions such as editing the text field or opening the options menu.
171
+ * Default is `false`.
172
+ */
173
+ @property({ type: Boolean, reflect: true }) disabled = false;
174
+
175
+ /**
176
+ * If used within a form, whether this combo box is required to have a value selected
177
+ * before the form may be submitted. Default is `false`.
178
+ */
179
+ @property({ type: Boolean, reflect: true }) required = false;
180
+
181
+ /**
182
+ * For `select-only` or `list` behavior, this value is the ID of the selected option,
183
+ * or `null` if there is no selection.
184
+ *
185
+ * For `freeform` behavior, this may be any string entered into the text field, and
186
+ * need not correspond to an option ID.
187
+ */
188
+ @property({ type: String }) value: string | null = null;
189
+
190
+ /**
191
+ * Whether any part of this component currently has focus.
192
+ */
193
+ @state() private hasFocus = false;
194
+
195
+ /**
196
+ * Which option in the dropdown menu is currently highlighted by the user. Not to be
197
+ * confused with the _selected_ option that has been committed, this field only
198
+ * represents the user's current navigation state within the menu, which they may
199
+ * subsequently select by, e.g., hitting Enter.
200
+ */
201
+ @state() private highlightedOption: IAComboBoxOption | null = null;
202
+
203
+ /**
204
+ * The text than has been entered into the text input box.
205
+ */
206
+ @state() private enteredText: string = '';
207
+
208
+ /**
209
+ * The text that we are currently using the filter the dropdown menu options.
210
+ */
211
+ @state() private filterText: string = '';
212
+
213
+ @query('#main-widget-row') private mainWidgetRow!: HTMLDivElement;
214
+
215
+ @query('#text-input') private textInput!: HTMLInputElement;
216
+
217
+ @query('#options-list') private optionsList!: HTMLUListElement;
218
+
219
+ static formAssociated = true;
220
+
221
+ private internals: ElementInternals;
222
+
223
+ /**
224
+ * Set when part of the component blurs, and cleared when any part of it is focused.
225
+ * If still set on the next tick after a blur event, this serves as a signal that focus
226
+ * has moved away from the component as a whole and should be closed.
227
+ */
228
+ private losingFocus = false;
229
+
230
+ /**
231
+ * A cache of the mapping from option IDs to their corresponding options, so that
232
+ * we can more efficiently look up options by ID.
233
+ */
234
+ private optionsByID: Map<string, IAComboBoxOption> = new Map();
235
+
236
+ /**
237
+ * A cache of the values against which each option should be filtered, to minimize
238
+ * the work needed at filter time to handle case-insensitivity etc.
239
+ */
240
+ private optionFilteringValues: Map<IAComboBoxOption, string> = new Map();
241
+
242
+ /**
243
+ * A cache of the current set of options that is pre-sorted if the component's
244
+ * `sort` flag is set, or as provided otherwise. Just ensures we don't have to
245
+ * sort all the options again every time we filter them.
246
+ */
247
+ private optionsRespectingSortFlag: IAComboBoxOption[] = [];
248
+
249
+ /**
250
+ * A cache of the current set of filtered options, so that we don't have to
251
+ * recalculate it unnecessarily whenever the component is opened/closed/etc.
252
+ */
253
+ private filteredOptions: IAComboBoxOption[] = [];
254
+
255
+ constructor() {
256
+ super();
257
+ this.internals = this.attachInternals();
258
+ }
259
+
260
+ render(): TemplateResult | typeof nothing {
261
+ const mainWidgetClasses = classMap({
262
+ disabled: this.disabled,
263
+ focused: this.hasFocus,
264
+ });
265
+
266
+ return html`
267
+ <div id="container" part="container">
268
+ ${this.labelTemplate}
269
+ <div id="main-widget-row" class=${mainWidgetClasses} part="combo-box">
270
+ ${this.textInputTemplate}
271
+ ${this.clearable ? this.clearButtonTemplate : nothing}
272
+ ${this.caretButtonTemplate}
273
+ </div>
274
+ ${this.optionsListTemplate}
275
+ </div>
276
+ `;
277
+ }
278
+
279
+ willUpdate(changed: PropertyValues): void {
280
+ if (changed.has('options') || changed.has('caseSensitive')) {
281
+ // Need to update the cached values against which our filters are matched
282
+ this.rebuildOptionFilteringValues();
283
+ }
284
+
285
+ if (changed.has('options')) {
286
+ // Need to update the cached mapping of IDs to options
287
+ this.rebuildOptionIDMap();
288
+ }
289
+
290
+ if (changed.has('options') || changed.has('sort')) {
291
+ // Sort the options upfront if needed
292
+ this.rebuildSortedOptions();
293
+ }
294
+
295
+ if (
296
+ hasAnyOf(changed, [
297
+ 'options',
298
+ 'behavior',
299
+ 'maxAutocompleteEntries',
300
+ 'filter',
301
+ 'filterText',
302
+ 'caseSensitive',
303
+ 'sort',
304
+ ])
305
+ ) {
306
+ // If anything about the options or how they are filtered has changed, we need to
307
+ // update our cache of the filtered options
308
+ this.rebuildFilteredOptions();
309
+ }
310
+
311
+ if (changed.has('value')) {
312
+ this.handleValueChanged();
313
+ }
314
+
315
+ if (changed.has('open')) {
316
+ if (this.open) {
317
+ // Highlight selection on open, if possible
318
+ if (this.value) this.setHighlightedOption(this.selectedOption);
319
+ } else {
320
+ // Clear highlight on close
321
+ this.setHighlightedOption(null);
322
+ }
323
+ }
324
+
325
+ if (changed.has('required')) {
326
+ this.updateFormValidity();
327
+ }
328
+ }
329
+
330
+ updated(changed: PropertyValues): void {
331
+ if (changed.has('open')) {
332
+ if (this.open) {
333
+ this.positionOptionsMenu();
334
+ this.optionsList.showPopover?.();
335
+ this.optionsList.classList.add('visible');
336
+ } else {
337
+ this.optionsList.hidePopover?.();
338
+ this.optionsList.classList.remove('visible');
339
+ }
340
+ }
341
+ }
342
+
343
+ //
344
+ // TEMPLATES
345
+ //
346
+
347
+ /**
348
+ * Template for the main label for the combo box.
349
+ *
350
+ * Uses the contents of the `label` named slot as the label text.
351
+ */
352
+ private get labelTemplate(): TemplateResult {
353
+ return html`
354
+ <label id="label" for="text-input">
355
+ <slot name="label"></slot>
356
+ </label>
357
+ `;
358
+ }
359
+
360
+ /**
361
+ * Template for the text input field that users can edit to filter the available
362
+ * options or (if freeform behavior) to enter a custom value.
363
+ */
364
+ private get textInputTemplate(): TemplateResult {
365
+ const textInputClasses = classMap({
366
+ 'clear-padding': this.clearable && !this.shouldShowClearButton,
367
+ });
368
+
369
+ return html`
370
+ <input
371
+ type="text"
372
+ id="text-input"
373
+ class=${textInputClasses}
374
+ .value=${live(this.enteredText)}
375
+ placeholder=${ifDefined(this.placeholder)}
376
+ part="text-input"
377
+ role="combobox"
378
+ autocomplete="off"
379
+ aria-autocomplete="list"
380
+ aria-controls="options-list"
381
+ aria-expanded=${this.open}
382
+ aria-activedescendant=${ifDefined(this.highlightedOption?.id)}
383
+ ?readonly=${this.behavior === 'select-only'}
384
+ ?disabled=${this.disabled}
385
+ ?required=${this.required}
386
+ @click=${this.handleComboBoxClick}
387
+ @keydown=${this.handleComboBoxKeyDown}
388
+ @input=${this.handleTextBoxInput}
389
+ @focus=${this.handleFocus}
390
+ @blur=${this.handleBlur}
391
+ />
392
+ `;
393
+ }
394
+
395
+ /**
396
+ * Template for the clear button that is shown when the `clearable` property
397
+ * is true.
398
+ */
399
+ private get clearButtonTemplate(): TemplateResult {
400
+ return html`
401
+ <button
402
+ type="button"
403
+ id="clear-button"
404
+ part="clear-button"
405
+ tabindex="-1"
406
+ ?hidden=${!this.shouldShowClearButton}
407
+ @click=${this.handleClearButtonClick}
408
+ >
409
+ <span class="sr-only">${msg('Clear')}</span>
410
+ <slot name="clear-button"> ${clearIcon} </slot>
411
+ </button>
412
+ `;
413
+ }
414
+
415
+ /**
416
+ * Template for the caret open/closed icons to show beside the text input.
417
+ * The icons are wrapped in named slots to allow consumers to override them.
418
+ */
419
+ private get caretTemplate(): TemplateResult {
420
+ return html`
421
+ <slot name="caret-closed" ?hidden=${this.open}> ${caretClosedIcon} </slot>
422
+ <slot name="caret-open" ?hidden=${!this.open}> ${caretOpenIcon} </slot>
423
+ `;
424
+ }
425
+
426
+ /**
427
+ * Template for the caret button to be shown beside the text input.
428
+ */
429
+ private get caretButtonTemplate(): TemplateResult {
430
+ return html`
431
+ <button
432
+ type="button"
433
+ id="caret-button"
434
+ part="caret-button"
435
+ tabindex="-1"
436
+ aria-controls="options-list"
437
+ aria-expanded=${this.open}
438
+ ?disabled=${this.disabled}
439
+ @click=${this.handleComboBoxClick}
440
+ @keydown=${this.handleComboBoxKeyDown}
441
+ @focus=${this.handleFocus}
442
+ @blur=${this.handleBlur}
443
+ >
444
+ <span class="sr-only">${msg('Toggle options')}</span>
445
+ ${this.caretTemplate}
446
+ </button>
447
+ `;
448
+ }
449
+
450
+ /**
451
+ * Template for the options list that is displayed when the combo box is open.
452
+ */
453
+ private get optionsListTemplate(): TemplateResult {
454
+ return html`
455
+ <ul
456
+ id="options-list"
457
+ part="options-list"
458
+ role="listbox"
459
+ tabindex="-1"
460
+ popover
461
+ ?hidden=${!this.open}
462
+ @focus=${this.handleFocus}
463
+ @blur=${this.handleBlur}
464
+ >
465
+ <slot name="options-list-top"></slot>
466
+ ${when(this.open, () => this.optionTemplates)}
467
+ <slot name="options-list-bottom"></slot>
468
+ </ul>
469
+ `;
470
+ }
471
+
472
+ /**
473
+ * Array of templates for all of the filtered options to be shown in the options menu.
474
+ */
475
+ private get optionTemplates(): TemplateResult[] {
476
+ // If there are no options matching the filter, just show a message saying as much.
477
+ if (this.filteredOptions.length === 0 && this.maxAutocompleteEntries > 0) {
478
+ return [this.emptyOptionsTemplate];
479
+ }
480
+
481
+ // Otherwise build a list item for each filtered option
482
+ return this.filteredOptions.map(opt => {
483
+ const optionClasses = classMap({
484
+ option: true,
485
+ highlight: opt === this.highlightedOption,
486
+ });
487
+
488
+ return html`
489
+ <li
490
+ id=${opt.id}
491
+ class=${optionClasses}
492
+ part="option"
493
+ role="option"
494
+ tabindex="-1"
495
+ @pointerenter=${this.handleOptionPointerEnter}
496
+ @pointermove=${this.handleOptionPointerMove}
497
+ @click=${this.handleOptionClick}
498
+ @focus=${this.handleFocus}
499
+ @blur=${this.handleBlur}
500
+ >
501
+ ${opt.content ?? opt.text}
502
+ </li>
503
+ `;
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Info message shown in the listbox when no options match the entered text.
509
+ * Renders within an `empty-options` named slot so that its content can be customized.
510
+ */
511
+ private get emptyOptionsTemplate(): TemplateResult {
512
+ return html`
513
+ <li id="empty-options" part="empty-options">
514
+ <slot name="empty-options">${msg('No matching options')}</slot>
515
+ </li>
516
+ `;
517
+ }
518
+
519
+ //
520
+ // EVENT HANDLERS & MUTATORS
521
+ //
522
+
523
+ /**
524
+ * Handler for when the pointer device enters an option element in the dropdown.
525
+ */
526
+ private handleOptionPointerEnter(e: PointerEvent): void {
527
+ this.handleOptionPointerMove(e);
528
+ }
529
+
530
+ /**
531
+ * Handler for when the pointer device is moved within an option in the dropdown.
532
+ */
533
+ private handleOptionPointerMove(e: PointerEvent): void {
534
+ const target = e.currentTarget as HTMLLIElement;
535
+ const option = this.getOptionFor(target.id);
536
+ if (option) this.setHighlightedOption(option);
537
+ }
538
+
539
+ /**
540
+ * Handler for when the user clicks on an option in the dropdown.
541
+ */
542
+ private handleOptionClick(e: PointerEvent): void {
543
+ const target = e.currentTarget as HTMLLIElement;
544
+ const option = this.getOptionFor(target.id);
545
+ if (option) {
546
+ this.setSelectedOption(option.id);
547
+ this.closeOptionsMenu();
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Handler for `keydown` events on various special keys.
553
+ */
554
+ private handleComboBoxKeyDown(e: KeyboardEvent): void {
555
+ switch (e.key) {
556
+ case 'Enter':
557
+ this.handleEnterPressed();
558
+ break;
559
+ case 'Escape':
560
+ this.handleEscapePressed();
561
+ break;
562
+ case 'ArrowUp':
563
+ if (e.altKey) {
564
+ this.handleAltUpArrowPressed();
565
+ } else {
566
+ this.handleUpArrowPressed();
567
+ }
568
+ break;
569
+ case 'ArrowDown':
570
+ if (e.altKey) {
571
+ this.handleAltDownArrowPressed();
572
+ } else {
573
+ this.handleDownArrowPressed();
574
+ }
575
+ break;
576
+ case 'Tab':
577
+ this.handleTabPressed();
578
+ return; // Never cancel the default behavior for Tab
579
+ case ' ':
580
+ this.handleSpacePressed();
581
+ // In the specific case of picking an option in select-only, we skip the defaults
582
+ if (this.behavior === 'select-only' && this.highlightedOption) break;
583
+ return; // Otherwise, don't cancel the default behavior
584
+ default:
585
+ // Do nothing and allow propagation for all other keys
586
+ return;
587
+ }
588
+
589
+ e.stopPropagation();
590
+ e.preventDefault();
591
+ }
592
+
593
+ /**
594
+ * Handler for `input` events in the text input box.
595
+ */
596
+ private async handleTextBoxInput(): Promise<void> {
597
+ this.enteredText = this.textInput.value;
598
+ this.setFilterText(this.textInput.value);
599
+ this.openOptionsMenu();
600
+
601
+ await this.updateComplete;
602
+ this.highlightFirstOption();
603
+ }
604
+
605
+ /**
606
+ * Handler for when the Enter key is pressed
607
+ */
608
+ private handleEnterPressed(): void {
609
+ // Just open the options menu if it's currently closed
610
+ if (!this.open) {
611
+ this.openOptionsMenu();
612
+ return;
613
+ }
614
+
615
+ if (this.highlightedOption) {
616
+ // If an option is highlighted, select it
617
+ this.setSelectedOption(this.highlightedOption.id);
618
+ } else if (this.behavior === 'freeform') {
619
+ // Otherwise, in the freeform behavior we just accept the current value regardless
620
+ this.setValue(this.enteredText);
621
+ }
622
+
623
+ // Close the options list if needed
624
+ if (!this.stayOpen) this.open = false;
625
+ }
626
+
627
+ /**
628
+ * Handler for when the Escape key is pressed
629
+ */
630
+ private handleEscapePressed(): void {
631
+ // Close the options menu if it's currently open
632
+ if (this.open) {
633
+ this.closeOptionsMenu();
634
+ return;
635
+ }
636
+
637
+ // Otherwise, deselect any selected option & clear any filter text
638
+ this.clearSelectedOption();
639
+ }
640
+
641
+ /**
642
+ * Handler for when the Up Arrow key is pressed (without modifiers).
643
+ * @see {@linkcode handleAltUpArrowPressed()} for behavior under the Alt modifier.
644
+ */
645
+ private handleUpArrowPressed(): void {
646
+ if (!this.open) this.openOptionsMenu();
647
+ this.highlightPreviousOption();
648
+ }
649
+
650
+ /**
651
+ * Handler for when the Down Arrow key is pressed (without modifiers).
652
+ * @see {@linkcode handleAltDownArrowPressed()} for behavior under the Alt modifier.
653
+ */
654
+ private handleDownArrowPressed(): void {
655
+ if (!this.open) this.openOptionsMenu();
656
+ this.highlightNextOption();
657
+ }
658
+
659
+ /**
660
+ * Handler for when the Alt + Up Arrow key combo is pressed
661
+ */
662
+ private handleAltUpArrowPressed(): void {
663
+ this.closeOptionsMenu();
664
+ }
665
+
666
+ /**
667
+ * Handler for when the Alt + Down Arrow key combo is pressed
668
+ */
669
+ private handleAltDownArrowPressed(): void {
670
+ this.openOptionsMenu();
671
+ }
672
+
673
+ /**
674
+ * Handler for when the Tab key is pressed
675
+ */
676
+ private handleTabPressed(): void {
677
+ if (this.highlightedOption) {
678
+ this.setSelectedOption(this.highlightedOption.id);
679
+ if (!this.stayOpen) this.open = false;
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Handler for when the Space key is pressed
685
+ */
686
+ private handleSpacePressed(): void {
687
+ if (this.behavior === 'select-only' && this.highlightedOption) {
688
+ this.setSelectedOption(this.highlightedOption.id);
689
+ if (!this.stayOpen) this.open = false;
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Handler for clicks on the combo box input field or caret button.
695
+ */
696
+ private handleComboBoxClick(): void {
697
+ this.toggleOptionsMenu();
698
+ }
699
+
700
+ /**
701
+ * Handler for when the clear button is clicked.
702
+ */
703
+ private handleClearButtonClick(): void {
704
+ this.clearSelectedOption();
705
+ this.textInput.focus();
706
+ this.openOptionsMenu();
707
+ }
708
+
709
+ /**
710
+ * Handler for when any part of the combo box receives focus.
711
+ */
712
+ private handleFocus(): void {
713
+ if (this.behavior !== 'select-only') {
714
+ // Always keep focus on the text input if it's editable
715
+ this.textInput.focus();
716
+ }
717
+ this.hasFocus = true;
718
+ this.losingFocus = false;
719
+ }
720
+
721
+ /**
722
+ * Handler for when any part of the combo box loses focus.
723
+ */
724
+ private handleBlur(): void {
725
+ this.hasFocus = false;
726
+ this.losingFocus = true;
727
+
728
+ // On the next tick, check whether we've actually lost focus to some other element,
729
+ // or just had a momentary internal focus switch. If it's the former, we should
730
+ // close the menu and possibly make a selection (depending on desired behavior).
731
+ setTimeout(() => {
732
+ if (this.losingFocus && !this.shadowRoot?.activeElement) {
733
+ this.losingFocus = false;
734
+ this.closeOptionsMenu();
735
+
736
+ if (this.behavior === 'list') {
737
+ this.setTextValue(this.selectedOption?.text ?? '', false);
738
+ } else if (
739
+ this.behavior === 'freeform' &&
740
+ (this.enteredText || this.value)
741
+ ) {
742
+ this.setValue(this.enteredText);
743
+ }
744
+ }
745
+ }, 0);
746
+ }
747
+
748
+ /**
749
+ * Handler for when the `value` of this component is changed externally
750
+ */
751
+ private handleValueChanged(): void {
752
+ if (this.behavior !== 'freeform' && this.value !== null) {
753
+ // The value must correspond to a valid option or null
754
+ if (!this.getOptionFor(this.value)) this.clearSelectedOption();
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Highlights the first option shown in the filtered list.
760
+ */
761
+ private highlightFirstOption(): void {
762
+ this.setHighlightedOption(this.firstFilteredOption);
763
+ }
764
+
765
+ /**
766
+ * Highlights the last option shown in the filtered list.
767
+ */
768
+ private highlightLastOption(): void {
769
+ this.setHighlightedOption(this.lastFilteredOption);
770
+ }
771
+
772
+ /**
773
+ * Highlights the option before the currently highlighted one in the list, or the last one
774
+ * if none is highlighted.
775
+ */
776
+ private highlightPreviousOption(): void {
777
+ const { filteredOptions, lastFilteredIndex } = this;
778
+
779
+ // If no option is highlighted yet, highlight the last one
780
+ if (!this.highlightedOption) {
781
+ this.highlightLastOption();
782
+ return;
783
+ }
784
+
785
+ // Otherwise, move to the previous option (wrapping if needed)
786
+ const { highlightedIndex } = this;
787
+ const previousIndex =
788
+ this.wrapArrowKeys && highlightedIndex === 0
789
+ ? lastFilteredIndex // Wrap around to the end
790
+ : Math.max(highlightedIndex - 1, 0);
791
+ this.setHighlightedOption(filteredOptions[previousIndex]);
792
+ }
793
+
794
+ /**
795
+ * Highlights the option after the currently highlighted one in the list, or the first one
796
+ * if none is highlighted.
797
+ */
798
+ private highlightNextOption(): void {
799
+ const { filteredOptions, lastFilteredIndex } = this;
800
+
801
+ // If no option is highlighted yet, highlight the first one
802
+ if (!this.highlightedOption) {
803
+ this.highlightFirstOption();
804
+ return;
805
+ }
806
+
807
+ // Otherwise, move to the next option (wrapping if needed)
808
+ const { highlightedIndex } = this;
809
+ const nextIndex =
810
+ this.wrapArrowKeys && highlightedIndex === lastFilteredIndex
811
+ ? 0 // Wrap back to the start
812
+ : Math.min(highlightedIndex + 1, lastFilteredIndex);
813
+ this.setHighlightedOption(filteredOptions[nextIndex]);
814
+ }
815
+
816
+ /**
817
+ * Highlights the given option and scrolls it into view if necessary.
818
+ * If `null` is provided, any current highlight will be cleared.
819
+ */
820
+ private async setHighlightedOption(
821
+ option: IAComboBoxOption | null,
822
+ ): Promise<void> {
823
+ this.highlightedOption = option;
824
+ await this.updateComplete;
825
+
826
+ const { optionsList, highlightedElement } = this;
827
+ if (!highlightedElement) return;
828
+
829
+ // TODO: Not ideal as this will trigger a reflow...
830
+ // But may not be an issue in practice given the highlight isn't changing in a hot loop.
831
+ // If we have issues with this let's see if we can hook up an IntersectionObserver.
832
+ const elmtRect = highlightedElement.getBoundingClientRect();
833
+ const listRect = optionsList.getBoundingClientRect();
834
+ if (elmtRect.top < listRect.top || elmtRect.bottom > listRect.bottom) {
835
+ highlightedElement.scrollIntoView({ block: 'nearest' });
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Changes this combo box's selected option to the one matching the specified ID.
841
+ *
842
+ * Throws a `RangeError` if given an ID that does not correspond to any option
843
+ * held by this combo box.
844
+ */
845
+ setSelectedOption(id: string): void {
846
+ const option = this.getOptionFor(id);
847
+ if (!option) throw new RangeError('Unknown option ID');
848
+
849
+ const prevValue = this.value;
850
+ this.value = option.id;
851
+ this.internals.setFormValue(this.value);
852
+ this.setTextValue(option.text, false);
853
+ this.setFilterText('');
854
+ if (this.value !== prevValue) this.emitChangeEvent();
855
+
856
+ // Invoke the option's select callback if defined
857
+ option.onSelected?.(option);
858
+ }
859
+
860
+ /**
861
+ * Clears any currently selected option from this combo box, setting it to null
862
+ * and clearing any value in the text box.
863
+ */
864
+ clearSelectedOption(): void {
865
+ const prevValue = this.value;
866
+ this.value = null;
867
+ this.internals.setFormValue(this.value);
868
+ this.setTextValue('');
869
+ if (this.value !== prevValue) this.emitChangeEvent();
870
+ }
871
+
872
+ /**
873
+ * Set this combo box's value to the given string if possible.
874
+ *
875
+ * In `freeform` behavior, this always succeeds.
876
+ *
877
+ * In other behavior modes, this method is identical to `setSelectedOption`,
878
+ * interpreting the input as an option ID and throwing an error if no such
879
+ * option exists.
880
+ *
881
+ * @see {@linkcode IAComboBox.setSelectedOption}
882
+ */
883
+ setValue(value: string): void {
884
+ if (this.behavior === 'freeform') {
885
+ const prevValue = this.value;
886
+ this.value = value;
887
+ this.internals.setFormValue(this.value);
888
+ this.setTextValue(value);
889
+ if (this.value !== prevValue) this.emitChangeEvent();
890
+ } else {
891
+ this.setSelectedOption(value);
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Changes the value of the text input box, and updates the filter accordingly.
897
+ */
898
+ private setTextValue(value: string, setFilter = true): void {
899
+ this.textInput.value = value;
900
+ this.enteredText = value;
901
+ if (setFilter) this.setFilterText(value);
902
+ }
903
+
904
+ /**
905
+ * Sets the current filter text based on the provided string. The resulting filter
906
+ * text might not exactly match the provided value, depending on the current case
907
+ * sensitivity.
908
+ */
909
+ private setFilterText(baseFilterText: string): void {
910
+ const { caseTransform } = this;
911
+ this.filterText = caseTransform(baseFilterText);
912
+ }
913
+
914
+ openOptionsMenu(): void {
915
+ this.open = true;
916
+ this.emitToggleEvent();
917
+ }
918
+
919
+ closeOptionsMenu(): void {
920
+ this.open = false;
921
+ this.emitToggleEvent();
922
+ }
923
+
924
+ toggleOptionsMenu(): void {
925
+ this.open = !this.open;
926
+ this.emitToggleEvent();
927
+ }
928
+
929
+ private updateFormValidity(): void {
930
+ if (this.required && !this.value) {
931
+ this.internals.setValidity(
932
+ { valueMissing: true },
933
+ msg('A value is required'),
934
+ );
935
+ } else {
936
+ // All good
937
+ this.internals.setValidity({});
938
+ }
939
+ }
940
+
941
+ private emitChangeEvent(): void {
942
+ this.dispatchEvent(
943
+ new CustomEvent<string | null>('change', {
944
+ detail: this.value,
945
+ }),
946
+ );
947
+ }
948
+
949
+ private emitToggleEvent(): void {
950
+ this.dispatchEvent(
951
+ new CustomEvent<boolean>('toggle', {
952
+ detail: this.open,
953
+ }),
954
+ );
955
+ }
956
+
957
+ //
958
+ // HELPERS
959
+ //
960
+
961
+ /**
962
+ * True iff no selection has been made and no text has been entered.
963
+ */
964
+ private get isEmpty(): boolean {
965
+ return !this.selectedOption && !this.enteredText;
966
+ }
967
+
968
+ /**
969
+ * We only show the clear button when the `clearable` property is set
970
+ * and the combo box is neither empty nor disabled.
971
+ */
972
+ private get shouldShowClearButton(): boolean {
973
+ return this.clearable && !this.disabled && !this.isEmpty;
974
+ }
975
+
976
+ /**
977
+ * Sets the size and position of the options menu to match the size and position of
978
+ * the combo box widget. Prefers to position below the main widget, but will flip
979
+ * to the top if needed & if there's more room above.
980
+ */
981
+ private positionOptionsMenu(): void {
982
+ const { mainWidgetRow, optionsList } = this;
983
+ const mainWidgetRect = mainWidgetRow.getBoundingClientRect();
984
+
985
+ const { innerHeight, scrollX, scrollY } = window;
986
+ const usableHeightAbove = mainWidgetRect.top;
987
+ const usableHeightBelow = innerHeight - mainWidgetRect.bottom;
988
+
989
+ // We still want to respect any CSS var specified by the consumer
990
+ const maxHeightVar = 'var(--combo-box-list-max-height, 250px)';
991
+
992
+ const optionsListStyles: Record<string, string> = {
993
+ top: `${mainWidgetRect.bottom + scrollY}px`,
994
+ left: `${mainWidgetRect.left + scrollX}px`,
995
+ width: `var(--combo-box-list-width, ${mainWidgetRect.width}px)`,
996
+ maxHeight: `min(${usableHeightBelow}px, ${maxHeightVar})`,
997
+ };
998
+
999
+ Object.assign(optionsList.style, optionsListStyles);
1000
+
1001
+ // Wait a tick for it to appear, then check if we should flip it upwards instead
1002
+ setTimeout(() => {
1003
+ const listRect = optionsList.getBoundingClientRect();
1004
+ const overflowingViewport = listRect.bottom >= innerHeight;
1005
+ const moreSpaceAbove = usableHeightAbove > usableHeightBelow;
1006
+ if (overflowingViewport && moreSpaceAbove) {
1007
+ optionsList.style.top = 'auto';
1008
+ optionsList.style.bottom = `${innerHeight - mainWidgetRect.top - scrollY}px`;
1009
+ optionsList.style.maxHeight = `min(${usableHeightAbove}px, ${maxHeightVar})`;
1010
+ }
1011
+ }, 0);
1012
+ }
1013
+
1014
+ /**
1015
+ * A function to transform option & filter text based on the combo box's
1016
+ * current case sensitivity.
1017
+ */
1018
+ private get caseTransform(): (text: string) => string {
1019
+ return this.caseSensitive ? STRING_IDENTITY_FN : STRING_LOWER_CASE_FN;
1020
+ }
1021
+
1022
+ /**
1023
+ * Returns the combo box option having the given ID, or null if none exists.
1024
+ */
1025
+ private getOptionFor(id: string): IAComboBoxOption | null {
1026
+ return this.optionsByID.get(id) ?? null;
1027
+ }
1028
+
1029
+ /**
1030
+ * Clears any previously-cached mapping of IDs to options, and rebuilds the
1031
+ * map based on the current set of options.
1032
+ */
1033
+ private rebuildOptionIDMap(): void {
1034
+ this.optionsByID.clear();
1035
+ for (const option of this.options) {
1036
+ this.optionsByID.set(option.id, option);
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Applies any required sort to the options and caches the result to be used
1042
+ * at filter/display time.
1043
+ */
1044
+ private rebuildSortedOptions(): void {
1045
+ if (this.sort) {
1046
+ this.optionsRespectingSortFlag = this.options.sort((a, b) => {
1047
+ const aValue = this.optionFilteringValues.get(a) as string;
1048
+ const bValue = this.optionFilteringValues.get(b) as string;
1049
+ return aValue.localeCompare(bValue);
1050
+ });
1051
+ } else {
1052
+ this.optionsRespectingSortFlag = this.options;
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Clears any previously-cached option filtering values, and rebuilds the
1058
+ * map based on the current component properties.
1059
+ */
1060
+ private rebuildOptionFilteringValues(): void {
1061
+ this.optionFilteringValues.clear();
1062
+
1063
+ const { caseTransform } = this;
1064
+ for (const option of this.options) {
1065
+ const filteringValue = caseTransform(option.text);
1066
+ this.optionFilteringValues.set(option, filteringValue);
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Returns the filtered array of options by applying the specified filter function
1072
+ * with the current filter text, up to the limit specified by `maxAutocompleteEntries`.
1073
+ */
1074
+ private rebuildFilteredOptions(): void {
1075
+ // We don't want to filter the results in select-only mode
1076
+ const resolvedFilterOption =
1077
+ this.behavior === 'select-only' ? 'all' : this.filter;
1078
+
1079
+ const filterFn =
1080
+ typeof resolvedFilterOption === 'string'
1081
+ ? FILTER_PRESETS[resolvedFilterOption]
1082
+ : resolvedFilterOption;
1083
+
1084
+ const filtered = this.optionsRespectingSortFlag
1085
+ .filter(opt => {
1086
+ const optionFilteringValue = this.optionFilteringValues.get(opt);
1087
+ if (!optionFilteringValue) return false;
1088
+
1089
+ return filterFn(this.filterText, optionFilteringValue, opt);
1090
+ })
1091
+ .slice(0, this.maxAutocompleteEntries);
1092
+
1093
+ this.filteredOptions = filtered;
1094
+ }
1095
+
1096
+ /**
1097
+ * The first option appearing in the filtered list, or null if there are none.
1098
+ */
1099
+ private get firstFilteredOption(): IAComboBoxOption | null {
1100
+ return this.filteredOptions[0] ?? null;
1101
+ }
1102
+
1103
+ /**
1104
+ * The last option appearing in the filtered list, or null if there are none.
1105
+ */
1106
+ private get lastFilteredOption(): IAComboBoxOption | null {
1107
+ return this.filteredOptions[this.lastFilteredIndex] ?? null;
1108
+ }
1109
+
1110
+ /**
1111
+ * The index of the last filtered option, or -1 if no options match the filter.
1112
+ */
1113
+ private get lastFilteredIndex(): number {
1114
+ return this.filteredOptions.length - 1;
1115
+ }
1116
+
1117
+ /**
1118
+ * The IAComboBoxOption that is currently selected, or null if none is selected.
1119
+ */
1120
+ get selectedOption(): IAComboBoxOption | null {
1121
+ if (this.value == null) return null;
1122
+ return this.getOptionFor(this.value);
1123
+ }
1124
+
1125
+ /**
1126
+ * The index of the currently highlighted option in the filtered list, or -1 if
1127
+ * no option is currently highlighted.
1128
+ */
1129
+ private get highlightedIndex(): number {
1130
+ if (!this.highlightedOption) return -1;
1131
+ return this.filteredOptions.indexOf(this.highlightedOption);
1132
+ }
1133
+
1134
+ /**
1135
+ * The HTML element representing the currently-highlighted option, or null if
1136
+ * no option is highlighted.
1137
+ */
1138
+ private get highlightedElement(): HTMLElement | null {
1139
+ if (!this.highlightedOption) return null;
1140
+ return this.shadowRoot!.getElementById(this.highlightedOption.id);
1141
+ }
1142
+
1143
+ static get styles(): CSSResultGroup {
1144
+ return css`
1145
+ #container {
1146
+ display: inline-block;
1147
+ width: var(--combo-box-width, auto);
1148
+ }
1149
+
1150
+ #label {
1151
+ display: block;
1152
+ width: fit-content;
1153
+ }
1154
+
1155
+ #main-widget-row {
1156
+ display: inline-flex;
1157
+ align-items: stretch;
1158
+ flex-wrap: nowrap;
1159
+ background: white;
1160
+ border: 1px solid black;
1161
+ width: 100%;
1162
+ }
1163
+
1164
+ #main-widget-row:not(.focused):hover,
1165
+ #main-widget-row:not(.focused):active {
1166
+ background: #fafafa;
1167
+ }
1168
+
1169
+ #main-widget-row.focused {
1170
+ outline: black auto 1px;
1171
+ outline-offset: 3px;
1172
+ }
1173
+
1174
+ #text-input {
1175
+ appearance: none;
1176
+ background: transparent;
1177
+ border: none;
1178
+ padding: var(--combo-box-padding, 5px);
1179
+ padding-right: 0;
1180
+ width: 100%;
1181
+ font-size: inherit;
1182
+ color: inherit;
1183
+ outline: none;
1184
+ text-overflow: ellipsis;
1185
+ }
1186
+
1187
+ #text-input.clear-padding {
1188
+ padding-right: 30px;
1189
+ }
1190
+
1191
+ #text-input:read-only {
1192
+ cursor: pointer;
1193
+ }
1194
+
1195
+ #clear-button,
1196
+ #caret-button {
1197
+ display: inline-flex;
1198
+ align-items: center;
1199
+ appearance: none;
1200
+ background: transparent;
1201
+ border: none;
1202
+ padding: var(--combo-box-padding, 5px) 5px;
1203
+ outline: none;
1204
+ cursor: pointer;
1205
+ }
1206
+
1207
+ #clear-button {
1208
+ flex: 0 0 30px;
1209
+ }
1210
+
1211
+ #clear-button[hidden] {
1212
+ display: none;
1213
+ }
1214
+
1215
+ #caret-button {
1216
+ padding-right: var(--combo-box-padding, 5px);
1217
+ }
1218
+
1219
+ #options-list {
1220
+ position: absolute;
1221
+ list-style-type: none;
1222
+ margin: 1px 0 0;
1223
+ border: none;
1224
+ padding: 0;
1225
+ background: white;
1226
+ max-height: 400px;
1227
+ box-shadow: 0 0 1px 1px #ddd;
1228
+ opacity: 0;
1229
+ transition: opacity 0.125s ease;
1230
+ }
1231
+
1232
+ #options-list.visible {
1233
+ opacity: 1;
1234
+ }
1235
+
1236
+ #empty-options {
1237
+ padding: 5px;
1238
+ color: #606060;
1239
+ font-style: italic;
1240
+ text-align: center;
1241
+ }
1242
+
1243
+ #caret-button svg {
1244
+ width: 14px;
1245
+ height: 14px;
1246
+ }
1247
+
1248
+ #clear-button svg {
1249
+ width: var(--combo-box-clear-icon-size, 16px);
1250
+ height: var(--combo-box-clear-icon-size, 16px);
1251
+ }
1252
+
1253
+ .option {
1254
+ padding: 7px 5px;
1255
+ width: 100%;
1256
+ box-sizing: border-box;
1257
+ line-height: 1.1;
1258
+ text-overflow: ellipsis;
1259
+ overflow: hidden;
1260
+ cursor: pointer;
1261
+ }
1262
+
1263
+ .highlight {
1264
+ background-color: #dbe0ff;
1265
+ }
1266
+
1267
+ .disabled,
1268
+ .disabled * {
1269
+ cursor: not-allowed !important;
1270
+ }
1271
+
1272
+ .sr-only {
1273
+ position: absolute !important;
1274
+ width: 1px !important;
1275
+ height: 1px !important;
1276
+ margin: -1px !important;
1277
+ padding: 0 !important;
1278
+ border: 0 !important;
1279
+ overflow: hidden !important;
1280
+ white-space: nowrap !important;
1281
+ clip: rect(1px, 1px, 1px, 1px) !important;
1282
+ -webkit-clip-path: inset(50%) !important;
1283
+ clip-path: inset(50%) !important;
1284
+ user-select: none !important;
1285
+ }
1286
+ `;
1287
+ }
1288
+ }