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

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.
@@ -16,6 +16,7 @@ import { msg } from '@lit/localize';
16
16
 
17
17
  import {
18
18
  hasAnyOf,
19
+ isSubsequence,
19
20
  type IAComboBoxBehavior,
20
21
  type IAComboBoxFilterFunction,
21
22
  type IAComboBoxFilterOption,
@@ -23,36 +24,9 @@ import {
23
24
  type IAComboBoxOption,
24
25
  } from './models';
25
26
 
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
- };
27
+ import caretClosedIcon from './caret-closed';
28
+ import caretOpenIcon from './caret-open';
29
+ import clearIcon from './clear';
56
30
 
57
31
  /**
58
32
  * Map from filter preset keys to their associated filtering function.
@@ -151,7 +125,8 @@ export class IAComboBox extends LitElement {
151
125
  caseSensitive = false;
152
126
 
153
127
  /**
154
- * Whether the filtered options should be listed in lexicographically-sorted order.
128
+ * Whether the filtered options should be listed in lexicographically-sorted order,
129
+ * respecting the current `caseSensitive` setting.
155
130
  * Default is `false`, displaying them in the same order as the provided options array.
156
131
  */
157
132
  @property({ type: Boolean, reflect: true }) sort = false;
@@ -179,6 +154,12 @@ export class IAComboBox extends LitElement {
179
154
  @property({ type: Boolean, reflect: true, attribute: 'stay-open' })
180
155
  stayOpen = false;
181
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
+
182
163
  /**
183
164
  * Whether the combo box's option menu is currently expanded. Default is `false`.
184
165
  */
@@ -233,8 +214,6 @@ export class IAComboBox extends LitElement {
233
214
 
234
215
  @query('#text-input') private textInput!: HTMLInputElement;
235
216
 
236
- @query('#caret-button') private caretButton!: HTMLInputElement;
237
-
238
217
  @query('#options-list') private optionsList!: HTMLUListElement;
239
218
 
240
219
  static formAssociated = true;
@@ -279,19 +258,18 @@ export class IAComboBox extends LitElement {
279
258
  }
280
259
 
281
260
  render(): TemplateResult | typeof nothing {
261
+ const mainWidgetClasses = classMap({
262
+ disabled: this.disabled,
263
+ focused: this.hasFocus,
264
+ });
265
+
282
266
  return html`
283
- <div
284
- id="container"
285
- class=${classMap({ focused: this.hasFocus })}
286
- part="container"
287
- >
267
+ <div id="container" part="container">
288
268
  ${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}
269
+ <div id="main-widget-row" class=${mainWidgetClasses} part="combo-box">
270
+ ${this.textInputTemplate}
271
+ ${this.clearable ? this.clearButtonTemplate : nothing}
272
+ ${this.caretButtonTemplate}
295
273
  </div>
296
274
  ${this.optionsListTemplate}
297
275
  </div>
@@ -369,10 +347,14 @@ export class IAComboBox extends LitElement {
369
347
  /**
370
348
  * Template for the main label for the combo box.
371
349
  *
372
- * Uses the contents of the default (unnamed) slot as the label text.
350
+ * Uses the contents of the `label` named slot as the label text.
373
351
  */
374
352
  private get labelTemplate(): TemplateResult {
375
- return html`<label id="label" for="text-input"><slot></slot></label>`;
353
+ return html`
354
+ <label id="label" for="text-input">
355
+ <slot name="label"></slot>
356
+ </label>
357
+ `;
376
358
  }
377
359
 
378
360
  /**
@@ -381,7 +363,7 @@ export class IAComboBox extends LitElement {
381
363
  */
382
364
  private get textInputTemplate(): TemplateResult {
383
365
  const textInputClasses = classMap({
384
- editable: this.behavior !== 'select-only',
366
+ 'clear-padding': this.clearable && !this.shouldShowClearButton,
385
367
  });
386
368
 
387
369
  return html`
@@ -398,6 +380,7 @@ export class IAComboBox extends LitElement {
398
380
  aria-controls="options-list"
399
381
  aria-expanded=${this.open}
400
382
  aria-activedescendant=${ifDefined(this.highlightedOption?.id)}
383
+ ?readonly=${this.behavior === 'select-only'}
401
384
  ?disabled=${this.disabled}
402
385
  ?required=${this.required}
403
386
  @click=${this.handleComboBoxClick}
@@ -409,14 +392,40 @@ export class IAComboBox extends LitElement {
409
392
  `;
410
393
  }
411
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">
411
+ ${clearIcon}
412
+ </slot>
413
+ </button>
414
+ `;
415
+ }
416
+
412
417
  /**
413
418
  * Template for the caret open/closed icons to show beside the text input.
414
419
  * The icons are wrapped in named slots to allow consumers to override them.
415
420
  */
416
421
  private get caretTemplate(): TemplateResult {
417
422
  return html`
418
- <slot name="caret-closed" ?hidden=${this.open}> ${caretClosed} </slot>
419
- <slot name="caret-open" ?hidden=${!this.open}> ${caretOpen} </slot>
423
+ <slot name="caret-closed" ?hidden=${this.open}>
424
+ ${caretClosedIcon}
425
+ </slot>
426
+ <slot name="caret-open" ?hidden=${!this.open}>
427
+ ${caretOpenIcon}
428
+ </slot>
420
429
  `;
421
430
  }
422
431
 
@@ -438,6 +447,7 @@ export class IAComboBox extends LitElement {
438
447
  @focus=${this.handleFocus}
439
448
  @blur=${this.handleBlur}
440
449
  >
450
+ <span class="sr-only">${msg('Toggle options')}</span>
441
451
  ${this.caretTemplate}
442
452
  </button>
443
453
  `;
@@ -452,6 +462,7 @@ export class IAComboBox extends LitElement {
452
462
  id="options-list"
453
463
  part="options-list"
454
464
  role="listbox"
465
+ tabindex="-1"
455
466
  popover
456
467
  ?hidden=${!this.open}
457
468
  @focus=${this.handleFocus}
@@ -485,6 +496,7 @@ export class IAComboBox extends LitElement {
485
496
  id=${opt.id}
486
497
  class=${optionClasses}
487
498
  part="option"
499
+ role="option"
488
500
  tabindex="-1"
489
501
  @pointerenter=${this.handleOptionPointerEnter}
490
502
  @pointermove=${this.handleOptionPointerMove}
@@ -525,7 +537,7 @@ export class IAComboBox extends LitElement {
525
537
  * Handler for when the pointer device is moved within an option in the dropdown.
526
538
  */
527
539
  private handleOptionPointerMove(e: PointerEvent): void {
528
- const target = e.target as HTMLLIElement;
540
+ const target = e.currentTarget as HTMLLIElement;
529
541
  const option = this.getOptionFor(target.id);
530
542
  if (option) this.setHighlightedOption(option);
531
543
  }
@@ -534,7 +546,7 @@ export class IAComboBox extends LitElement {
534
546
  * Handler for when the user clicks on an option in the dropdown.
535
547
  */
536
548
  private handleOptionClick(e: PointerEvent): void {
537
- const target = e.target as HTMLLIElement;
549
+ const target = e.currentTarget as HTMLLIElement;
538
550
  const option = this.getOptionFor(target.id);
539
551
  if (option) {
540
552
  this.setSelectedOption(option.id);
@@ -568,10 +580,15 @@ export class IAComboBox extends LitElement {
568
580
  }
569
581
  break;
570
582
  case 'Tab':
571
- this.textInput.focus();
572
- return;
583
+ this.handleTabPressed();
584
+ return; // Never cancel the default behavior for Tab
585
+ case ' ':
586
+ this.handleSpacePressed();
587
+ // In the specific case of picking an option in select-only, we skip the defaults
588
+ if (this.behavior === 'select-only' && this.highlightedOption) break;
589
+ return; // Otherwise, don't cancel the default behavior
573
590
  default:
574
- // Do nothing and allow propagation otherwise
591
+ // Do nothing and allow propagation for all other keys
575
592
  return;
576
593
  }
577
594
 
@@ -659,6 +676,26 @@ export class IAComboBox extends LitElement {
659
676
  this.openOptionsMenu();
660
677
  }
661
678
 
679
+ /**
680
+ * Handler for when the Tab key is pressed
681
+ */
682
+ private handleTabPressed(): void {
683
+ if (this.highlightedOption) {
684
+ this.setSelectedOption(this.highlightedOption.id);
685
+ if (!this.stayOpen) this.open = false;
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Handler for when the Space key is pressed
691
+ */
692
+ private handleSpacePressed(): void {
693
+ if (this.behavior === 'select-only' && this.highlightedOption) {
694
+ this.setSelectedOption(this.highlightedOption.id);
695
+ if (!this.stayOpen) this.open = false;
696
+ }
697
+ }
698
+
662
699
  /**
663
700
  * Handler for clicks on the combo box input field or caret button.
664
701
  */
@@ -666,13 +703,21 @@ export class IAComboBox extends LitElement {
666
703
  this.toggleOptionsMenu();
667
704
  }
668
705
 
706
+ /**
707
+ * Handler for when the clear button is clicked.
708
+ */
709
+ private handleClearButtonClick(): void {
710
+ this.clearSelectedOption();
711
+ this.textInput.focus();
712
+ this.openOptionsMenu();
713
+ }
714
+
669
715
  /**
670
716
  * Handler for when any part of the combo box receives focus.
671
717
  */
672
718
  private handleFocus(): void {
673
- if (this.behavior === 'select-only') {
674
- this.caretButton.focus();
675
- } else {
719
+ if (this.behavior !== 'select-only') {
720
+ // Always keep focus on the text input if it's editable
676
721
  this.textInput.focus();
677
722
  }
678
723
  this.hasFocus = true;
@@ -685,11 +730,23 @@ export class IAComboBox extends LitElement {
685
730
  private handleBlur(): void {
686
731
  this.hasFocus = false;
687
732
  this.losingFocus = true;
733
+
734
+ // On the next tick, check whether we've actually lost focus to some other element,
735
+ // or just had a momentary internal focus switch. If it's the former, we should
736
+ // close the menu and possibly make a selection (depending on desired behavior).
688
737
  setTimeout(() => {
689
738
  if (this.losingFocus && !this.shadowRoot?.activeElement) {
690
739
  this.losingFocus = false;
691
740
  this.closeOptionsMenu();
692
- if (this.behavior === 'freeform') this.setValue(this.enteredText);
741
+
742
+ if (this.behavior === 'list') {
743
+ this.setTextValue(this.selectedOption?.text ?? '', false);
744
+ } else if (
745
+ this.behavior === 'freeform' &&
746
+ (this.enteredText || this.value)
747
+ ) {
748
+ this.setValue(this.enteredText);
749
+ }
693
750
  }
694
751
  }, 0);
695
752
  }
@@ -798,7 +855,8 @@ export class IAComboBox extends LitElement {
798
855
  const prevValue = this.value;
799
856
  this.value = option.id;
800
857
  this.internals.setFormValue(this.value);
801
- this.setTextValue(option.text);
858
+ this.setTextValue(option.text, false);
859
+ this.setFilterText('');
802
860
  if (this.value !== prevValue) this.emitChangeEvent();
803
861
 
804
862
  // Invoke the option's select callback if defined
@@ -843,10 +901,20 @@ export class IAComboBox extends LitElement {
843
901
  /**
844
902
  * Changes the value of the text input box, and updates the filter accordingly.
845
903
  */
846
- private setTextValue(value: string): void {
904
+ private setTextValue(value: string, setFilter = true): void {
847
905
  this.textInput.value = value;
848
906
  this.enteredText = value;
849
- this.setFilterText(value);
907
+ if (setFilter) this.setFilterText(value);
908
+ }
909
+
910
+ /**
911
+ * Sets the current filter text based on the provided string. The resulting filter
912
+ * text might not exactly match the provided value, depending on the current case
913
+ * sensitivity.
914
+ */
915
+ private setFilterText(baseFilterText: string): void {
916
+ const { caseTransform } = this;
917
+ this.filterText = caseTransform(baseFilterText);
850
918
  }
851
919
 
852
920
  openOptionsMenu(): void {
@@ -896,6 +964,21 @@ export class IAComboBox extends LitElement {
896
964
  // HELPERS
897
965
  //
898
966
 
967
+ /**
968
+ * True iff no selection has been made and no text has been entered.
969
+ */
970
+ private get isEmpty(): boolean {
971
+ return !this.selectedOption && !this.enteredText;
972
+ }
973
+
974
+ /**
975
+ * We only show the clear button when the `clearable` property is set
976
+ * and the combo box is neither empty nor disabled.
977
+ */
978
+ private get shouldShowClearButton(): boolean {
979
+ return this.clearable && !this.disabled && !this.isEmpty;
980
+ }
981
+
899
982
  /**
900
983
  * Sets the size and position of the options menu to match the size and position of
901
984
  * the combo box widget. Prefers to position below the main widget, but will flip
@@ -910,12 +993,12 @@ export class IAComboBox extends LitElement {
910
993
  const usableHeightBelow = innerHeight - mainWidgetRect.bottom;
911
994
 
912
995
  // We still want to respect any CSS var specified by the consumer
913
- const maxHeightVar = 'var(--comboBoxListMaxHeight, 250px)';
996
+ const maxHeightVar = 'var(--combo-box-list-max-height, 250px)';
914
997
 
915
998
  const optionsListStyles: Record<string, string> = {
916
999
  top: `${mainWidgetRect.bottom + scrollY}px`,
917
1000
  left: `${mainWidgetRect.left + scrollX}px`,
918
- width: `var(--comboBoxListWidth, ${mainWidgetRect.width}px)`,
1001
+ width: `var(--combo-box-list-width, ${mainWidgetRect.width}px)`,
919
1002
  maxHeight: `min(${usableHeightBelow}px, ${maxHeightVar})`,
920
1003
  };
921
1004
 
@@ -942,16 +1025,6 @@ export class IAComboBox extends LitElement {
942
1025
  return this.caseSensitive ? STRING_IDENTITY_FN : STRING_LOWER_CASE_FN;
943
1026
  }
944
1027
 
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
1028
  /**
956
1029
  * Returns the combo box option having the given ID, or null if none exists.
957
1030
  */
@@ -1075,6 +1148,11 @@ export class IAComboBox extends LitElement {
1075
1148
 
1076
1149
  static get styles(): CSSResultGroup {
1077
1150
  return css`
1151
+ #container {
1152
+ display: inline-block;
1153
+ width: var(--combo-box-width, auto);
1154
+ }
1155
+
1078
1156
  #label {
1079
1157
  display: block;
1080
1158
  width: fit-content;
@@ -1086,9 +1164,15 @@ export class IAComboBox extends LitElement {
1086
1164
  flex-wrap: nowrap;
1087
1165
  background: white;
1088
1166
  border: 1px solid black;
1167
+ width: 100%;
1089
1168
  }
1090
1169
 
1091
- .focused #main-widget-row {
1170
+ #main-widget-row:not(.focused):hover,
1171
+ #main-widget-row:not(.focused):active {
1172
+ background: #fafafa;
1173
+ }
1174
+
1175
+ #main-widget-row.focused {
1092
1176
  outline: black auto 1px;
1093
1177
  outline-offset: 3px;
1094
1178
  }
@@ -1097,27 +1181,47 @@ export class IAComboBox extends LitElement {
1097
1181
  appearance: none;
1098
1182
  background: transparent;
1099
1183
  border: none;
1100
- padding: var(--comboBoxPadding, 5px);
1184
+ padding: var(--combo-box-padding, 5px);
1185
+ padding-right: 0;
1101
1186
  width: 100%;
1102
1187
  font-size: inherit;
1188
+ color: inherit;
1103
1189
  outline: none;
1190
+ text-overflow: ellipsis;
1191
+ }
1192
+
1193
+ #text-input.clear-padding {
1194
+ padding-right: 30px;
1104
1195
  }
1105
1196
 
1106
- #text-input:not(.editable) {
1197
+ #text-input:read-only {
1107
1198
  cursor: pointer;
1108
1199
  }
