Files
dees-catalog/ts_web/elements/dees-input-dropdown.ts

465 lines
14 KiB
TypeScript
Raw Normal View History

2023-12-08 18:15:40 +01:00
import {
customElement,
type TemplateResult,
property,
state,
html,
css,
cssManager,
} from '@design.estate/dees-element';
2023-08-07 19:13:29 +02:00
import * as domtools from '@design.estate/dees-domtools';
2023-11-29 17:20:32 +01:00
import { demoFunc } from './dees-input-dropdown.demo.js';
import { DeesInputBase } from './dees-input-base.js';
2020-09-13 16:24:48 +00:00
2021-03-06 15:48:02 +00:00
declare global {
interface HTMLElementTagNameMap {
'dees-input-dropdown': DeesInputDropdown;
}
}
2020-09-13 16:24:48 +00:00
@customElement('dees-input-dropdown')
export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
2023-12-08 18:15:40 +01:00
public static demo = demoFunc;
2020-12-09 23:05:13 +00:00
2021-08-20 00:25:14 +02:00
// INSTANCE
2020-09-13 16:24:48 +00:00
@property()
2023-12-08 18:15:40 +01:00
public options: { option: string; key: string; payload?: any }[] = [];
2020-09-13 16:24:48 +00:00
@property()
2023-12-08 18:15:40 +01:00
public selectedOption: { option: string; key: string; payload?: any } = null;
2020-09-13 16:24:48 +00:00
// Add value property for form compatibility
public get value() {
return this.selectedOption;
}
public set value(val: { option: string; key: string; payload?: any }) {
this.selectedOption = val;
}
2021-08-26 21:30:35 +02:00
@property({
2023-12-08 18:15:40 +01:00
type: Boolean,
})
public enableSearch: boolean = true;
@state()
public opensToTop: boolean = false;
2023-12-20 19:09:55 +01:00
@state()
private filteredOptions: { option: string; key: string; payload?: any }[] = [];
@state()
private highlightedIndex: number = 0;
@state()
public isOpened = false;
2025-06-27 16:20:06 +00:00
@state()
private searchValue: string = '';
2023-03-25 17:30:41 +01:00
public static styles = [
...DeesInputBase.baseStyles,
2023-03-25 17:30:41 +01:00
cssManager.defaultStyles,
css`
* {
2023-12-08 18:15:40 +01:00
box-sizing: border-box;
}
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
:host {
2025-06-27 16:20:06 +00:00
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
2023-12-08 18:15:40 +01:00
position: relative;
2025-06-27 16:20:06 +00:00
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
2023-12-08 18:15:40 +01:00
}
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
.maincontainer {
display: block;
2025-06-27 16:20:06 +00:00
position: relative;
2023-12-08 18:15:40 +01:00
}
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
.selectedBox {
user-select: none;
position: relative;
2025-06-27 16:20:06 +00:00
width: 100%;
2023-12-08 18:15:40 +01:00
height: 40px;
2025-06-27 16:20:06 +00:00
line-height: 38px;
padding: 0 40px 0 12px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
transition: all 0.15s ease;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
2023-12-08 18:15:40 +01:00
}
2023-03-25 20:56:12 +01:00
2025-06-27 16:20:06 +00:00
.selectedBox:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
2023-12-26 21:21:18 +01:00
}
2025-06-27 16:20:06 +00:00
.selectedBox:focus-visible {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.1)', 'hsl(217.2 91.2% 59.8% / 0.1)')};
2023-12-08 18:15:40 +01:00
}
2020-09-13 16:24:48 +00:00
2025-06-27 16:20:06 +00:00
.selectedBox.disabled {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
cursor: not-allowed;
opacity: 0.5;
}
/* Dropdown arrow */
.selectedBox::after {
content: '';
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
transition: transform 0.15s ease;
}
.selectedBox.open::after {
transform: translateY(-50%) rotate(180deg);
2023-12-08 18:15:40 +01:00
}
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
.selectionBox {
2025-06-27 16:20:06 +00:00
will-change: transform, opacity;
2023-12-08 18:15:40 +01:00
pointer-events: none;
2025-06-27 16:20:06 +00:00
transition: all 0.15s ease;
2023-12-08 18:15:40 +01:00
opacity: 0;
2025-06-27 16:20:06 +00:00
transform: translateY(-8px) scale(0.98);
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
2023-12-08 18:15:40 +01:00
min-height: 40px;
2025-06-27 16:20:06 +00:00
max-height: 300px;
overflow: hidden;
border-radius: 6px;
2023-12-08 18:15:40 +01:00
position: absolute;
2023-12-20 19:09:55 +01:00
user-select: none;
2025-06-27 16:20:06 +00:00
margin-top: 4px;
z-index: 50;
left: 0;
right: 0;
2023-12-08 18:15:40 +01:00
}
2025-06-27 16:20:06 +00:00
2024-01-10 05:11:55 +01:00
.selectionBox.top {
2025-06-27 16:20:06 +00:00
bottom: calc(100% + 4px);
top: auto;
margin-top: 0;
margin-bottom: 4px;
transform: translateY(8px) scale(0.98);
2024-01-10 05:11:55 +01:00
}
2025-06-27 16:20:06 +00:00
2024-01-10 05:11:55 +01:00
.selectionBox.bottom {
2025-06-27 16:20:06 +00:00
top: 100%;
2024-01-10 05:11:55 +01:00
}
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
.selectionBox.show {
pointer-events: all;
2025-06-27 16:20:06 +00:00
transform: translateY(0) scale(1);
2023-12-08 18:15:40 +01:00
opacity: 1;
}
2020-09-13 16:24:48 +00:00
2025-06-27 16:20:06 +00:00
/* Options container */
.options-container {
max-height: 250px;
overflow-y: auto;
padding: 4px;
}
/* Options */
2023-12-08 18:15:40 +01:00
.option {
2025-06-27 16:20:06 +00:00
transition: all 0.15s ease;
2023-12-20 19:09:55 +01:00
line-height: 32px;
2025-06-27 16:20:06 +00:00
padding: 0 8px;
border-radius: 4px;
margin: 2px 0;
cursor: pointer;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
2023-12-20 19:09:55 +01:00
}
.option.highlighted {
2025-06-27 16:20:06 +00:00
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
2023-12-08 18:15:40 +01:00
}
.option:hover {
2025-06-27 16:20:06 +00:00
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
2023-12-08 18:15:40 +01:00
}
2025-06-27 16:20:06 +00:00
/* No options message */
.no-options {
padding: 8px;
text-align: center;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
font-style: italic;
2024-01-10 05:11:55 +01:00
}
2025-06-27 16:20:06 +00:00
/* Search */
.search {
padding: 4px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 4px;
}
2024-01-10 05:11:55 +01:00
.search.bottom {
2025-06-27 16:20:06 +00:00
border-bottom: none;
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 0;
margin-top: 4px;
2023-12-08 18:15:40 +01:00
}
2025-06-27 16:20:06 +00:00
2023-12-08 18:15:40 +01:00
.search input {
display: block;
2024-01-10 05:11:55 +01:00
width: 100%;
2025-06-27 16:20:06 +00:00
height: 32px;
padding: 0 8px;
background: transparent;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
color: inherit;
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
2023-12-08 18:15:40 +01:00
}
2025-06-27 16:20:06 +00:00
.search input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
2024-01-10 05:11:55 +01:00
}
2023-12-08 18:15:40 +01:00
.search input:focus {
2025-06-27 16:20:06 +00:00
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
}
/* Scrollbar styling */
.options-container::-webkit-scrollbar {
width: 8px;
}
.options-container::-webkit-scrollbar-track {
background: transparent;
}
.options-container::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
}
.options-container::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
2023-12-08 18:15:40 +01:00
}
`,
];
2023-03-25 17:30:41 +01:00
public render(): TemplateResult {
return html`
<div class="input-wrapper">
2025-06-27 16:20:06 +00:00
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
<div class="maincontainer">
<div
class="selectedBox ${this.isOpened ? 'open' : ''} ${this.disabled ? 'disabled' : ''}"
@click="${() => !this.disabled && this.toggleSelectionBox()}"
tabindex="${this.disabled ? '-1' : '0'}"
@keydown="${this.handleSelectedBoxKeydown}"
>
${this.selectedOption?.option || 'Select an option'}
</div>
<div class="selectionBox ${this.isOpened ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}">
${this.enableSearch
? html`
<div class="search">
<input
type="text"
placeholder="Search options..."
.value="${this.searchValue}"
@input="${this.handleSearch}"
@click="${(e: Event) => e.stopPropagation()}"
@keydown="${this.handleSearchKeydown}"
/>
</div>
`
: null}
<div class="options-container">
${this.filteredOptions.length === 0
? html`<div class="no-options">No options found</div>`
: this.filteredOptions.map((option, index) => {
const isHighlighted = this.highlightedIndex === index;
return html`
<div
class="option ${isHighlighted ? 'highlighted' : ''}"
@click="${() => this.updateSelection(option)}"
@mouseenter="${() => this.highlightedIndex = index}"
>
${option.option}
</div>
`;
})
}
</div>
</div>
2020-09-13 16:24:48 +00:00
</div>
</div>
`;
}
2025-06-27 16:20:06 +00:00
async connectedCallback() {
super.connectedCallback();
this.handleClickOutside = this.handleClickOutside.bind(this);
}
2020-09-13 16:24:48 +00:00
firstUpdated() {
2024-01-09 13:57:53 +01:00
this.selectedOption = this.selectedOption || null;
2025-06-27 16:20:06 +00:00
this.filteredOptions = this.options;
2020-09-13 16:24:48 +00:00
}
2025-06-27 16:20:06 +00:00
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('options')) {
this.filteredOptions = this.options;
}
}
public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
2020-09-13 16:24:48 +00:00
this.selectedOption = selectedOption;
2025-06-27 16:20:06 +00:00
this.isOpened = false;
this.searchValue = '';
this.filteredOptions = this.options;
this.highlightedIndex = 0;
2020-09-13 16:24:48 +00:00
2023-12-08 18:15:40 +01:00
this.dispatchEvent(
new CustomEvent('selectedOption', {
detail: selectedOption,
bubbles: true,
})
);
2025-06-27 16:20:06 +00:00
2021-08-20 00:25:14 +02:00
this.changeSubject.next(this);
2020-09-13 16:24:48 +00:00
}
2025-06-27 16:20:06 +00:00
private handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath();
if (!path.includes(this)) {
this.isOpened = false;
this.searchValue = '';
this.filteredOptions = this.options;
document.removeEventListener('click', this.handleClickOutside);
}
};
2023-12-08 18:15:40 +01:00
public async toggleSelectionBox() {
2023-12-20 19:09:55 +01:00
this.isOpened = !this.isOpened;
2025-06-27 16:20:06 +00:00
if (this.isOpened) {
// Check available space and set position
const selectedBox = this.shadowRoot.querySelector('.selectedBox') as HTMLElement;
const rect = selectedBox.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
2025-06-26 18:37:49 +00:00
2025-06-27 16:20:06 +00:00
// Determine if we should open upwards
this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
2025-06-26 20:20:34 +00:00
2025-06-27 16:20:06 +00:00
// Focus search input if present
await this.updateComplete;
const searchInput = this.shadowRoot.querySelector('.search input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
2025-06-26 20:20:34 +00:00
2025-06-27 16:20:06 +00:00
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
2023-12-08 18:15:40 +01:00
} else {
2025-06-27 16:20:06 +00:00
// Cleanup
this.searchValue = '';
this.filteredOptions = this.options;
document.removeEventListener('click', this.handleClickOutside);
2023-12-08 18:15:40 +01:00
}
2023-11-29 17:20:32 +01:00
}
2023-12-20 19:09:55 +01:00
private handleSearch(event: Event): void {
2025-06-27 16:20:06 +00:00
const searchTerm = (event.target as HTMLInputElement).value;
this.searchValue = searchTerm;
const searchLower = searchTerm.toLowerCase();
2023-12-20 19:09:55 +01:00
this.filteredOptions = this.options.filter((option) =>
2025-06-27 16:20:06 +00:00
option.option.toLowerCase().includes(searchLower)
2023-12-20 19:09:55 +01:00
);
2025-06-27 16:20:06 +00:00
this.highlightedIndex = 0;
2023-12-20 19:09:55 +01:00
}
private handleKeyDown(event: KeyboardEvent): void {
const key = event.key;
const maxIndex = this.filteredOptions.length - 1;
if (key === 'ArrowDown') {
event.preventDefault();
2025-06-27 16:20:06 +00:00
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
2023-12-20 19:09:55 +01:00
} else if (key === 'ArrowUp') {
event.preventDefault();
2025-06-27 16:20:06 +00:00
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
2023-12-20 19:09:55 +01:00
} else if (key === 'Enter') {
event.preventDefault();
2025-06-27 16:20:06 +00:00
if (this.filteredOptions[this.highlightedIndex]) {
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
}
} else if (key === 'Escape') {
event.preventDefault();
this.isOpened = false;
}
}
private handleSearchKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
this.handleKeyDown(event);
}
}
private handleSelectedBoxKeydown(event: KeyboardEvent) {
if (this.disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleSelectionBox();
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (!this.isOpened) {
this.toggleSelectionBox();
}
} else if (event.key === 'Escape') {
event.preventDefault();
if (this.isOpened) {
this.isOpened = false;
}
2023-12-20 19:09:55 +01:00
}
}
public getValue(): { option: string; key: string; payload?: any } {
return this.selectedOption;
}
public setValue(value: { option: string; key: string; payload?: any }): void {
this.selectedOption = value;
}
2025-06-27 16:20:06 +00:00
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
}