Додані повідомлення та перепрацьована структура застосунку та api
This commit is contained in:
292
web/lib/customElements/smartSelect.js
Normal file
292
web/lib/customElements/smartSelect.js
Normal file
@@ -0,0 +1,292 @@
|
||||
const SMART_SELECT_STYLES_CSS = `
|
||||
:host { display: block; position: relative; width: 100%; font-family: system-ui, sans-serif; }
|
||||
:host ::-webkit-scrollbar { height: 5px; width: 8px; }
|
||||
:host ::-webkit-scrollbar-track { background: transparent; }
|
||||
:host ::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
|
||||
@media (hover: hover) {
|
||||
:host ::-webkit-scrollbar-thumb:hover { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Стили для скролла */
|
||||
.trigger::-webkit-scrollbar { height: 4px; }
|
||||
.trigger::-webkit-scrollbar-thumb { background: var(--smart-select-chip-background, #475569); border-radius: 4px; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 35px;
|
||||
border: 1px solid var(--smart-select-border-color, #ccc);
|
||||
border-radius: var(--smart-select-border-radius-1, 6px);
|
||||
display: flex;
|
||||
padding: 0 6px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--smart-select-background, #fff);
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
color: var(--smart-select-chip-color);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
font-size: var(--smart-select-font-size-2);
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--smart-select-chip-background, #dbe3ea);
|
||||
color: var(--smart-select-chip-color, #000);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
font-size: var(--smart-select-font-size-1, 12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 15px;
|
||||
justify-content: flex-end;
|
||||
fill: var(--smart-select-chip-fill, #e91e63);
|
||||
}
|
||||
|
||||
.chip button svg{ width: 15px; height: 15px; }
|
||||
|
||||
.dropdown {
|
||||
display: none; position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: var(--smart-select-background, #ffffff);
|
||||
border: 1px solid var(--smart-select-border-color, #ccc);
|
||||
border-radius: var(--smart-select-border-radius-1, 6px); z-index: 9999; margin-top: 4px;
|
||||
flex-direction: column; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
:host([open]) .dropdown { display: flex; }
|
||||
|
||||
input[type="search"] {
|
||||
margin: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
border: 1px solid var(--smart-select-search-border, #ccc);
|
||||
background: transparent;
|
||||
color: var(--smart-select-search-color, #000);
|
||||
}
|
||||
|
||||
.options-list {max-height: 200px; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
::slotted([slot="option"]) {
|
||||
padding: 8px 12px !important;
|
||||
cursor: pointer;
|
||||
border-radius: var(--smart-select-border-radius-2, 4px);
|
||||
display: block;
|
||||
color: var(--smart-select-option-color, #000);
|
||||
font-size: var(--smart-select-font-size-2, 14px);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
::slotted([slot="option"]:hover) {
|
||||
background: var(--smart-select-hover-background, #475569);
|
||||
color: var(--smart-select-hover-color, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
::slotted([slot="option"].selected) {
|
||||
background: var(--smart-select-selected-background, #dbe3eb) !important;
|
||||
color: var(--smart-select-selected-color, #000) !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Створення об'єкта CSSStyleSheet (якщо підтримується)
|
||||
let SmartSelectStyles = null;
|
||||
if (typeof CSSStyleSheet !== 'undefined' && CSSStyleSheet.prototype.replaceSync) {
|
||||
SmartSelectStyles = new CSSStyleSheet(); // (2) Визначення об'єкта тут
|
||||
SmartSelectStyles.replaceSync(SMART_SELECT_STYLES_CSS);
|
||||
}
|
||||
|
||||
class SmartSelect extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._selectedValues = new Set();
|
||||
|
||||
// Додаємо стилі в конструкторі, якщо це adoptable
|
||||
if (this.shadowRoot.adoptedStyleSheets) {
|
||||
this.shadowRoot.adoptedStyleSheets = [SmartSelectStyles];
|
||||
} else {
|
||||
// FALLBACK для старих браузерів (наприклад, iOS < 16.4)
|
||||
const style = document.createElement('style');
|
||||
style.textContent = SMART_SELECT_STYLES_CSS;
|
||||
this.shadowRoot.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
|
||||
const searchInput = this.shadowRoot.querySelector('input[type="search"]');
|
||||
searchInput.addEventListener('input', (e) => this.handleSearch(e));
|
||||
|
||||
const wrapper = this.shadowRoot.querySelector('.wrapper');
|
||||
wrapper.addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
this.toggleAttribute('open');
|
||||
if (this.hasAttribute('open')) {
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Слушаем клики по элементам в слоте
|
||||
this.addEventListener('click', (e) => {
|
||||
const opt = e.target.closest('[slot="option"]');
|
||||
if (opt) {
|
||||
this.toggleValue(opt.getAttribute('data-value'), opt);
|
||||
}
|
||||
});
|
||||
|
||||
// Слушаем изменение слота для инициализации (фикс Safari)
|
||||
this.shadowRoot.querySelector('slot').addEventListener('slotchange', () => {
|
||||
this.syncOptions();
|
||||
});
|
||||
|
||||
this.syncOptions();
|
||||
}
|
||||
|
||||
_formatValue(val) {
|
||||
const isNumber = this.getAttribute('type') === 'number';
|
||||
return isNumber ? Number(val) : String(val);
|
||||
}
|
||||
|
||||
syncOptions() {
|
||||
const options = Array.from(this.querySelectorAll('[slot="option"]'));
|
||||
options.forEach(opt => {
|
||||
// Используем data-selected вместо атрибута selected
|
||||
if (opt.hasAttribute('data-selected')) {
|
||||
// Зберігаємо вже у потрібному форматі
|
||||
const val = this._formatValue(opt.getAttribute('data-value'));
|
||||
this._selectedValues.add(val);
|
||||
}
|
||||
});
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
handleSearch(e) {
|
||||
const term = e.target.value.toLowerCase().trim();
|
||||
const options = this.querySelectorAll('[slot="option"]');
|
||||
options.forEach(opt => {
|
||||
const text = opt.textContent.toLowerCase();
|
||||
opt.style.display = text.includes(term) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
toggleValue(val) {
|
||||
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
|
||||
|
||||
const formattedVal = this._formatValue(val);
|
||||
|
||||
if (this._selectedValues.has(formattedVal)) {
|
||||
this._selectedValues.delete(formattedVal);
|
||||
this._click = {
|
||||
value: formattedVal,
|
||||
state: "delete"
|
||||
};
|
||||
} else {
|
||||
if (max && this._selectedValues.size >= max) return;
|
||||
this._selectedValues.add(formattedVal);
|
||||
this._click = {
|
||||
value: formattedVal,
|
||||
state: "add"
|
||||
};
|
||||
}
|
||||
|
||||
this.updateDisplay();
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
updateDisplay() {
|
||||
const container = this.shadowRoot.getElementById('tags');
|
||||
const placeholder = this.shadowRoot.getElementById('placeholder');
|
||||
const optionsElements = this.querySelectorAll('[slot="option"]');
|
||||
|
||||
// Керування видимістю плейсхолдера
|
||||
if (this._selectedValues.size > 0) {
|
||||
placeholder.style.display = 'none';
|
||||
} else {
|
||||
placeholder.style.display = 'block';
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
this._selectedValues.forEach(val => {
|
||||
// Важливо: при пошуку елемента в DOM атрибут data-value завжди рядок,
|
||||
// тому використовуємо == для порівняння числа з рядком
|
||||
const opt = Array.from(optionsElements).find(o => o.getAttribute('data-value') == val);
|
||||
if (opt) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'chip';
|
||||
chip.innerHTML = `${opt.textContent} <button><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26"><path d="M 6.65625 4 C 6.367188 4 6.105469 4.113281 5.90625 4.3125 L 4.3125 5.90625 C 3.914063 6.304688 3.914063 7 4.3125 7.5 L 9.8125 13 L 4.3125 18.5 C 3.914063 19 3.914063 19.695313 4.3125 20.09375 L 5.90625 21.6875 C 6.40625 22.085938 7.101563 22.085938 7.5 21.6875 L 13 16.1875 L 18.5 21.6875 C 19 22.085938 19.695313 22.085938 20.09375 21.6875 L 21.6875 20.09375 C 22.085938 19.59375 22.085938 18.898438 21.6875 18.5 L 16.1875 13 L 21.6875 7.5 C 22.085938 7 22.085938 6.304688 21.6875 5.90625 L 20.09375 4.3125 C 19.59375 3.914063 18.898438 3.914063 18.5 4.3125 L 13 9.8125 L 7.5 4.3125 C 7.25 4.113281 6.945313 4 6.65625 4 Z"></path></svg></button>`;
|
||||
chip.querySelector('button').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleValue(val);
|
||||
};
|
||||
container.appendChild(chip);
|
||||
}
|
||||
});
|
||||
|
||||
const max = this.getAttribute('max') ? parseInt(this.getAttribute('max')) : null;
|
||||
const isFull = max && this._selectedValues.size >= max;
|
||||
|
||||
optionsElements.forEach(opt => {
|
||||
const optVal = this._formatValue(opt.getAttribute('data-value'));
|
||||
const isSelected = this._selectedValues.has(optVal);
|
||||
opt.classList.toggle('selected', isSelected);
|
||||
|
||||
// Если лимит исчерпан, делаем невыбранные опции полупрозрачными
|
||||
if (isFull && !isSelected) {
|
||||
opt.style.opacity = '0.5';
|
||||
opt.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
opt.style.opacity = '1';
|
||||
opt.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get value() {
|
||||
return Array.from(this._selectedValues);
|
||||
}
|
||||
|
||||
get getClick() {
|
||||
return this._click;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<div class="wrapper">
|
||||
<div class="trigger" id="tags"></div>
|
||||
<div id="placeholder" class="placeholder-text">
|
||||
${this.getAttribute('placeholder') || 'Оберіть значення...'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<input type="search" placeholder="Пошук...">
|
||||
<div class="options-list">
|
||||
<slot name="option"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('smart-select', SmartSelect);
|
||||
Reference in New Issue
Block a user