standard-ui/components/dist/select.js
2023-05-25 19:40:46 +05:30

492 lines
16 KiB
JavaScript

const smSelect = document.createElement('template')
smSelect.innerHTML = `
<style>
*{
padding: 0;
margin: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
:host{
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
:host([disabled]) .select{
opacity: 0.6;
cursor: not-allowed;
}
:host([readonly]) .select{
cursor: default;
pointer-events: none;
}
.select{
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
cursor: pointer;
width: 100%;
-webkit-tap-highlight-color: transparent;
}
.icon {
height: 1.2rem;
width: 1.2rem;
margin-left: 0.5rem;
fill: rgba(var(--text-color, (17,17,17)), 0.7);
}
.selected-option-text{
font-size: inherit;
overflow: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.selection{
border-radius: var(--select-border-radius,0.5rem);
display: -ms-grid;
display: grid;
-ms-grid-columns: 1fr auto;
grid-template-columns: 1fr auto;
grid-template-areas: 'heading heading' '. .';
padding: var(--padding,0.6rem 0.8rem);
background: var(--background, rgba(var(--text-color,(17,17,17)), 0.06));
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
outline: none;
z-index: 2;
height: 100%;
}
.selection:focus{
-webkit-box-shadow: 0 0 0 0.1rem var(--accent-color, teal) inset;
box-shadow: 0 0 0 0.1rem var(--accent-color, teal) inset;
}
:host([align-select="left"]) .options{
left: 0;
}
:host([align-select="right"]) .options{
right: 0;
}
.options{
top: 100%;
padding: var(--options-padding, 0.3rem);
margin-top: 0.2rem;
overflow: hidden auto;
position: absolute;
grid-area: options;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
width: var(--options-width, 100%);
min-width: var(--min-width, auto);
max-height: var(--max-height, auto);
background: rgba(var(--foreground-color,(255,255,255)), 1);
border: solid 1px rgba(var(--text-color,(17,17,17)), 0.2);
border-radius: var(--options-border-radius, 0.5rem);
z-index: 1;
box-shadow: 0 1rem 1.5rem rgba(0 0 0 /0.2);
}
:host([isUnder]) .options{
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: 0.2rem;
box-shadow: 0 -1rem 1.5rem rgba(0 0 0 /0.2);
}
:host([open]) .icon--expand{
display: none;
}
:host([open]) .icon--collapse{
display: block;
}
.icon--expand{
display: block;
}
.icon--collapse{
display: none;
}
.hidden{
display: none;
}
@media (any-hover: hover){
::-webkit-scrollbar{
width: 0.5rem;
height: 0.5rem;
}
::-webkit-scrollbar-thumb{
background: rgba(var(--text-color,(17,17,17)), 0.3);
border-radius: 1rem;
&:hover{
background: rgba(var(--text-color,(17,17,17)), 0.5);
}
}
}
</style>
<div class="select">
<div class="selection">
<div class="selected-option-text"></div>
<svg class="icon icon--expand" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></svg>
<svg class="icon icon--collapse" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M24 0v24H0V0h24z" fill="none" opacity=".87"/><path d="M7.41 18.59L8.83 20 12 16.83 15.17 20l1.41-1.41L12 14l-4.59 4.59zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10l4.59-4.59z"/></svg>
</div>
<div part="options" class="options hidden">
<slot></slot>
</div>
</div>`;
customElements.define('sm-select', class extends HTMLElement {
constructor() {
super()
this.attachShadow({
mode: 'open'
}).append(smSelect.content.cloneNode(true))
this.focusIn = this.focusIn.bind(this)
this.reset = this.reset.bind(this)
this.open = this.open.bind(this)
this.collapse = this.collapse.bind(this)
this.toggle = this.toggle.bind(this)
this.handleOptionsNavigation = this.handleOptionsNavigation.bind(this)
this.handleOptionSelection = this.handleOptionSelection.bind(this)
this.handleKeydown = this.handleKeydown.bind(this)
this.handleClickOutside = this.handleClickOutside.bind(this)
this.selectOption = this.selectOption.bind(this)
this.debounce = this.debounce.bind(this)
this.elementsChanged = this.elementsChanged.bind(this)
this.availableOptions = []
this.previousOption
this.isOpen = false;
this.label = ''
this.defaultSelected = ''
this.isUnderViewport = false
this.animationOptions = {
duration: 300,
fill: "forwards",
easing: 'ease'
}
this.optionList = this.shadowRoot.querySelector('.options')
this.selection = this.shadowRoot.querySelector('.selection')
this.selectedOptionText = this.shadowRoot.querySelector('.selected-option-text')
}
static get observedAttributes() {
return ['disabled', 'label', 'readonly']
}
get value() {
return this.getAttribute('value')
}
set value(val) {
const selectedOption = this.availableOptions.find(option => option.getAttribute('value') === val)
if (selectedOption) {
this.setAttribute('value', val)
this.selectOption(selectedOption)
} else {
console.warn(`There is no option with ${val} as value`)
}
}
debounce(callback, wait) {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback.apply(null, args);
}, wait);
};
}
reset(fire = true) {
if (this.availableOptions[0] && this.previousOption !== this.availableOptions[0]) {
const selectedOption = this.availableOptions.find(option => option.hasAttribute('selected')) || this.availableOptions[0];
this.value = selectedOption.getAttribute('value')
if (fire) {
this.fireEvent()
}
}
}
selectOption(selectedOption) {
if (this.previousOption !== selectedOption) {
this.querySelectorAll('[selected]').forEach(option => option.removeAttribute('selected'))
this.selectedOptionText.textContent = `${this.label}${selectedOption.textContent}`;
selectedOption.setAttribute('selected', '')
this.previousOption = selectedOption
}
}
focusIn() {
this.selection.focus()
}
open() {
this.availableOptions.forEach(option => option.setAttribute('tabindex', 0))
this.optionList.classList.remove('hidden')
this.isUnderViewport = this.getBoundingClientRect().bottom + this.optionList.getBoundingClientRect().height > window.innerHeight;
if (this.isUnderViewport) {
this.setAttribute('isUnder', '')
} else {
this.removeAttribute('isUnder')
}
this.optionList.animate([
{
transform: `translateY(${this.isUnderViewport ? '' : '-'}0.5rem)`,
opacity: 0
},
{
transform: `translateY(0)`,
opacity: 1
}
], this.animationOptions)
this.setAttribute('open', '');
this.style.zIndex = 1000;
(this.availableOptions.find(option => option.hasAttribute('selected')) || this.availableOptions[0]).focus()
document.addEventListener('mousedown', this.handleClickOutside)
this.isOpen = true
}
collapse() {
this.removeAttribute('open')
this.optionList.animate([
{
transform: `translateY(0)`,
opacity: 1
},
{
transform: `translateY(${this.isUnderViewport ? '' : '-'}0.5rem)`,
opacity: 0
},
], this.animationOptions)
.onfinish = () => {
this.availableOptions.forEach(option => option.removeAttribute('tabindex'))
document.removeEventListener('mousedown', this.handleClickOutside)
this.optionList.classList.add('hidden')
this.isOpen = false
this.style.zIndex = 'auto';
}
}
toggle() {
if (!this.isOpen && !this.hasAttribute('disabled')) {
this.open()
} else {
this.collapse()
}
}
fireEvent() {
this.dispatchEvent(new CustomEvent('change', {
bubbles: true,
composed: true,
detail: {
value: this.value
}
}))
}
handleOptionsNavigation(e) {
if (e.key === 'ArrowUp') {
e.preventDefault()
if (document.activeElement.previousElementSibling) {
document.activeElement.previousElementSibling.focus()
} else {
this.availableOptions[this.availableOptions.length - 1].focus()
}
} else if (e.key === 'ArrowDown') {
e.preventDefault()
if (document.activeElement.nextElementSibling) {
document.activeElement.nextElementSibling.focus()
} else {
this.availableOptions[0].focus()
}
}
}
handleOptionSelection(e) {
if (this.previousOption !== document.activeElement) {
this.value = document.activeElement.getAttribute('value')
this.fireEvent()
}
}
handleClick(e) {
if (e.target === this) {
this.toggle()
}
else {
this.handleOptionSelection()
this.collapse()
}
}
handleKeydown(e) {
if (e.target === this) {
if (this.isOpen && e.key === 'ArrowDown') {
e.preventDefault();
(this.availableOptions.find(option => option.hasAttribute('selected')) || this.availableOptions[0]).focus()
this.handleOptionSelection(e)
} else if (e.key === ' ') {
e.preventDefault()
this.toggle()
}
} else {
this.handleOptionsNavigation(e)
this.handleOptionSelection(e)
if (['Enter', ' ', 'Escape', 'Tab'].includes(e.key)) {
e.preventDefault()
this.collapse()
this.focusIn()
}
}
}
handleClickOutside(e) {
if (this.isOpen && !this.contains(e.target)) {
this.collapse()
}
}
elementsChanged() {
this.availableOptions = [...this.querySelectorAll('sm-option')];
this.reset(false)
this.defaultSelected = this.value
}
connectedCallback() {
this.setAttribute('role', 'listbox')
if (!this.hasAttribute('disabled') && !this.hasAttribute('readonly')) {
this.selection.setAttribute('tabindex', '0')
this.addEventListener('click', this.handleClick)
this.addEventListener('keydown', this.handleKeydown)
}
const updateDecedents = this.debounce(this.elementsChanged, 100);
this.shadowRoot.querySelector('slot').addEventListener('slotchange', updateDecedents);
this.mutationObserver = new MutationObserver(mutations => {
let attributesChanged = false;
mutations.forEach(mutation => {
switch (mutation.type) {
case 'childList':
updateDecedents();
break;
case 'attributes':
attributesChanged = true;
break;
}
});
if (attributesChanged) {
const selectedOption = this.availableOptions.find(option => option.hasAttribute('selected')) || this.availableOptions[0];
this.selectedOptionText.textContent = `${this.label}${selectedOption.textContent}`;
this.setAttribute('value', selectedOption.getAttribute('value'));
}
});
this.mutationObserver.observe(this, { subtree: true, childList: true, attributeFilter: ["selected"] });
new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const offsetLeft = this.selection.getBoundingClientRect().left
if (offsetLeft < window.innerWidth / 2) {
this.setAttribute('align-select', 'left')
} else {
this.setAttribute('align-select', 'right')
}
}
})
}).observe(this)
}
disconnectedCallback() {
this.removeEventListener('click', this.handleClick)
this.removeEventListener('keydown', this.handleKeydown)
}
attributeChangedCallback(name) {
if (name === "disabled" || name === "readonly") {
if (this.hasAttribute('disabled') || this.hasAttribute('readonly')) {
this.selection.removeAttribute('tabindex')
this.removeEventListener('click', this.handleClick)
this.removeEventListener('keydown', this.handleKeydown)
} else {
this.selection.setAttribute('tabindex', '0')
this.addEventListener('click', this.handleClick)
this.addEventListener('keydown', this.handleKeydown)
}
} else if (name === 'label') {
this.label = this.hasAttribute('label') ? `${this.getAttribute('label')} ` : ''
}
}
})
// option
const smOption = document.createElement('template')
smOption.innerHTML = `
<style>
*{
padding: 0;
margin: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
:host{
display: -webkit-box;
display: -ms-flexbox;
display: flex;
overflow: hidden;
border-radius: var(--border-radius, 0.3rem);
}
.option{
position: relative;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
width: 100%;
gap: 0.5rem;
padding: var(--padding, 0.6rem 1rem);
cursor: pointer;
outline: none;
user-select: none;
}
.option::before{
position: absolute;
content: '';
display: block;
width: 0.2rem;
height: 1em;
left: 0;
border-radius: 0 1em 1em 0;
background: rgba(var(--text-color,(17,17,17)), 0.5);
transition: all 0.2s ease-in-out;
opacity: 0;
}
:host(:focus){
outline: none;
background: rgba(var(--text-color,(17,17,17)), 0.1);
}
:host(:focus) .option::before{
opacity: 1
}
:host([selected]) .option::before{
opacity: 1;
background: var(--accent-color, teal);
}
@media (hover: hover){
.option:hover{
background: rgba(var(--text-color,(17,17,17)), 0.1);
}
:host(:not([selected]):hover) .option::before{
opacity: 1
}
}
</style>
<div class="option" part="option">
<slot></slot>
</div>`;
customElements.define('sm-option', class extends HTMLElement {
constructor() {
super()
this.attachShadow({
mode: 'open'
}).append(smOption.content.cloneNode(true))
}
connectedCallback() {
this.setAttribute('role', 'option')
}
})