html-combobox-element 0.0.2-beta.6 → 0.0.2-beta.7

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