1109
1200
 
1201
+ #clear-button,
1110
1202
  #caret-button {
1111
1203
  display: inline-flex;
1112
1204
  align-items: center;
1113
1205
  appearance: none;
1114
1206
  background: transparent;
1115
1207
  border: none;
1116
- padding: var(--comboBoxPadding, 5px);
1208
+ padding: var(--combo-box-padding, 5px) 5px;
1117
1209
  outline: none;
1118
1210
  cursor: pointer;
1119
1211
  }
1120
1212
 
1213
+ #clear-button {
1214
+ flex: 0 0 30px;
1215
+ }
1216
+
1217
+ #clear-button[hidden] {
1218
+ display: none;
1219
+ }
1220
+
1221
+ #caret-button {
1222
+ padding-right: var(--combo-box-padding, 5px);
1223
+ }
1224
+
1121
1225
  #options-list {
1122
1226
  position: absolute;
1123
1227
  list-style-type: none;
@@ -1142,13 +1246,23 @@ export class IAComboBox extends LitElement {
1142
1246
  text-align: center;
1143
1247
  }
1144
1248
 
1145
- .caret {
1249
+ #caret-button svg {
1146
1250
  width: 14px;
1147
1251
  height: 14px;
1148
1252
  }
1149
1253
 
1254
+ #clear-button svg {
1255
+ width: var(--combo-box-clear-icon-size, 16px);
1256
+ height: var(--combo-box-clear-icon-size, 16px);
1257
+ }
1258
+
1150
1259
  .option {
1151
- padding: 5px;
1260
+ padding: 7px 5px;
1261
+ width: 100%;
1262
+ box-sizing: border-box;
1263
+ line-height: 1.1;
1264
+ text-overflow: ellipsis;
1265
+ overflow: hidden;
1152
1266
  cursor: pointer;
1153
1267
  }
