Compare commits

...

9 Commits

Author SHA1 Message Date
a7a710b320 v3.56.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 23:47:13 +00:00
b1c174a4e2 fix(dees-input-dropdown): improve dropdown popup lifecycle with window layer overlay and animated visibility transitions 2026-04-04 23:47:13 +00:00
395e0fa3da v3.56.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 23:37:28 +00:00
f52b9d8b72 feat(dees-input-dropdown): extract dropdown popup into a floating overlay component with search, keyboard navigation, and viewport repositioning 2026-04-04 23:37:28 +00:00
561d1b15d9 v3.55.6
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 20:20:34 +00:00
0722f362f3 fix(dees-heading): adjust heading hr text color to use muted theme values 2026-04-04 20:20:34 +00:00
2104b3bdce v3.55.5
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-04 12:29:39 +00:00
e2d03107df fix(chart): refine ECharts series styling and legend color handling across bar, donut, and radar charts 2026-04-04 12:29:39 +00:00
54a87a5cc0 fix(chart): initialize chart instance with SVG renderer and update styles for chart container 2026-04-04 11:38:18 +00:00
13 changed files with 612 additions and 330 deletions

View File

@@ -1,5 +1,32 @@
# Changelog
## 2026-04-04 - 3.56.1 - fix(dees-input-dropdown)
improve dropdown popup lifecycle with window layer overlay and animated visibility transitions
- use a window layer to handle outside-click closing instead of document-level mousedown listeners
- await popup show and search focus to keep popup initialization and overlay setup in sync
- add guarded async hide logic with animated teardown and cleanup of scroll/resize listeners
## 2026-04-04 - 3.56.0 - feat(dees-input-dropdown)
extract dropdown popup into a floating overlay component with search, keyboard navigation, and viewport repositioning
- adds a new dees-input-dropdown-popup export for rendering the menu as a fixed overlay attached to document.body
- keeps the dropdown aligned to its trigger on scroll and resize, and closes it when the trigger moves off-screen
- moves option filtering and keyboard selection handling into the popup component while preserving selection events
## 2026-04-04 - 3.55.6 - fix(dees-heading)
adjust heading hr text color to use muted theme values
- Updates the dees-heading horizontal rule variant to use softer light and dark theme text colors instead of pure black and white.
## 2026-04-04 - 3.55.5 - fix(chart)
refine ECharts series styling and legend color handling across bar, donut, and radar charts
- switch chart series palettes to hex colors and add rgba conversion to prevent black flashes during ECharts hover and emphasis animations
- explicitly provide legend item colors and solid tooltip markers so translucent fills render consistently across chart types
- deep-merge legend theme options in the shared ECharts base component to preserve nested legend text styling
- adjust donut chart spacing and shared chart container styling for improved layout
## 2026-04-04 - 3.55.4 - fix(chart)
align ECharts components with theme tokens and load the full ECharts ESM bundle

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "3.55.4",
"version": "3.56.1",
"private": false,
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
"main": "dist_ts_web/index.js",

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
version: '3.55.4',
version: '3.56.1',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js';
import { barStyles } from './styles.js';
import { renderChartBar } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js';
import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IBarSeriesItem {
name: string;
@@ -87,28 +87,42 @@ export class DeesChartBar extends DeesChartEchartsBase {
splitLine: { lineStyle: { color: colors.borderSubtle } },
};
const seriesData = this.series.map((s, index) => ({
name: s.name,
type: 'bar' as const,
data: s.data,
stack: this.stacked ? 'total' : undefined,
itemStyle: {
color: s.color || seriesColors[index % seriesColors.length],
borderRadius: this.stacked ? [0, 0, 0, 0] : this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0],
},
barMaxWidth: 40,
emphasis: {
const fillAlpha = this.goBright ? 0.15 : 0.25;
const borderRadius = this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0];
const noBorderRadius = [0, 0, 0, 0];
const legendData: Array<{ name: string; itemStyle: { color: string } }> = [];
const seriesData = this.series.map((s, index) => {
const color = s.color || seriesColors[index % seriesColors.length];
legendData.push({ name: s.name, itemStyle: { color } });
return {
name: s.name,
type: 'bar' as const,
data: s.data,
stack: this.stacked ? 'total' : undefined,
itemStyle: {
shadowBlur: 6,
shadowColor: 'rgba(0, 0, 0, 0.15)',
color: hexToRgba(color, fillAlpha),
borderColor: color,
borderWidth: 1,
borderRadius: this.stacked ? noBorderRadius : borderRadius,
},
},
}));
barMaxWidth: 40,
barGap: '20%',
emphasis: {
itemStyle: {
color: hexToRgba(color, fillAlpha + 0.15),
borderColor: color,
borderWidth: 1.5,
},
},
};
});
// For stacked bars, round the top corners of the last visible series
if (this.stacked && seriesData.length > 0) {
const last = seriesData[seriesData.length - 1];
last.itemStyle.borderRadius = this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0];
last.itemStyle.borderRadius = borderRadius;
}
return {
@@ -119,13 +133,15 @@ export class DeesChartBar extends DeesChartEchartsBase {
const items = Array.isArray(params) ? params : [params];
let result = `<strong>${items[0].axisValueLabel}</strong><br/>`;
for (const p of items) {
result += `${p.marker} ${p.seriesName}: <strong>${formatter(p.value)}</strong><br/>`;
const solidColor = p.borderColor || p.color;
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${solidColor};"></span>`;
result += `${marker}${p.seriesName}: <strong>${formatter(p.value)}</strong><br/>`;
}
return result;
},
},
legend: this.showLegend && this.series.length > 1
? { bottom: 8, itemWidth: 10, itemHeight: 10 }
? { bottom: 8, itemWidth: 10, itemHeight: 10, data: legendData }
: { show: false },
grid: {
left: 16,

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js';
import { donutStyles } from './styles.js';
import { renderChartDonut } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js';
import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IDonutDataItem {
name: string;
@@ -62,13 +62,32 @@ export class DeesChartDonut extends DeesChartEchartsBase {
}
protected buildOption(): Record<string, any> {
const colors = getThemeColors(this.goBright);
const themeColors = getThemeColors(this.goBright);
const seriesColors = getEchartsSeriesColors(this.goBright);
const data = this.data.map((item, index) => ({
name: item.name,
value: item.value,
itemStyle: item.color ? { color: item.color } : { color: seriesColors[index % seriesColors.length] },
}));
const fillAlpha = this.goBright ? 0.15 : 0.2;
const legendData: Array<{ name: string; itemStyle: { color: string } }> = [];
const data = this.data.map((item, index) => {
const color = item.color || seriesColors[index % seriesColors.length];
legendData.push({ name: item.name, itemStyle: { color } });
return {
name: item.name,
value: item.value,
itemStyle: {
color: hexToRgba(color, fillAlpha),
borderColor: color,
borderWidth: 1,
},
emphasis: {
itemStyle: {
color: hexToRgba(color, fillAlpha + 0.15),
borderColor: color,
borderWidth: 1.5,
},
},
};
});
const formatter = this.valueFormatter;
@@ -76,7 +95,9 @@ export class DeesChartDonut extends DeesChartEchartsBase {
tooltip: {
trigger: 'item',
formatter: (params: any) => {
return `${params.marker} ${params.name}: <strong>${formatter(params.value)}</strong> (${params.percent}%)`;
const solidColor = params.data?.itemStyle?.borderColor || params.color;
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${solidColor};"></span>`;
return `${marker}${params.name}: <strong>${formatter(params.value)}</strong> (${params.percent}%)`;
},
},
legend: this.showLegend
@@ -87,6 +108,7 @@ export class DeesChartDonut extends DeesChartEchartsBase {
itemWidth: 10,
itemHeight: 10,
itemGap: 12,
data: legendData,
formatter: (name: string) => {
const item = this.data.find((d) => d.name === name);
return item ? `${name} ${formatter(item.value)}` : name;
@@ -99,31 +121,19 @@ export class DeesChartDonut extends DeesChartEchartsBase {
radius: [this.innerRadiusPercent, '85%'],
center: this.showLegend ? ['35%', '50%'] : ['50%', '50%'],
avoidLabelOverlap: true,
padAngle: 2,
itemStyle: {
borderRadius: 4,
borderColor: 'transparent',
borderWidth: 2,
},
label: this.showLabels
? {
show: true,
formatter: '{b}: {d}%',
fontSize: 11,
color: colors.textSecondary,
color: themeColors.textSecondary,
textBorderColor: 'transparent',
}
: { show: false },
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.2)',
},
label: {
show: true,
fontWeight: 'bold',
},
},
data,
},
],

View File

@@ -7,5 +7,8 @@ export const donutStyles = [
:host {
height: 360px;
}
.chartContainer {
inset: 12px 0;
}
`,
];

View File

@@ -62,7 +62,7 @@ export abstract class DeesChartEchartsBase extends DeesElement {
if (!chartContainer) return;
try {
this.chartInstance = this.echartsBundle.init(chartContainer);
this.chartInstance = this.echartsBundle.init(chartContainer, null, { renderer: 'svg' });
this.updateChart();
this.resizeObserver = new ResizeObserver(() => {
@@ -87,12 +87,17 @@ export abstract class DeesChartEchartsBase extends DeesElement {
if (!this.chartInstance) return;
const themeOptions = getEchartsThemeOptions(this.goBright);
const chartOption = this.buildOption();
// Merge theme defaults with chart-specific options
// Deep-merge theme defaults with chart-specific options for nested objects
const merged = {
...themeOptions,
...chartOption,
textStyle: { ...themeOptions.textStyle, ...(chartOption.textStyle || {}) },
tooltip: { ...themeOptions.tooltip, ...(chartOption.tooltip || {}) },
legend: {
...themeOptions.legend,
...(chartOption.legend || {}),
textStyle: { ...(themeOptions.legend?.textStyle || {}), ...(chartOption.legend?.textStyle || {}) },
},
};
this.chartInstance.setOption(merged, true);
}

View File

@@ -3,8 +3,12 @@
* Uses the centralized themeDefaults tokens so chart colors stay in sync
* with the rest of the dees-catalog design system.
*
* ECharts renders on <canvas> and cannot read CSS custom properties,
* ECharts renders on <svg> and cannot read CSS custom properties,
* so we reference the TypeScript source-of-truth (themeDefaults) directly.
*
* IMPORTANT: All colors passed to ECharts for data series must be hex or rgb/rgba.
* ECharts cannot interpolate HSL strings during hover/emphasis animations,
* causing them to flash black.
*/
import { themeDefaults } from '../00theme.js';
@@ -12,22 +16,27 @@ import { themeDefaults } from '../00theme.js';
const light = themeDefaults.colors.light;
const dark = themeDefaults.colors.dark;
/**
* Series color palette for ECharts charts.
* Aligned with the Tailwind/shadcn-inspired palette used throughout the codebase.
* All values are hex — ECharts requires this for animation interpolation.
*/
const SERIES_COLORS = {
dark: [
dark.accentPrimary, // blue
'hsl(173.4 80.4% 40%)', // teal (no token yet)
'hsl(280.3 87.4% 66.7%)', // purple (no token yet)
dark.accentWarning, // orange/amber
dark.accentSuccess, // green
dark.accentError, // rose/red
'#60a5fa', // blue-400 — softer in dark mode
'#2dd4bf', // teal-400
'#a78bfa', // violet-400
'#fbbf24', // amber-400
'#34d399', // emerald-400
'#fb7185', // rose-400
],
light: [
light.accentPrimary,
'hsl(142.1 76.2% 36.3%)', // teal (no token yet)
'hsl(280.3 47.7% 50.2%)', // purple (no token yet)
light.accentWarning,
light.accentSuccess,
light.accentError,
'#3b82f6', // blue-500
'#14b8a6', // teal-500
'#8b5cf6', // violet-500
'#f59e0b', // amber-500
'#10b981', // emerald-500
'#f43f5e', // rose-500
],
};
@@ -35,6 +44,16 @@ export function getEchartsSeriesColors(goBright: boolean): string[] {
return goBright ? SERIES_COLORS.light : SERIES_COLORS.dark;
}
/**
* Convert a hex color to an rgba string with the given alpha.
*/
export function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
const colors = goBright ? light : dark;
return {
@@ -44,7 +63,8 @@ export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: 12,
},
color: goBright ? SERIES_COLORS.light : SERIES_COLORS.dark,
// No global `color` array — each component sets per-item/per-series
// colors explicitly to avoid conflicts during emphasis animations.
tooltip: {
backgroundColor: colors.bgPrimary,
borderColor: colors.borderDefault,
@@ -65,7 +85,6 @@ export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
/**
* Helper to get the resolved theme colors object for use in buildOption().
* Components can use this instead of hardcoding dark/light color values.
*/
export function getThemeColors(goBright: boolean) {
return goBright ? light : dark;

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js';
import { radarStyles } from './styles.js';
import { renderChartRadar } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js';
import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IRadarIndicator {
name: string;
@@ -67,16 +67,18 @@ export class DeesChartRadar extends DeesChartEchartsBase {
const colors = getThemeColors(this.goBright);
const seriesColors = getEchartsSeriesColors(this.goBright);
const fillAlpha = this.goBright ? 0.1 : 0.15;
const seriesData = this.series.map((s, index) => {
const color = s.color || seriesColors[index % seriesColors.length];
return {
name: s.name,
value: s.values,
itemStyle: { color },
lineStyle: { color, width: 2 },
areaStyle: this.fillArea ? { color, opacity: 0.15 } : undefined,
itemStyle: { color, borderColor: color, borderWidth: 1 },
lineStyle: { color, width: 1.5 },
areaStyle: this.fillArea ? { color: hexToRgba(color, fillAlpha) } : undefined,
symbol: 'circle',
symbolSize: 6,
symbolSize: 5,
};
});

View File

@@ -0,0 +1,374 @@
import {
customElement,
type TemplateResult,
property,
state,
html,
css,
cssManager,
DeesElement,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { zIndexRegistry } from '../../00zindex.js';
import { cssGeistFontFamily } from '../../00fonts.js';
import { themeDefaultStyles } from '../../00theme.js';
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-dropdown-popup': DeesInputDropdownPopup;
}
}
@customElement('dees-input-dropdown-popup')
export class DeesInputDropdownPopup extends DeesElement {
@property({ type: Array })
accessor options: { option: string; key: string; payload?: any }[] = [];
@property({ type: Boolean })
accessor enableSearch: boolean = true;
@property({ type: Boolean })
accessor opensToTop: boolean = false;
@property({ attribute: false })
accessor triggerRect: DOMRect | null = null;
@property({ attribute: false })
accessor ownerComponent: HTMLElement | null = null;
@state()
accessor filteredOptions: { option: string; key: string; payload?: any }[] = [];
@state()
accessor highlightedIndex: number = 0;
@state()
accessor searchValue: string = '';
@state()
accessor menuZIndex: number = 1000;
@state()
accessor visible: boolean = false;
private windowLayer: DeesWindowLayer | null = null;
private isDestroying: boolean = false;
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
css`
:host {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
font-family: ${cssGeistFontFamily};
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
* {
box-sizing: border-box;
}
.selectionBox {
position: fixed;
pointer-events: auto;
will-change: transform, opacity;
transition: all 0.15s ease;
opacity: 0;
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);
min-height: 40px;
max-height: 300px;
overflow: hidden;
border-radius: 6px;
user-select: none;
}
.selectionBox.top {
transform: translateY(8px) scale(0.98);
}
.selectionBox.show {
pointer-events: auto;
transform: translateY(0) scale(1);
opacity: 1;
}
.options-container {
max-height: 250px;
overflow-y: auto;
padding: 4px;
}
.option {
transition: all 0.15s ease;
line-height: 32px;
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%)')};
}
.option.highlighted {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
}
.option:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
.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;
}
.search {
padding: 4px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 4px;
}
.search input {
display: block;
width: 100%;
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;
}
.search input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
}
.search input:focus {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
}
.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%)')};
}
`,
];
public render(): TemplateResult {
if (!this.triggerRect) return html``;
const posStyle = this.computePositionStyle();
return html`
<div
class="selectionBox ${this.visible ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}"
style="${posStyle}; z-index: ${this.menuZIndex};"
>
${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.selectOption(option)}"
@mouseenter="${() => (this.highlightedIndex = index)}"
>
${option.option}
</div>
`;
})}
</div>
</div>
`;
}
private computePositionStyle(): string {
const rect = this.triggerRect!;
const left = rect.left;
const width = rect.width;
if (this.opensToTop) {
const bottom = window.innerHeight - rect.top + 4;
return `left: ${left}px; width: ${width}px; bottom: ${bottom}px; top: auto`;
} else {
const top = rect.bottom + 4;
return `left: ${left}px; width: ${width}px; top: ${top}px`;
}
}
public async show(): Promise<void> {
this.filteredOptions = this.options;
this.highlightedIndex = 0;
this.searchValue = '';
// Create window layer (transparent, no blur)
this.windowLayer = await DeesWindowLayer.createAndShow();
this.windowLayer.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('close-request'));
});
// Set z-index above the window layer
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.style.zIndex = this.menuZIndex.toString();
document.body.appendChild(this);
// Animate in on next frame
await domtools.plugins.smartdelay.delayFor(0);
this.visible = true;
// Add scroll/resize listeners for repositioning
window.addEventListener('scroll', this.handleScrollOrResize, { capture: true, passive: true });
window.addEventListener('resize', this.handleScrollOrResize, { passive: true });
}
public async hide(): Promise<void> {
// Guard against double-destruction
if (this.isDestroying) {
return;
}
this.isDestroying = true;
// Remove scroll/resize listeners
window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions);
window.removeEventListener('resize', this.handleScrollOrResize);
zIndexRegistry.unregister(this);
this.searchValue = '';
this.filteredOptions = this.options;
this.highlightedIndex = 0;
// Don't await - let window layer cleanup happen in background for instant visual feedback
if (this.windowLayer) {
this.windowLayer.destroy();
this.windowLayer = null;
}
// Animate out via CSS transition
this.visible = false;
await domtools.plugins.smartdelay.delayFor(150);
if (this.parentElement) {
this.parentElement.removeChild(this);
}
this.isDestroying = false;
}
public async focusSearchInput(): Promise<void> {
await this.updateComplete;
const input = this.shadowRoot!.querySelector('.search input') as HTMLInputElement;
if (input) input.focus();
}
public updateOptions(options: { option: string; key: string; payload?: any }[]): void {
this.options = options;
// Re-filter with current search value
if (this.searchValue) {
const searchLower = this.searchValue.toLowerCase();
this.filteredOptions = this.options.filter((opt) =>
opt.option.toLowerCase().includes(searchLower)
);
} else {
this.filteredOptions = this.options;
}
this.highlightedIndex = 0;
}
private selectOption(option: { option: string; key: string; payload?: any }): void {
this.dispatchEvent(
new CustomEvent('option-selected', {
detail: option,
})
);
}
private handleSearch = (event: Event): void => {
const searchTerm = (event.target as HTMLInputElement).value;
this.searchValue = searchTerm;
const searchLower = searchTerm.toLowerCase();
this.filteredOptions = this.options.filter((option) =>
option.option.toLowerCase().includes(searchLower)
);
this.highlightedIndex = 0;
};
private handleSearchKeydown = (event: KeyboardEvent): void => {
const key = event.key;
const maxIndex = this.filteredOptions.length - 1;
if (key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
} else if (key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
} else if (key === 'Enter') {
event.preventDefault();
if (this.filteredOptions[this.highlightedIndex]) {
this.selectOption(this.filteredOptions[this.highlightedIndex]);
}
} else if (key === 'Escape') {
event.preventDefault();
this.dispatchEvent(new CustomEvent('close-request'));
}
};
private handleScrollOrResize = (): void => {
this.dispatchEvent(new CustomEvent('reposition-request'));
};
async disconnectedCallback() {
await super.disconnectedCallback();
window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions);
window.removeEventListener('resize', this.handleScrollOrResize);
zIndexRegistry.unregister(this);
}
}

