html-combobox-element 0.0.2-beta.1 → 0.0.2-beta.10

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.
@@ -9,40 +9,84 @@ export class ComboboxMarkup {
9
9
  placeholder = null;
10
10
  searchInput = null;
11
11
  tagTemplate = null;
12
- options;
13
- connected = false;
12
+ customTag = null;
13
+ #rso;
14
+ #hostWidth = 0;
15
+ #hostHeight = 0;
16
+ #hostTop = 0;
14
17
  constructor(shadowRoot, internals) {
15
18
  this.#shadowRoot = shadowRoot;
16
19
  this.#internals = internals;
17
- this.#shadowRoot.host.addEventListener('focus', this.showDropdown);
18
- this.#shadowRoot.host.addEventListener('blur', this.hideDropdown);
19
- this.#shadowRoot.host.addEventListener('click', this.showDropdown);
20
- document.addEventListener('click', this.hideDropdown);
21
- document.addEventListener('scroll', this.onPageScroll);
20
+ window.addEventListener('click', this.onClickOutside);
21
+ this.#shadowRoot.host.addEventListener('focusin', this.onFocusIn);
22
+ this.#shadowRoot.host.addEventListener('focusout', this.onFocusOut);
23
+ this.#rso = new ResizeObserver(this.#onDimensionsChanges);
22
24
  }
23
25
  connect() {
24
- const placeholder = this.#shadowRoot.host.getAttribute('placeholder') || '';
25
- this.tagsContainer = this.#shadowRoot.querySelector('#tags');
26
- this.optionsContainer = this.#shadowRoot.querySelector('[part*="options"]');
27
- this.clearAllButton = this.#shadowRoot.querySelector('[part*="clear-all"]');
28
- this.dropdown = this.#shadowRoot.querySelector('#dropdown');
29
- this.placeholder = this.#shadowRoot.querySelector('#placeholder');
26
+ const placeholder = this.#shadowRoot.host.getAttribute('placeholder') || `Выбрать`;
27
+ this.tagsContainer = this.#shadowRoot.querySelector('#cbtags');
28
+ this.#rso.observe(this.#shadowRoot.host, { box: 'border-box' });
29
+ this.optionsContainer = this.#shadowRoot.querySelector('#cboptions');
30
+ this.clearAllButton = this.#shadowRoot.querySelector('#cbclear');
31
+ this.clearAllButton?.addEventListener('focusout', this.onClearAllFocusOut);
32
+ this.dropdown = this.#shadowRoot.querySelector('#cbdropdown');
33
+ this.placeholder = this.#shadowRoot.querySelector('#cbplaceholder');
30
34
  this.placeholder.innerText = placeholder;
31
- this.searchInput = this.#shadowRoot.querySelector('[part*="search-input"]');
35
+ this.searchInput = this.#shadowRoot.querySelector('#cbfilter');
32
36
  this.searchInput.value = this.#shadowRoot.host.getAttribute('query');
33
- this.searchInput.placeholder = placeholder;
37
+ this.searchInput.style.setProperty('--search-input-placeholder-color', this.placeholder.style.color);
34
38
  const innerTemplate = this.#shadowRoot.querySelector('#tag-template');
35
39
  const doc = document.importNode(innerTemplate.content, true);
36
40
  this.tagTemplate = doc.querySelector('box-tag');
37
- this.connected = true;
38
41
  }
39
- invalidateOptionsCache() {
40
- this.options = this.optionsContainer.querySelectorAll('box-option');
42
+ onHostConnected() {
43
+ const host = this.#shadowRoot.host;
44
+ const clearable = host.hasAttribute('clearable') || host.hasAttribute('multiple');
45
+ this.tagTemplate.toggleAttribute('visible', clearable);
46
+ const userTagTemplate = this.#shadowRoot.host.firstElementChild;
47
+ if (userTagTemplate instanceof HTMLComboboxTagElement) {
48
+ this.customTag = userTagTemplate;
49
+ }
50
+ }
51
+ disconnect() {
52
+ this.#shadowRoot.host.removeEventListener('focusin', this.onFocusIn);
53
+ this.#shadowRoot.host.removeEventListener('focusout', this.onFocusOut);
54
+ this.clearAllButton?.removeEventListener('focusout', this.onClearAllFocusOut);
55
+ window.removeEventListener('click', this.onClickOutside);
56
+ this.#rso.unobserve(this.#shadowRoot.host);
57
+ }
58
+ setPlaceholder(value) {
59
+ if (this.placeholder) {
60
+ this.placeholder.innerText = value.trim() || `Выбрать`;
61
+ }
62
+ }
63
+ setInputValue(value) {
64
+ if (this.searchInput) {
65
+ this.searchInput.value = value;
66
+ }
41
67
  }
68
+ onClearAllFocusOut = (event) => {
69
+ event.preventDefault();
70
+ event.stopPropagation();
71
+ if (event.relatedTarget == null) {
72
+ const host = this.#shadowRoot.host;
73
+ if (!host.hasAttribute('searchable') && !host.hasAttribute('filterable')) {
74
+ this.closeDropdown();
75
+ }
76
+ }
77
+ };
78
+ onClickOutside = (ev) => {
79
+ if (this.#internals.ariaExpanded !== 'true')
80
+ return;
81
+ if (ev.target === this.#shadowRoot.host)
82
+ return;
83
+ this.closeDropdown();
84
+ };
42
85
  #timer = undefined;
43
86
  sort(query) {
44
87
  clearTimeout(this.#timer);
45
88
  this.#timer = setTimeout(() => {
89
+ // console.log(this.options)
46
90
  const regex = new RegExp(query.trim(), 'i');
47
91
  this.options.forEach((option) => {
48
92
  if (!regex.test(option.textContent)) {
@@ -53,78 +97,77 @@ export class ComboboxMarkup {
53
97
  }
54
98
  });
55
99
  this.dropdown.scrollTo({ top: 0, behavior: 'smooth' });
56
- }, 200);
100
+ }, 400);
57
101
  }
58
- disconnect() {
59
- this.#shadowRoot.host.removeEventListener('focus', this.showDropdown);
60
- this.#shadowRoot.host.removeEventListener('blur', this.hideDropdown);
61
- this.#shadowRoot.host.removeEventListener('click', this.showDropdown);
62
- document.removeEventListener('click', this.hideDropdown);
63
- document.removeEventListener('scroll', this.onPageScroll);
64
- this.connected = false;
102
+ get options() {
103
+ return this.optionsContainer.querySelectorAll('box-option');
104
+ }
105
+ getTagControl(tag) {
106
+ return tag.querySelector('[part*="clear-tag"]');
65
107
  }
66
- onPageScroll = () => {
67
- if (this.dropdown.matches(':popover-open')) {
68
- this.setDropdownPosition(this.#shadowRoot.host.getBoundingClientRect());
108
+ #onDimensionsChanges = (entries) => {
109
+ const entry = entries[0];
110
+ if (entry) {
111
+ this.#hostWidth = entry.borderBoxSize[0].inlineSize;
112
+ this.#hostHeight = entry.borderBoxSize[0].blockSize;
69
113
  }
114
+ this.setDropdownPosition();
70
115
  };
71
- setDropdownPosition(rect) {
116
+ setDropdownPosition() {
72
117
  const dropdown = this.dropdown;
73
118
  const vh = window.innerHeight;
74
- const sh = rect.height;
75
- const sy = rect.top;
76
- if (sy < (vh - vh / 3)) {
77
- dropdown.style.top = sh + sy + 'px';
78
- dropdown.style.bottom = 'unset';
79
- dropdown.style.transform = `unset`;
80
- }
81
- else {
82
- dropdown.style.top = sh + sy + 'px';
83
- dropdown.style.bottom = 'unset';
84
- dropdown.style.transform = `translateY(calc(-1 * calc(100% + ${sh}px)))`;
85
- }
86
- dropdown.style.left = rect.left + 'px';
87
- dropdown.style.width = rect.width + 'px';
88
- dropdown.style.maxHeight = '50vh';
119
+ const sh = this.#hostHeight;
120
+ const sy = this.#hostTop;
121
+ // Calculate available space below and above
122
+ const spaceBelow = vh - (sy + sh);
123
+ const spaceAbove = sy;
124
+ // Determine if dropdown should go below or above
125
+ const shouldGoBelow = spaceBelow >= spaceAbove;
126
+ // Set dropdown to max 50vh or available space, whichever is smaller
127
+ const maxHeight = Math.min(50 * vh / 100, shouldGoBelow ? spaceBelow - 10 : spaceAbove - 10);
128
+ dropdown.style.width = this.#hostWidth + 'px';
129
+ dropdown.style.maxHeight = `${Math.max(100, maxHeight)}px`;
130
+ }
131
+ showDropdown() {
132
+ this.onFocusIn();
89
133
  }
90
- showDropdown = () => {
134
+ onFocusIn = () => {
135
+ if (this.#internals.ariaExpanded === "true")
136
+ return;
137
+ this.#hostTop = this.#shadowRoot.host.getBoundingClientRect().top;
91
138
  try {
92
- this.setDropdownPosition(this.#shadowRoot.host.getBoundingClientRect());
93
- this.dropdown.style.display = 'flex';
139
+ // this.dropdown!.style.display = 'flex';
140
+ // @ts-ignore
94
141
  this.dropdown.showPopover();
95
142
  this.#internals.ariaExpanded = "true";
96
- if (this.tagsContainer?.children.length === 0) {
97
- this.searchInput?.focus();
98
- }
99
- this.placeholder.innerText = '';
100
143
  }
101
- catch {
144
+ catch (e) {
102
145
  this.#internals.ariaExpanded = "false";
103
146
  }
104
147
  };
105
148
  closeDropdown() {
106
149
  try {
150
+ // @ts-ignore
107
151
  this.dropdown.hidePopover();
108
- this.dropdown.style.display = 'none';
152
+ // this.dropdown!.style.display = 'none';
109
153
  this.#internals.ariaExpanded = "false";
110
- this.placeholder.innerText = this.#shadowRoot.host.getAttribute('placeholder');
111
154
  }
112
155
  catch (e) {
113
156
  this.#internals.ariaExpanded = "true";
114
157
  }
115
158
  }
116
- hideDropdown = (event) => {
117
- if (event.composedPath().includes(this.#shadowRoot.host))
159
+ onFocusOut = () => {
160
+ if (this.#internals.ariaExpanded !== "true")
118
161
  return;
119
162
  this.closeDropdown();
120
163
  };
121
164
  createAndAppendTag(option) {
122
165
  const value = option.value;
123
- const userTagTemplate = this.#shadowRoot.host.firstElementChild;
166
+ const userTagTemplate = this.customTag;
124
167
  let tag;
125
168
  let button;
126
169
  if (userTagTemplate && userTagTemplate instanceof HTMLComboboxTagElement) {
127
- tag = userTagTemplate.cloneNode(true);
170
+ tag = document.importNode(userTagTemplate, true);
128
171
  tag.querySelectorAll('slot')
129
172
  .forEach(node => {
130
173
  const relatedNode = option.querySelector(`[slot="${node.name}"]`);
@@ -142,25 +185,29 @@ export class ComboboxMarkup {
142
185
  }
143
186
  else {
144
187
  const template = this.tagTemplate;
188
+ // console.log(this)
145
189
  tag = template.cloneNode(true);
146
- const label = tag.querySelector('[part="tag-label"]');
147
- label.textContent = option.textContent;
148
- button = tag.querySelector('[part="clear-tag"]');
190
+ // console.log(template)
191
+ const label = tag.querySelector('.cb-tag-label');
192
+ label.textContent = option.label || option.textContent;
193
+ button = tag.querySelector('.cb-clear-tag');
149
194
  }
150
195
  button?.setAttribute('value', value);
196
+ // button.addEventListener('blur', e => e.stopPropagation());
151
197
  tag.setAttribute('value', value);
152
198
  this.tagsContainer.appendChild(tag);
199
+ this.clearAllButton?.removeAttribute('disabled');
153
200
  return button;
154
201
  }
155
202
  getTagByValue(value) {
203
+ // console.log(this.tagsContainer)
156
204
  return this.tagsContainer.querySelector(`box-tag[value="${value}"]`);
157
205
  }
158
206
  getOptionByValue(value) {
159
207
  return this.optionsContainer.querySelector(`box-option[value="${value}"]`);
160
208
  }
161
209
  get selectedOptions() {
162
- return this.optionsContainer
163
- .querySelectorAll('box-option[selected]');
210
+ return this.optionsContainer.querySelectorAll('box-option[selected]');
164
211
  }
165
212
  static importCSS(urls) {
166
213
  ComboboxMarkup.template = ComboboxMarkup.template.replace(':host {', `
@@ -174,35 +221,65 @@ ${urls.map(url => `@import "${url}";`)}
174
221
  :host {
175
222
  font-size: inherit;
176
223
  font-family: inherit;
224
+ display: flow-root;
225
+ position: relative;
226
+ anchor-name: --position;
227
+ }
228
+
229
+ #cbwrapper {
230
+ min-block-size: 1lh;
177
231
  display: grid;
178
232
  grid-template-columns: minmax(0, max-content) 1fr;
179
233
  align-items: center;
180
234
  gap: 1px;
181
- position: relative;
235
+ border-radius: inherit;
182
236
  }
183
237
 
184
- :host([multiple]) {
238
+ :host([multiple]) #cbwrapper {
185
239
  grid-template-columns: minmax(0, max-content) 1fr auto;
186
240
  }
187
241
 
188
- #dropdown {
242
+ #cbdropdown {
243
+ contain: layout style paint;
189
244
  inset: unset;
190
245
  margin: 0;
246
+ /*position: absolute;*/
191
247
  box-sizing: border-box;
192
248
  overflow-y: scroll;
193
249
  flex-direction: column;
194
250
  border-radius: inherit;
195
251
  border-color: ButtonFace;
196
252
  border-width: inherit;
253
+ background-color: Canvas;
254
+ outline: none;
255
+ display: none;
256
+ top: anchor(bottom);
257
+ left: anchor(left);
258
+ position-anchor: --position;
259
+ position-try: normal flip-block;
260
+ }
261
+
262
+ #cbdropdown:popover-open {
263
+ display: flex;
264
+ }
265
+
266
+ #cbdropdown::-webkit-scrollbar {
267
+ display: none;
197
268
  }
198
269
 
199
- [part="options"] {
270
+ #cboptions {
200
271
  display: flex;
201
272
  flex-direction: column;
202
273
  justify-content: start;
203
274
  gap: 2px;
204
- padding-block: .5rem;
275
+ /*padding: inherit;*/
276
+ /*padding-block: .5rem;*/
205
277
  border-radius: inherit;
278
+
279
+ }
280
+
281
+ :host([filterable]) #cboptions, :host([searchable]) #cboptions {
282
+ padding-top: .5rem;
206
283
  }
207
284
 
208
285
  box-option {
@@ -210,8 +287,10 @@ ${urls.map(url => `@import "${url}";`)}
210
287
  border-radius: inherit;
211
288
  content-visibility: auto;
212
289
  cursor: pointer;
213
- padding-inline: 2px;
214
- padding-block: 1px;
290
+ padding: inherit;
291
+ padding-inline: .5lh;
292
+ /*padding-inline: 2px;*/
293
+ /*padding-block: 1px;*/
215
294
  }
216
295
 
217
296
  box-option:hover {
@@ -224,46 +303,56 @@ ${urls.map(url => `@import "${url}";`)}
224
303
  pointer-events: none;
225
304
  }
226
305
 
227
- [part="search-input"] {
306
+ #cbfilter {
228
307
  display: none;
229
308
  position: sticky;
230
309
  top: 0;
231
310
  z-index: 2;
232
- border-radius: inherit;
233
- border-style: inherit;
234
- border-width: inherit;
235
- border-color: inherit;
236
- padding: inherit;
311
+ /*border-radius: inherit;*/
312
+ /*border-style: inherit;*/
313
+ /*border-width: inherit;*/
314
+ /*border-color: inherit;*/
315
+ /*padding: inherit;*/
316
+ /*padding-inline: .4lh;*/
317
+ font-size: inherit;
318
+ outline: inherit;
319
+ /*&::placeholder {*/
320
+ /* color: var(--search-input-placeholder-color);*/
321
+ /*} */
237
322
  }
238
323
 
239
- :host([searchable]) [part="search-input"],
240
- :host([filterable]) [part="search-input"] {
241
- display: flex;
324
+ :host([searchable]) #cbfilter,
325
+ :host([filterable]) #cbfilter {
326
+ display: flow-root;
242
327
  }
243
328
 
244
- #placeholder {
329
+ #cbplaceholder {
245
330
  text-align: left;
246
331
  overflow: hidden;
247
332
  padding-inline-start: 2px;
248
- font-size: smaller;
333
+ /*font-size: smaller;*/
249
334
  color: dimgrey;
335
+ outline: none;
336
+ user-select: none;
250
337
  }
251
338
 
252
- #tags:not(:empty) + #placeholder {
339
+ #cbtags:not(:empty) + #cbplaceholder {
253
340
  display: none;
254
341
  }
255
342
 
256
- #tags:not(:empty) {
343
+ #cbtags:not(:empty) {
257
344
  grid-column: 1 / span 2;
258
345
  width: 100%;
259
346
  }
347
+
348
+
260
349
 
261
- #tags {
350
+ #cbtags {
262
351
  display: flex;
263
352
  flex-wrap: wrap;
264
353
  overflow: hidden;
265
354
  gap: 2px;
266
- border-radius: inherit;
355
+ /*border-radius: inherit;*/
267
356
  }
268
357
 
269
358
  box-tag {
@@ -272,13 +361,14 @@ ${urls.map(url => `@import "${url}";`)}
272
361
  box-sizing: border-box;
273
362
  display: flex;
274
363
  align-items: center;
275
- border-radius: inherit;
276
- padding-inline-start: 0.2lh;
277
- padding-inline-end: .2rem;
364
+ /*border-radius: inherit;*/
365
+ padding-inline-start: 0.4lh;
366
+ padding-inline-end: 0.2lh;
278
367
  background-color: transparent;
279
368
  gap: 5px;
280
- font-size: medium;
369
+ /*font-size: medium;*/
281
370
  text-transform: uppercase;
371
+ border-radius: 1lh;
282
372
  }
283
373
 
284
374
  :host([multiple]) box-tag {
@@ -287,79 +377,99 @@ ${urls.map(url => `@import "${url}";`)}
287
377
  max-width: 100%;
288
378
  }
289
379
 
290
- box-tag [part*="tag-label"] {
380
+ .cb-tag-label {
291
381
  white-space: nowrap;
292
382
  text-overflow: ellipsis;
293
383
  overflow: hidden;
294
384
  user-select: none;
295
- font-size: 95%;
385
+ /*font-size: 95%;*/
296
386
  flex-grow: 1;
297
387
  }
298
388
 
299
- :host([multiple]) box-tag[part*="tag-label"] {
389
+ :host([multiple]) .cb-tag-label {
300
390
  flex-grow: unset;
301
391
  }
302
392
 
303
- [part*="clear-tag"], [part*="clear-all"] {
393
+ .cb-clear-tag, #cbclear {
304
394
  border-radius: 100%;
305
395
  border: none;
306
396
  aspect-ratio: 1;
307
- line-height: 0;
397
+ /*line-height: 0;*/
308
398
  padding: 0!important;
309
399
  user-select: none;
310
400
  background-color: transparent;
311
401
  }
312
402
 
313
- [part*="clear-tag"] {
314
- inline-size: 1em;
315
- block-size: 1em;
316
- font-size: 80%;
403
+ .cb-clear-tag {
404
+ /*inline-size: 1lh;*/
405
+ block-size: 1lh;
406
+ font-size: .7lh;
407
+ text-transform: uppercase;
317
408
  display: none;
318
409
  }
410
+
411
+ .cb-clear-tag:focus-visible, .cb-clear-tag:focus {
412
+ outline-style: solid;
413
+ outline-width: 1px;
414
+ }
319
415
 
320
- :host([multiple]) [part*="clear-all"] {
416
+ :host([multiple]) #cbclear {
321
417
  display: block;
322
418
  }
323
419
 
324
- :host([clearable]) [part*="clear-tag"] {
325
- display: block;
420
+ :host([clearable]) #cbclear,
421
+ :host([multiple]) .cb-clear-tag {
422
+ display: grid;
423
+ place-content: center;
326
424
  }
327
425
 
328
- [part*="clear-all"] {
426
+ #cbclear {
329
427
  font-size: inherit;
330
- inline-size: 1.2em;
331
- block-size: 1.2em;
428
+ text-transform: uppercase;
429
+ /*inline-size: 1lh;*/
430
+ block-size: 1lh;
431
+ /*inline-size: 1.2em;*/
432
+ /*block-size: 1.2em;*/
332
433
  display: none;
333
434
  }
334
435
 
335
- [part*="clear-all"]:hover,
336
- [part*="clear-tag"]:hover {
436
+ #cbclear:hover, .cb-clear-tag:hover {
337
437
  color: ActiveText;
338
438
  cursor: pointer;
339
439
  }
340
440
 
341
- [part*="clear-all"]:hover {
441
+ #cbclear:hover {
342
442
  background-color: ButtonFace;
343
443
  }
344
444
 
345
- :host:has(#tags:empty) [part*="clear-all"] {
445
+ #cbtags:empty + #cbclear {
346
446
  pointer-events: none;
347
- color: darkgrey;
447
+ outline: none;
348
448
  }
349
449
  </style>
350
450
 
351
- <div id="tags"></div>
352
- <div id="placeholder">&nbsp;</div>
353
- <button part="clear-all">✕</button>
354
- <div id="dropdown" popover="manual">
355
- <input name="search-input" part="search-input" />
356
- <div part="options"></div>
451
+ <div id='cbwrapper' part='internal'>
452
+ <div id="cbtags" part='tags'></div>
453
+ <div id="cbplaceholder" part='placeholder' tabindex='0'>Выбрать</div>
454
+ <button id="cbclear" part="clear-all">✕</button>
357
455
  </div>
456
+
457
+ <div id="cbdropdown" popover="manual" part='dropdown'>
458
+ <input id="cbfilter" placeholder='Поиск' name="filter" part="search-input" inputmode='search' autofocus />
459
+ <div part="options" id='cboptions'></div>
460
+ </div>
461
+
358
462
  <template id='tag-template'>
359
463
  <box-tag>
360
- <span part="tag-label"></span>
361
- <button part="clear-tag">✕</button>
464
+ <span class="cb-tag-label" part="tag-label"></span>
465
+ <div class="cb-clear-tag" part="clear-tag" role='button' tabindex='0'>✕</div>
362
466
  </box-tag>
363
467
  </template>
364
468
  `;
469
+ static markup;
470
+ static {
471
+ const template = document.createElement('template');
472
+ template.innerHTML = ComboboxMarkup.template;
473
+ ComboboxMarkup.markup = template;
474
+ }
365
475
  }