1154
1268
 
@@ -81,3 +81,33 @@ export type IAComboBoxFilterOption =
81
81
  export function hasAnyOf<T>(map: Map<T, unknown>, keys: T[]): boolean {
82
82
  return keys.some((prop) => map.has(prop));
83
83
  }
84
+
85
+ /**
86
+ * Tests whether the given `haystack` string has the given `needle` as a subsequence.
87
+ * Returns `true` if the characters of `needle` appear in order within `haystack`,
88
+ * regardless of whether they are contiguous. Returns `false` otherwise.
89
+ *
90
+ * E.g., `ace` is a subsequence of `archive` (but not a contiguous substring).
91
+ *
92
+ * Note: The empty string is a subsequence of any string, including itself.
93
+ *
94
+ * @param needle The potential subsequence to check for inside `haystack`.
95
+ * @param haystack The string to be tested for containing the `needle` subsequence.
96
+ * @returns Whether `haystack` has `needle` as a subsequence.
97
+ */
98
+ export function isSubsequence(needle: string, haystack: string): boolean {
99
+ const needleChars = [...needle]; // Split out the full code points
100
+ const haystackChars = [...haystack];
101
+ const needleLen = needleChars.length;
102
+ const haystackLen = haystackChars.length;
103
+ if (needleLen === 0) return true;
104
+
105
+ let needleIdx = 0;
106
+ let haystackIdx = 0;
107
+ while (haystackIdx < haystackLen) {
108
+ if (haystackChars[haystackIdx] === needleChars[needleIdx]) needleIdx += 1;
109
+ if (needleIdx >= needleLen) return true;
110
+ haystackIdx += 1;
111
+ }
112
+ return false;
113
+ }