View File

@@ -7,11 +7,11 @@ import {
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-input-dropdown.demo.js';
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
import { cssGeistFontFamily } from '../../00fonts.js';
import { themeDefaultStyles } from '../../00theme.js';
import { DeesInputDropdownPopup } from './dees-input-dropdown-popup.js';
declare global {
interface HTMLElementTagNameMap {
@@ -46,27 +46,16 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
})
accessor enableSearch: boolean = true;
@state()
accessor opensToTop: boolean = false;
@state()
accessor filteredOptions: { option: string; key: string; payload?: any }[] = [];
@state()
accessor highlightedIndex: number = 0;
@state()
accessor isOpened = false;
@state()
accessor searchValue: string = '';
private popupInstance: DeesInputDropdownPopup | null = null;
public static styles = [
themeDefaultStyles,
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
/* TODO: Migrate hardcoded values to --dees-* CSS variables */
* {
box-sizing: border-box;
}
@@ -137,137 +126,6 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
.selectedBox.open::after {
transform: translateY(-50%) rotate(180deg);
}
.selectionBox {
will-change: transform, opacity;
pointer-events: none;
transition: all 0.15s ease;
opacity: 0;
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);
min-height: 40px;
max-height: 300px;
overflow: hidden;
border-radius: 6px;
position: absolute;
user-select: none;
margin-top: 4px;
z-index: 50;
left: 0;
right: 0;
}
.selectionBox.top {
bottom: calc(100% + 4px);
top: auto;
margin-top: 0;
margin-bottom: 4px;
transform: translateY(8px) scale(0.98);
}
.selectionBox.bottom {
top: 100%;
}
.selectionBox.show {
pointer-events: all;
transform: translateY(0) scale(1);
opacity: 1;
}
/* Options container */
.options-container {
max-height: 250px;
overflow-y: auto;
padding: 4px;
}
/* Options */
.option {
transition: all 0.15s ease;
line-height: 32px;
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%)')};
}
.option.highlighted {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
}
.option:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
}
/* 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;
}
/* Search */
.search {
padding: 4px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin-bottom: 4px;
}
.search.bottom {
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;
}
.search input {
display: block;
width: 100%;
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;
}
.search input::placeholder {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
}
.search input:focus {
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%)')};
}
`,
];
@@ -284,68 +142,26 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
>
${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>
</div>
</div>
`;
}
async connectedCallback() {
super.connectedCallback();
this.handleClickOutside = this.handleClickOutside.bind(this);
}
firstUpdated() {
this.selectedOption = this.selectedOption || null;
this.filteredOptions = this.options;
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('options')) {
this.filteredOptions = this.options;
if (changedProperties.has('options') && this.popupInstance && this.isOpened) {
this.popupInstance.updateOptions(this.options);
}
}
public async updateSelection(selectedOption: { option: string; key: string; payload?: any }) {
this.selectedOption = selectedOption;
this.isOpened = false;
this.searchValue = '';
this.filteredOptions = this.options;
this.highlightedIndex = 0;
this.closePopup();
this.dispatchEvent(
new CustomEvent('selectedOption', {
@@ -353,92 +169,95 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
bubbles: true,
})
);
this.changeSubject.next(this);
}
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);
}
};
public async toggleSelectionBox() {
this.isOpened = !this.isOpened;
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;
// Determine if we should open upwards
this.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
// Focus search input if present
await this.updateComplete;
const searchInput = this.shadowRoot!.querySelector('.search input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
} else {
// Cleanup
this.searchValue = '';
this.filteredOptions = this.options;
document.removeEventListener('click', this.handleClickOutside);
this.closePopup();
return;
}
this.isOpened = true;
// Get trigger position
const selectedBox = this.shadowRoot!.querySelector('.selectedBox') as HTMLElement;
const rect = selectedBox.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
// Create popup if needed
if (!this.popupInstance) {
this.popupInstance = new DeesInputDropdownPopup();
}
// Configure popup
this.popupInstance.options = this.options;
this.popupInstance.enableSearch = this.enableSearch;
this.popupInstance.opensToTop = opensToTop;
this.popupInstance.triggerRect = rect;
this.popupInstance.ownerComponent = this;
// Listen for popup events
this.popupInstance.addEventListener('option-selected', this.handleOptionSelected);
this.popupInstance.addEventListener('close-request', this.handleCloseRequest);
this.popupInstance.addEventListener('reposition-request', this.handleRepositionRequest);
// Show popup (creates window layer, appends to document.body)
await this.popupInstance.show();
// Focus search input
if (this.enableSearch) {
await this.popupInstance.focusSearchInput();
}
}
private handleSearch(event: Event): void {
const searchTerm = (event.target as HTMLInputElement).value;
this.searchValue = searchTerm;
const searchLower = searchTerm.toLowerCase();
this.filteredOptions = this.options.filter((option) =>
option.option.toLowerCase().includes(searchLower)
);
this.highlightedIndex = 0;
}
private closePopup(): void {
this.isOpened = false;
private handleKeyDown(event: KeyboardEvent): void {
const key = event.key;
const maxIndex = this.filteredOptions.length - 1;
if (key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex + 1 > maxIndex ? 0 : this.highlightedIndex + 1;
} else if (key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = this.highlightedIndex - 1 < 0 ? maxIndex : this.highlightedIndex - 1;
} else if (key === 'Enter') {
event.preventDefault();
if (this.filteredOptions[this.highlightedIndex]) {
this.updateSelection(this.filteredOptions[this.highlightedIndex]);
}
} else if (key === 'Escape') {
event.preventDefault();
this.isOpened = false;
if (this.popupInstance) {
this.popupInstance.removeEventListener('option-selected', this.handleOptionSelected);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
}
}
private handleSearchKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
this.handleKeyDown(event);
private handleOptionSelected = (event: Event): void => {
const detail = (event as CustomEvent).detail;
this.updateSelection(detail);
};
private handleCloseRequest = (): void => {
this.closePopup();
};
private handleRepositionRequest = (): void => {
if (!this.popupInstance || !this.isOpened) return;
const selectedBox = this.shadowRoot!.querySelector('.selectedBox') as HTMLElement;
if (!selectedBox) return;
const rect = selectedBox.getBoundingClientRect();
// Close if trigger scrolled off-screen
if (rect.bottom < 0 || rect.top > window.innerHeight) {
this.closePopup();
return;
}
}
// Update position
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
this.popupInstance.opensToTop = spaceBelow < 300 && spaceAbove > spaceBelow;
this.popupInstance.triggerRect = rect;
};
private handleSelectedBoxKeydown(event: KeyboardEvent) {
if (this.disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.toggleSelectionBox();
@@ -450,7 +269,7 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
} else if (event.key === 'Escape') {
event.preventDefault();
if (this.isOpened) {
this.isOpened = false;
this.closePopup();
}
}
}
@@ -462,9 +281,15 @@ export class DeesInputDropdown extends DeesInputBase<DeesInputDropdown> {
public setValue(value: { option: string; key: string; payload?: any }): void {
this.selectedOption = value;
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
if (this.popupInstance) {
this.popupInstance.removeEventListener('option-selected', this.handleOptionSelected);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
this.popupInstance = null;
}
}
}
}

View File

@@ -1 +1,2 @@
export * from './dees-input-dropdown.js';
export * from './dees-input-dropdown-popup.js';

View File

@@ -56,7 +56,7 @@ export class DeesHeading extends DeesElement {
align-items: center;
text-align: center;
margin: 16px 0;
color: ${cssManager.bdTheme('#000', '#fff')};
color: ${cssManager.bdTheme('#999', '#555')};
}
/* Fade lines toward and away from text for hr style */
.heading-hr::before {