Compare commits

...

27 Commits

Author SHA1 Message Date
e806c9bce6 v3.62.0
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-07 12:19:59 +00:00
fa56c7cce8 feat(dees-table): add multi-column sorting with header menu controls and priority indicators 2026-04-07 12:19:59 +00:00
3601651e10 v3.61.2
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-06 10:41:20 +00:00
b5055b0696 fix(dees-input-list,dees-icon): preserve input focus after list updates and make icons ignore pointer events 2026-04-06 10:41:20 +00:00
a5f7a7ecee v3.61.1
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-05 11:08:40 +00:00
dede3216fb fix(dees-input-list): align list input with dees-tile styling and improve item affordances 2026-04-05 11:08:40 +00:00
5ee89b31b1 v3.61.0
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-05 09:50:35 +00:00
2d354ace55 feat(dees-input-list): allow freeform entries alongside candidate suggestions in dees-input-list 2026-04-05 09:50:35 +00:00
34f5239607 v3.60.0
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-05 09:26:21 +00:00
d17bdcbaad feat(dees-input-list): add candidate autocomplete with tab completion and payload retrieval 2026-04-05 09:26:21 +00:00
dc8a3b620b v3.59.1
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-05 00:28:37 +00:00
9b96949a76 fix(project): no changes to commit 2026-04-05 00:28:37 +00:00
931797466a v3.59.0
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-05 00:24:12 +00:00
9bfb6446af feat(input): extract datepicker popup into a window-layer overlay and enhance the code editor modal status UI 2026-04-05 00:24:12 +00:00
976039798a v3.58.0
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-05 00:06:28 +00:00
0e2176ec7d feat(dees-input-code): add editor status footer with cursor position, line count, and language display 2026-04-05 00:06:28 +00:00
cada1a4234 v3.57.0
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-05 00:00:35 +00:00
465f7585ac feat(dees-input-fileupload): redesign the file upload dropzone with dees-tile integration and themed file list styling 2026-04-05 00:00:35 +00:00
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
30 changed files with 2679 additions and 1512 deletions

View File

@@ -1,5 +1,93 @@
# Changelog
## 2026-04-07 - 3.62.0 - feat(dees-table)
add multi-column sorting with header menu controls and priority indicators
- replace single-column sort state with ordered sort descriptors for cascading client-side sorting
- add Shift+click header sorting, context menu actions, and confirmation before replacing an active sort cascade
- show multi-sort direction and priority badges in table headers and add a demo showcasing the new behavior
## 2026-04-06 - 3.61.2 - fix(dees-input-list,dees-icon)
preserve input focus after list updates and make icons ignore pointer events
- Delays refocusing the add input in dees-input-list until after Lit re-renders complete when adding or removing entries.
- Adds pointer-events: none to dees-icon so icons do not block click interactions on surrounding controls.
## 2026-04-05 - 3.61.1 - fix(dees-input-list)
align list input with dees-tile styling and improve item affordances
- wrap the list in dees-tile with a dynamic item count heading and move the add-item controls into the tile footer
- replace custom container styling with shared tile and theme tokens for hover, focus, row, and disabled states
- show a bullet icon for non-sortable or disabled items when no candidate state icon is present
## 2026-04-05 - 3.61.0 - feat(dees-input-list)
allow freeform entries alongside candidate suggestions in dees-input-list
- adds an allowFreeform option so Enter can add values that do not exactly match the candidate list
- shows check and question-mark indicators to distinguish known candidates from custom freeform items
- updates the component demo with a freeform-plus-candidates example
## 2026-04-05 - 3.60.0 - feat(dees-input-list)
add candidate autocomplete with tab completion and payload retrieval
- Adds terminal-style inline autocomplete with ghost text, Tab accept, Shift+Tab cycling, and Escape clearing for candidate-based input.
- Introduces candidate payload support with APIs to retrieve selected candidate objects after items are added.
- Updates the dees-input-list demo with candidate selection examples for team members and technology stacks.
## 2026-04-05 - 3.59.1 - fix(project)
no changes to commit
## 2026-04-05 - 3.59.0 - feat(input)
extract datepicker popup into a window-layer overlay and enhance the code editor modal status UI
- move the datepicker calendar, time, timezone, and event rendering into a dedicated popup component exported from the input module
- render the datepicker popup in a window-layer overlay with reposition and cleanup handling for scroll, resize, and close events
- preserve timezone-aware value formatting for selected dates
- add a footer to the code editor modal showing cursor position, line count, and selected language
- apply modal-specific Monaco background themes that react to light and dark mode
## 2026-04-05 - 3.58.0 - feat(dees-input-code)
add editor status footer with cursor position, line count, and language display
- Tracks and displays the current cursor line and column in the code editor footer
- Shows dynamic line count updates as editor content changes
- Aligns the Monaco editor background with the surrounding tile theme, including light and dark mode updates
## 2026-04-05 - 3.57.0 - feat(dees-input-fileupload)
redesign the file upload dropzone with dees-tile integration and themed file list styling
- Replace the custom dropzone container with dees-tile and move actions and metadata into header and footer slots
- Add an explicit empty state for the file list and simplify file list layout and interaction handling
- Adopt shared theme tokens in the file upload styles and introduce a reusable row hover color token
## 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.62.0",
"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.62.0',
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

@@ -1,4 +1,4 @@
import type { Column, TDisplayFunction } from './types.js';
import type { Column, ISortDescriptor, TDisplayFunction } from './types.js';
export function computeColumnsFromDisplayFunction<T>(
displayFunction: TDisplayFunction<T>,
@@ -36,11 +36,31 @@ export function getCellValue<T>(row: T, col: Column<T>, displayFunction?: TDispl
return col.value ? col.value(row) : (row as any)[col.key as any];
}
/**
* Compares two cell values in ascending order. Returns -1, 0, or 1.
* Null/undefined values sort before defined values. Numbers compare numerically;
* everything else compares as case-insensitive strings.
*/
export function compareCellValues(va: any, vb: any): number {
if (va == null && vb == null) return 0;
if (va == null) return -1;
if (vb == null) return 1;
if (typeof va === 'number' && typeof vb === 'number') {
if (va < vb) return -1;
if (va > vb) return 1;
return 0;
}
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return -1;
if (sa > sb) return 1;
return 0;
}
export function getViewData<T>(
data: T[],
effectiveColumns: Column<T>[],
sortKey?: string,
sortDir?: 'asc' | 'desc' | null,
sortBy: ISortDescriptor[],
filterText?: string,
columnFilters?: Record<string, string>,
filterMode: 'table' | 'data' = 'table',
@@ -94,21 +114,17 @@ export function getViewData<T>(
return true;
});
}
if (!sortKey || !sortDir) return arr;
const col = effectiveColumns.find((c) => String(c.key) === sortKey);
if (!col) return arr;
const dir = sortDir === 'asc' ? 1 : -1;
if (!sortBy || sortBy.length === 0) return arr;
// Pre-resolve descriptors -> columns once for performance.
const resolved = sortBy
.map((desc) => ({ desc, col: effectiveColumns.find((c) => String(c.key) === desc.key) }))
.filter((entry): entry is { desc: ISortDescriptor; col: Column<T> } => !!entry.col);
if (resolved.length === 0) return arr;
arr.sort((a, b) => {
const va = getCellValue(a, col);
const vb = getCellValue(b, col);
if (va == null && vb == null) return 0;
if (va == null) return -1 * dir;
if (vb == null) return 1 * dir;
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
const sa = String(va).toLowerCase();
const sb = String(vb).toLowerCase();
if (sa < sb) return -1 * dir;
if (sa > sb) return 1 * dir;
for (const { desc, col } of resolved) {
const cmp = compareCellValues(getCellValue(a, col), getCellValue(b, col));
if (cmp !== 0) return desc.dir === 'asc' ? cmp : -cmp;
}
return 0;
});
return arr;

View File

@@ -580,6 +580,44 @@ export const demoFunc = () => html`
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Multi-Column Sort</h2>
<p class="demo-description">
Click any column header for a single-column sort. Hold Shift while clicking to add the
column to a multi-sort cascade (or cycle its direction). Right-click any sortable header
to open a menu where you can pin a column to a specific priority slot, remove it, or
clear the cascade.
</p>
<dees-table
heading1="People Directory"
heading2="Pre-seeded with department ▲ 1, name ▲ 2"
.sortBy=${[
{ key: 'department', dir: 'asc' },
{ key: 'name', dir: 'asc' },
]}
.columns=${[
{ key: 'department', header: 'Department', sortable: true },
{ key: 'name', header: 'Name', sortable: true },
{ key: 'role', header: 'Role', sortable: true },
{ key: 'createdAt', header: 'Created', sortable: true },
{ key: 'location', header: 'Location', sortable: true },
{ key: 'status', header: 'Status', sortable: true },
]}
.data=${[
{ department: 'R&D', name: 'Alice Johnson', role: 'Engineer', createdAt: '2023-01-12', location: 'Berlin', status: 'Active' },
{ department: 'R&D', name: 'Diana Martinez', role: 'Engineer', createdAt: '2020-06-30', location: 'Madrid', status: 'Active' },
{ department: 'R&D', name: 'Mark Lee', role: 'Engineer', createdAt: '2024-03-04', location: 'Berlin', status: 'Active' },
{ department: 'Design', name: 'Bob Smith', role: 'Designer', createdAt: '2022-11-05', location: 'Paris', status: 'Active' },
{ department: 'Design', name: 'Sara Kim', role: 'Designer', createdAt: '2021-08-19', location: 'Paris', status: 'On Leave' },
{ department: 'Ops', name: 'Charlie Davis', role: 'Manager', createdAt: '2021-04-21', location: 'London', status: 'On Leave' },
{ department: 'Ops', name: 'Helena Voss', role: 'SRE', createdAt: '2023-07-22', location: 'London', status: 'Active' },
{ department: 'QA', name: 'Fiona Clark', role: 'QA', createdAt: '2022-03-14', location: 'Vienna', status: 'Active' },
{ department: 'QA', name: 'Tomás Rivera', role: 'QA', createdAt: '2024-01-09', location: 'Madrid', status: 'Active' },
{ department: 'CS', name: 'Ethan Brown', role: 'Support', createdAt: '2019-09-18', location: 'Rome', status: 'Inactive' },
]}
></dees-table>
</div>
<div class="demo-section">
<h2 class="demo-title">Wide Properties + Many Actions</h2>
<p class="demo-description">A table with many columns and rich actions to stress test layout and sticky Actions.</p>

View File

@@ -3,10 +3,11 @@ import { demoFunc } from './dees-table.demo.js';
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
import * as domtools from '@design.estate/dees-domtools';
import { type TIconKey } from '../../00group-utility/dees-icon/dees-icon.js';
import { tableStyles } from './styles.js';
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
import type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
import {
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
computeEffectiveColumns as computeEffectiveColumnsFn,
@@ -17,7 +18,14 @@ import { compileLucenePredicate } from './lucene.js';
import { themeDefaultStyles } from '../../00theme.js';
import '../../00group-layout/dees-tile/dees-tile.js';
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
export type { Column, ISortDescriptor, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
/** Returns the English ordinal label for a 1-based position (e.g. 1 → "1st"). */
function ordinalLabel(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
declare global {
interface HTMLElementTagNameMap {
@@ -161,11 +169,12 @@ export class DeesTable<T> extends DeesElement {
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
// simple client-side sorting (Phase 1)
/**
* Multi-column sort cascade. The first entry is the primary sort key,
* subsequent entries are tiebreakers in priority order.
*/
@property({ attribute: false })
accessor sortKey: string | undefined = undefined;
@property({ attribute: false })
accessor sortDir: 'asc' | 'desc' | null = null;
accessor sortBy: ISortDescriptor[] = [];
// simple client-side filtering (Phase 1)
@property({ type: String })
@@ -213,8 +222,7 @@ export class DeesTable<T> extends DeesElement {
const viewData = getViewDataFn(
this.data,
effectiveColumns,
this.sortKey,
this.sortDir,
this.sortBy,
this.filterText,
this.columnFilters,
this.searchMode === 'data' ? 'data' : 'table',
@@ -318,7 +326,12 @@ export class DeesTable<T> extends DeesElement {
role="columnheader"
aria-sort=${ariaSort}
style="${isSortable ? 'cursor: pointer;' : ''}"
@click=${() => (isSortable ? this.toggleSort(col) : null)}
@click=${(eventArg: MouseEvent) =>
isSortable ? this.handleHeaderClick(eventArg, col, effectiveColumns) : null}
@contextmenu=${(eventArg: MouseEvent) =>
isSortable
? this.openHeaderContextMenu(eventArg, col, effectiveColumns)
: null}
>
${col.header ?? (col.key as any)}
${this.renderSortIndicator(col)}
@@ -651,35 +664,348 @@ export class DeesTable<T> extends DeesElement {
// compute helpers moved to ./data.ts
private toggleSort(col: Column<T>) {
const key = String(col.key);
if (this.sortKey !== key) {
this.sortKey = key;
this.sortDir = 'asc';
} else {
if (this.sortDir === 'asc') this.sortDir = 'desc';
else if (this.sortDir === 'desc') {
this.sortDir = null;
this.sortKey = undefined;
} else this.sortDir = 'asc';
}
this.dispatchEvent(
new CustomEvent('sortChange', {
detail: { key: this.sortKey, dir: this.sortDir },
bubbles: true,
})
);
// ─── sort: public API ────────────────────────────────────────────────
/** Returns the descriptor for `key` if the column is currently in the cascade. */
public getSortDescriptor(key: string): ISortDescriptor | undefined {
return this.sortBy.find((d) => d.key === key);
}
/** Returns the 0-based priority of `key` in the cascade, or -1 if not present. */
public getSortPriority(key: string): number {
return this.sortBy.findIndex((d) => d.key === key);
}
/** Replaces the cascade with a single sort entry. */
public setSort(key: string, dir: 'asc' | 'desc'): void {
this.sortBy = [{ key, dir }];
this.emitSortChange();
this.requestUpdate();
}
/**
* Inserts (or moves) `key` to a 0-based position in the cascade. If the key is
* already present elsewhere, its previous entry is removed before insertion so
* a column appears at most once.
*/
public addSortAt(key: string, position: number, dir: 'asc' | 'desc'): void {
const next = this.sortBy.filter((d) => d.key !== key);
const clamped = Math.max(0, Math.min(position, next.length));
next.splice(clamped, 0, { key, dir });
this.sortBy = next;
this.emitSortChange();
this.requestUpdate();
}
/** Appends `key` to the end of the cascade (or moves it there if already present). */
public appendSort(key: string, dir: 'asc' | 'desc'): void {
const next = this.sortBy.filter((d) => d.key !== key);
next.push({ key, dir });
this.sortBy = next;
this.emitSortChange();
this.requestUpdate();
}
/** Removes `key` from the cascade. No-op if not present. */
public removeSort(key: string): void {
if (!this.sortBy.some((d) => d.key === key)) return;
this.sortBy = this.sortBy.filter((d) => d.key !== key);
this.emitSortChange();
this.requestUpdate();
}
/** Empties the cascade. */
public clearSorts(): void {
if (this.sortBy.length === 0) return;
this.sortBy = [];
this.emitSortChange();
this.requestUpdate();
}
private emitSortChange() {
this.dispatchEvent(
new CustomEvent('sortChange', {
detail: { sortBy: this.sortBy.map((d) => ({ ...d })) },
bubbles: true,
})
);
}
// ─── sort: header interaction handlers ───────────────────────────────
/**
* Plain left-click on a sortable header. Cycles `none → asc → desc → none`
* collapsing the cascade to a single column. If a multi-column cascade is
* active, asks the user to confirm the destructive replacement first. A
* Shift+click bypasses the modal and routes through the multi-sort cycle.
*/
private async handleHeaderClick(
eventArg: MouseEvent,
col: Column<T>,
_effectiveColumns: Column<T>[]
) {
if (eventArg.shiftKey) {
this.handleHeaderShiftClick(col);
return;
}
const proceed = await this.confirmReplaceCascade(col);
if (!proceed) return;
this.cycleSingleSort(col);
}
/**
* Cycles a single column through `none → asc → desc → none`, collapsing the
* cascade. Used by both plain click and the menu's "Sort Ascending/Descending"
* shortcuts (after confirmation).
*/
private cycleSingleSort(col: Column<T>) {
const key = String(col.key);
const current = this.sortBy.length === 1 && this.sortBy[0].key === key ? this.sortBy[0].dir : null;
if (current === 'asc') this.setSort(key, 'desc');
else if (current === 'desc') this.clearSorts();
else this.setSort(key, 'asc');
}
/**
* Shift+click cycle on a sortable header. Edits the cascade in place without
* destroying other sort keys: append → flip dir → remove.
*/
private handleHeaderShiftClick(col: Column<T>) {
const key = String(col.key);
const existing = this.getSortDescriptor(key);
if (!existing) {
this.appendSort(key, 'asc');
} else if (existing.dir === 'asc') {
this.sortBy = this.sortBy.map((d) => (d.key === key ? { key, dir: 'desc' } : d));
this.emitSortChange();
this.requestUpdate();
} else {
this.removeSort(key);
}
}
/**
* Opens a confirmation modal when the cascade has more than one entry and the
* user attempts a destructive single-sort replacement. Resolves to `true` if
* the user accepts, `false` if they cancel. If the cascade has 0 or 1 entries
* the modal is skipped and we resolve to `true` immediately.
*/
private confirmReplaceCascade(targetCol: Column<T>): Promise<boolean> {
if (this.sortBy.length <= 1) return Promise.resolve(true);
return new Promise((resolve) => {
let settled = false;
const settle = (result: boolean) => {
if (settled) return;
settled = true;
resolve(result);
};
const summary = this.sortBy
.map((d, i) => {
const c = (this as any)._lookupColumnByKey?.(d.key) as Column<T> | undefined;
const label = c?.header ?? d.key;
return html`<li>${i + 1}. ${label} ${d.dir === 'asc' ? '▲' : '▼'}</li>`;
});
DeesModal.createAndShow({
heading: 'Replace multi-column sort?',
width: 'small',
showCloseButton: true,
content: html`
<div style="font-size:13px; line-height:1.55;">
<p style="margin:0 0 8px;">
You currently have a ${this.sortBy.length}-column sort active:
</p>
<ul style="margin:0 0 12px; padding-left:18px;">${summary}</ul>
<p style="margin:0;">
Continuing will discard the cascade and replace it with a single sort on
<strong>${targetCol.header ?? String(targetCol.key)}</strong>.
</p>
</div>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modal) => {
settle(false);
await modal!.destroy();
return null;
},
},
{
name: 'Replace',
iconName: 'lucide:check',
action: async (modal) => {
settle(true);
await modal!.destroy();
return null;
},
},
],
});
});
}
/**
* Looks up a column by its string key in the currently effective column set.
* Used by the modal helper to render human-friendly labels.
*/
private _lookupColumnByKey(key: string): Column<T> | undefined {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const effective = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
return effective.find((c) => String(c.key) === key);
}
/**
* Opens the header context menu for explicit multi-sort priority control.
*/
private openHeaderContextMenu(
eventArg: MouseEvent,
col: Column<T>,
effectiveColumns: Column<T>[]
) {
const items = this.buildHeaderMenuItems(col, effectiveColumns);
DeesContextmenu.openContextMenuWithOptions(eventArg, items as any);
}
/**
* Builds the dynamic context-menu structure for a single column header.
*/
private buildHeaderMenuItems(col: Column<T>, effectiveColumns: Column<T>[]) {
const key = String(col.key);
const existing = this.getSortDescriptor(key);
const cascadeLen = this.sortBy.length;
// Maximum exposed slot: one beyond the current cascade, capped at the
// number of sortable columns. If the column is already in the cascade we
// never need to grow the slot count.
const sortableColumnCount = effectiveColumns.filter((c) => !!c.sortable).length;
const maxSlot = Math.min(
Math.max(cascadeLen + (existing ? 0 : 1), 1),
Math.max(sortableColumnCount, 1)
);
const items: any[] = [];
// Single-sort shortcuts. These are destructive when a cascade is active, so
// they go through confirmReplaceCascade just like a plain click.
items.push({
name: 'Sort Ascending',
iconName: cascadeLen === 1 && existing?.dir === 'asc' ? 'lucide:check' : 'lucide:arrowUp',
action: async () => {
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'asc');
return null;
},
});
items.push({
name: 'Sort Descending',
iconName: cascadeLen === 1 && existing?.dir === 'desc' ? 'lucide:check' : 'lucide:arrowDown',
action: async () => {
if (await this.confirmReplaceCascade(col)) this.setSort(key, 'desc');
return null;
},
});
items.push({ divider: true });
// Priority slot entries (1..maxSlot). Each slot has an asc/desc submenu.
for (let slot = 1; slot <= maxSlot; slot++) {
const ordinal = ordinalLabel(slot);
const isCurrentSlot = existing && this.getSortPriority(key) === slot - 1;
items.push({
name: `Set as ${ordinal} sort`,
iconName: isCurrentSlot ? 'lucide:check' : 'lucide:listOrdered',
submenu: [
{
name: 'Ascending',
iconName: 'lucide:arrowUp',
action: async () => {
this.addSortAt(key, slot - 1, 'asc');
return null;
},
},
{
name: 'Descending',
iconName: 'lucide:arrowDown',
action: async () => {
this.addSortAt(key, slot - 1, 'desc');
return null;
},
},
],
});
}
items.push({ divider: true });
items.push({
name: 'Append to sort',
iconName: 'lucide:plus',
submenu: [
{
name: 'Ascending',
iconName: 'lucide:arrowUp',
action: async () => {
this.appendSort(key, 'asc');
return null;
},
},
{
name: 'Descending',
iconName: 'lucide:arrowDown',
action: async () => {
this.appendSort(key, 'desc');
return null;
},
},
],
});
if (existing) {
items.push({ divider: true });
items.push({
name: 'Remove from sort',
iconName: 'lucide:minus',
action: async () => {
this.removeSort(key);
return null;
},
});
}
if (cascadeLen > 0) {
if (!existing) items.push({ divider: true });
items.push({
name: 'Clear all sorts',
iconName: 'lucide:trash',
action: async () => {
this.clearSorts();
return null;
},
});
}
return items;
}
// ─── sort: indicator + ARIA ──────────────────────────────────────────
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
return this.sortDir === 'asc' ? 'ascending' : 'descending';
// ARIA sort reflects only the primary sort key (standard grid pattern).
const primary = this.sortBy[0];
if (!primary || primary.key !== String(col.key)) return 'none';
return primary.dir === 'asc' ? 'ascending' : 'descending';
}
private renderSortIndicator(col: Column<T>) {
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
const idx = this.getSortPriority(String(col.key));
if (idx < 0) return html``;
const desc = this.sortBy[idx];
const arrow = desc.dir === 'asc' ? '▲' : '▼';
if (this.sortBy.length === 1) {
return html`<span class="sortArrow">${arrow}</span>`;
}
return html`<span class="sortArrow">${arrow}</span><span class="sortBadge">${idx + 1}</span>`;
}
// filtering helpers

View File

@@ -276,6 +276,32 @@ export const tableStyles: CSSResult[] = [
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
letter-spacing: -0.01em;
}
th[role='columnheader']:hover {
color: var(--dees-color-text-primary);
}
th .sortArrow {
display: inline-block;
margin-left: 6px;
font-size: 10px;
line-height: 1;
opacity: 0.7;
vertical-align: middle;
}
th .sortBadge {
display: inline-block;
margin-left: 3px;
padding: 1px 5px;
font-size: 10px;
font-weight: 600;
line-height: 1;
color: ${cssManager.bdTheme('hsl(222.2 47.4% 30%)', 'hsl(217.2 91.2% 75%)')};
background: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.12)', 'hsl(217.2 91.2% 59.8% / 0.18)')};
border-radius: 999px;
vertical-align: middle;
}
:host([show-vertical-lines]) th {
border-right: 1px solid var(--dees-color-border-default);

View File

@@ -26,4 +26,13 @@ export interface Column<T = any> {
hidden?: boolean;
}
/**
* One entry in a multi-column sort cascade. Order in the array reflects priority:
* index 0 is the primary sort key, index 1 the secondary tiebreaker, and so on.
*/
export interface ISortDescriptor {
key: string;
dir: 'asc' | 'desc';
}
export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>;

View File

@@ -79,6 +79,12 @@ export class DeesInputCode extends DeesInputBase<string> {
@state()
accessor copySuccess: boolean = false;
@state()
accessor lineCount: number = 0;
@state()
accessor cursorPosition: { line: number; column: number } = { line: 1, column: 1 };
private editorElement: DeesWorkspaceMonaco | null = null;
public static styles = [
@@ -223,6 +229,11 @@ export class DeesInputCode extends DeesInputBase<string> {
height: 100%;
}
/* Match Monaco background to tile */
dees-workspace-monaco::part(container) {
background: var(--dees-color-bg-primary);
}
.toolbar-divider {
width: 1px;
height: 20px;
@@ -230,6 +241,32 @@ export class DeesInputCode extends DeesInputBase<string> {
margin: 0 4px;
}
/* Footer */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 28px;
font-size: 11px;
color: var(--dees-color-text-muted);
width: 100%;
box-sizing: border-box;
}
.footer-left,
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.footer-separator {
width: 1px;
height: 12px;
background: var(--dees-color-border-default);
}
:host([disabled]) .code-container {
opacity: 0.5;
pointer-events: none;
@@ -314,6 +351,16 @@ export class DeesInputCode extends DeesInputBase<string> {
@content-change=${this.handleContentChange}
></dees-workspace-monaco>
</div>
<div slot="footer" class="editor-footer">
<div class="footer-left">
<span>Ln ${this.cursorPosition.line}, Col ${this.cursorPosition.column}</span>
<div class="footer-separator"></div>
<span>${this.lineCount} line${this.lineCount !== 1 ? 's' : ''}</span>
</div>
<div class="footer-right">
<span>${(LANGUAGES.find(l => l.key === this.language) || LANGUAGES[0]).label}</span>
</div>
</div>
</dees-tile>
</div>
`;
@@ -329,6 +376,49 @@ export class DeesInputCode extends DeesInputBase<string> {
this.changeSubject.next(this as any);
}
});
// Track cursor position and line count
const editor = await this.editorElement.editorDeferred.promise;
// Set initial line count
const model = editor.getModel();
if (model) {
this.lineCount = model.getLineCount();
model.onDidChangeContent(() => {
this.lineCount = model.getLineCount();
});
}
// Track cursor position
editor.onDidChangeCursorPosition((e) => {
this.cursorPosition = { line: e.position.lineNumber, column: e.position.column };
});
// Override Monaco editor background to match tile
const domtoolsInstance = await this.editorElement.domtoolsPromise;
const updateEditorBg = (isBright: boolean) => {
const bg = isBright ? '#ffffff' : '#0a0a0a';
editor.updateOptions({});
// Override via Monaco's theme API
(window as any).monaco?.editor?.defineTheme?.('dees-light', {
base: 'vs',
inherit: true,
rules: [],
colors: { 'editor.background': bg },
});
(window as any).monaco?.editor?.defineTheme?.('dees-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: { 'editor.background': bg },
});
editor.updateOptions({ theme: isBright ? 'dees-light' : 'dees-dark' });
};
updateEditorBg(domtoolsInstance.themeManager.goBrightBoolean);
domtoolsInstance.themeManager.themeObservable.subscribe((goBright: boolean) => {
updateEditorBg(goBright);
});
}
}
@@ -418,23 +508,15 @@ export class DeesInputCode extends DeesInputBase<string> {
const toolbar = modal.shadowRoot?.querySelector('.modal-toolbar');
if (!toolbar) return;
// Update language button text
const langBtn = toolbar.querySelector('.language-button span');
if (langBtn) langBtn.textContent = getLanguageLabel();
// Update word wrap button
const wrapBtn = toolbar.querySelector('.wrap-btn') as HTMLElement;
if (wrapBtn) {
wrapBtn.classList.toggle('active', modalWordWrap === 'on');
}
if (wrapBtn) wrapBtn.classList.toggle('active', modalWordWrap === 'on');
// Update line numbers button
const linesBtn = toolbar.querySelector('.lines-btn') as HTMLElement;
if (linesBtn) {
linesBtn.classList.toggle('active', modalShowLineNumbers);
}
if (linesBtn) linesBtn.classList.toggle('active', modalShowLineNumbers);
// Update copy button
const copyBtn = toolbar.querySelector('.copy-btn') as HTMLElement;
const copyIcon = copyBtn?.querySelector('dees-icon') as any;
if (copyBtn && copyIcon) {
@@ -442,13 +524,28 @@ export class DeesInputCode extends DeesInputBase<string> {
copyIcon.icon = modalCopySuccess ? 'lucide:Check' : 'lucide:Copy';
}
// Update dropdown visibility
const dropdown = toolbar.querySelector('.language-dropdown') as HTMLElement;
if (dropdown) {
dropdown.style.display = modalLanguageDropdownOpen ? 'block' : 'none';
}
if (dropdown) dropdown.style.display = modalLanguageDropdownOpen ? 'block' : 'none';
};
// Helper to update footer UI
const updateFooterUI = (modal: DeesModal) => {
const footer = modal.shadowRoot?.querySelector('.modal-footer');
if (!footer) return;
const cursorEl = footer.querySelector('.footer-cursor');
const linesEl = footer.querySelector('.footer-lines');
const langEl = footer.querySelector('.footer-lang');
if (cursorEl) cursorEl.textContent = `Ln ${modalCursorLine}, Col ${modalCursorCol}`;
if (linesEl) linesEl.textContent = `${modalLineCount} line${modalLineCount !== 1 ? 's' : ''}`;
if (langEl) langEl.textContent = getLanguageLabel();
};
let modalCursorLine = 1;
let modalCursorCol = 1;
let modalLineCount = currentValue.split('\n').length;
const modal = await DeesModal.createAndShow({
heading: this.label || 'Code Editor',
width: 'fullscreen',
@@ -459,9 +556,7 @@ export class DeesInputCode extends DeesInputBase<string> {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(0 0% 7%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
padding: 4px 12px;
gap: 8px;
}
.modal-toolbar .toolbar-left {
@@ -554,9 +649,30 @@ export class DeesInputCode extends DeesInputBase<string> {
}
.modal-editor-wrapper {
position: relative;
height: calc(100vh - 175px);
height: calc(100vh - 200px);
width: 100%;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 28px;
font-size: 11px;
color: ${cssManager.bdTheme('hsl(0 0% 45%)', 'hsl(0 0% 55%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.modal-footer .footer-left,
.modal-footer .footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.modal-footer .footer-separator {
width: 1px;
height: 12px;
background: ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(0 0% 20%)')};
}
</style>
<div class="modal-toolbar">
<div class="toolbar-left">
@@ -597,6 +713,16 @@ export class DeesInputCode extends DeesInputBase<string> {
.wordWrap=${modalWordWrap}
></dees-workspace-monaco>
</div>
<div class="modal-footer">
<div class="footer-left">
<span class="footer-cursor">Ln ${modalCursorLine}, Col ${modalCursorCol}</span>
<div class="footer-separator"></div>
<span class="footer-lines">${modalLineCount} line${modalLineCount !== 1 ? 's' : ''}</span>
</div>
<div class="footer-right">
<span class="footer-lang">${getLanguageLabel()}</span>
</div>
</div>
`,
menuOptions: [
{
@@ -608,7 +734,6 @@ export class DeesInputCode extends DeesInputBase<string> {
{
name: 'Save & Close',
action: async (modalRef) => {
// Get the editor content from the modal
modalEditorElement = modalRef!.shadowRoot?.querySelector('dees-workspace-monaco') as DeesWorkspaceMonaco;
if (modalEditorElement) {
const editor = await modalEditorElement.editorDeferred.promise;
@@ -625,17 +750,61 @@ export class DeesInputCode extends DeesInputBase<string> {
await new Promise(resolve => setTimeout(resolve, 100));
modalEditorElement = modal.shadowRoot?.querySelector('dees-workspace-monaco') as DeesWorkspaceMonaco;
// Apply custom Monaco theme for matching background
if (modalEditorElement) {
const editor = await modalEditorElement.editorDeferred.promise;
const domtoolsInstance = await modalEditorElement.domtoolsPromise;
const applyModalTheme = (isBright: boolean) => {
const bg = isBright ? '#ffffff' : '#0a0a0a';
(window as any).monaco?.editor?.defineTheme?.('dees-light', {
base: 'vs',
inherit: true,
rules: [],
colors: { 'editor.background': bg },
});
(window as any).monaco?.editor?.defineTheme?.('dees-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: { 'editor.background': bg },
});
editor.updateOptions({ theme: isBright ? 'dees-light' : 'dees-dark' });
};
applyModalTheme(domtoolsInstance.themeManager.goBrightBoolean);
domtoolsInstance.themeManager.themeObservable.subscribe((goBright: boolean) => {
applyModalTheme(goBright);
});
// Track cursor position
editor.onDidChangeCursorPosition((e) => {
modalCursorLine = e.position.lineNumber;
modalCursorCol = e.position.column;
updateFooterUI(modal);
});
// Track line count
const model = editor.getModel();
if (model) {
modalLineCount = model.getLineCount();
updateFooterUI(modal);
model.onDidChangeContent(() => {
modalLineCount = model.getLineCount();
updateFooterUI(modal);
});
}
}
// Wire up toolbar event handlers
const toolbar = modal.shadowRoot?.querySelector('.modal-toolbar');
if (toolbar) {
// Language button click
const langBtn = toolbar.querySelector('.language-button');
langBtn?.addEventListener('click', () => {
modalLanguageDropdownOpen = !modalLanguageDropdownOpen;
updateToolbarUI(modal);
});
// Language option clicks
const langOptions = toolbar.querySelectorAll('.language-option');
langOptions.forEach((option) => {
option.addEventListener('click', async () => {
@@ -644,23 +813,21 @@ export class DeesInputCode extends DeesInputBase<string> {
modalLanguage = newLang;
modalLanguageDropdownOpen = false;
// Update editor language
const editor = await modalEditorElement.editorDeferred.promise;
const model = editor.getModel();
if (model) {
(window as any).monaco.editor.setModelLanguage(model, newLang);
const editorModel = editor.getModel();
if (editorModel) {
(window as any).monaco.editor.setModelLanguage(editorModel, newLang);
}
// Update selected state
langOptions.forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
updateToolbarUI(modal);
updateFooterUI(modal);
}
});
});
// Word wrap button
const wrapBtn = toolbar.querySelector('.wrap-btn');
wrapBtn?.addEventListener('click', async () => {
modalWordWrap = modalWordWrap === 'on' ? 'off' : 'on';
@@ -671,7 +838,6 @@ export class DeesInputCode extends DeesInputBase<string> {
updateToolbarUI(modal);
});
// Line numbers button
const linesBtn = toolbar.querySelector('.lines-btn');
linesBtn?.addEventListener('click', async () => {
modalShowLineNumbers = !modalShowLineNumbers;
@@ -682,7 +848,6 @@ export class DeesInputCode extends DeesInputBase<string> {
updateToolbarUI(modal);
});
// Copy button
const copyBtn = toolbar.querySelector('.copy-btn');
copyBtn?.addEventListener('click', async () => {
if (modalEditorElement) {
@@ -702,7 +867,6 @@ export class DeesInputCode extends DeesInputBase<string> {
}
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (modalLanguageDropdownOpen && !langBtn?.contains(e.target as Node)) {
modalLanguageDropdownOpen = false;

View File

@@ -8,7 +8,7 @@ import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
import { demoFunc } from './demo.js';
import { datepickerStyles } from './styles.js';
import { renderDatepicker } from './template.js';
import type { IDateEvent } from './types.js';
import { DeesInputDatepickerPopup } from './datepicker-popup.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import '../../00group-layout/dees-label/dees-label.js';
@@ -49,7 +49,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
accessor disabledDates: string[] = [];
@property({ type: Number })
accessor weekStartsOn: 0 | 1 = 1; // Default to Monday
accessor weekStartsOn: 0 | 1 = 1;
@property({ type: String })
accessor placeholder: string = 'YYYY-MM-DD';
@@ -61,14 +61,11 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
accessor timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
@property({ type: Array })
accessor events: IDateEvent[] = [];
accessor events: import('./types.js').IDateEvent[] = [];
@state()
accessor isOpened: boolean = false;
@state()
accessor opensToTop: boolean = false;
@state()
accessor selectedDate: Date | null = null;
@@ -81,57 +78,19 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
@state()
accessor selectedMinute: number = 0;
private popupInstance: DeesInputDatepickerPopup | null = null;
public static styles = datepickerStyles;
public getTimezones(): { value: string; label: string }[] {
// Common timezones with their display names
return [
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
{ value: 'America/New_York', label: 'Eastern Time (US & Canada)' },
{ value: 'America/Chicago', label: 'Central Time (US & Canada)' },
{ value: 'America/Denver', label: 'Mountain Time (US & Canada)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' },
{ value: 'America/Phoenix', label: 'Arizona' },
{ value: 'America/Anchorage', label: 'Alaska' },
{ value: 'Pacific/Honolulu', label: 'Hawaii' },
{ value: 'Europe/London', label: 'London' },
{ value: 'Europe/Paris', label: 'Paris' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Europe/Moscow', label: 'Moscow' },
{ value: 'Asia/Dubai', label: 'Dubai' },
{ value: 'Asia/Kolkata', label: 'India Standard Time' },
{ value: 'Asia/Shanghai', label: 'China Standard Time' },
{ value: 'Asia/Tokyo', label: 'Tokyo' },
{ value: 'Australia/Sydney', label: 'Sydney' },
{ value: 'Pacific/Auckland', label: 'Auckland' },
];
}
public render(): TemplateResult {
return renderDatepicker(this);
}
async connectedCallback() {
super.connectedCallback();
this.handleClickOutside = this.handleClickOutside.bind(this);
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.handleClickOutside);
}
async firstUpdated() {
// Initialize with empty value if not set
if (!this.value) {
this.value = '';
}
// Initialize view date and selected time
if (this.value) {
try {
const date = new Date(this.value);
@@ -160,19 +119,15 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
if (isNaN(date.getTime())) return '';
let formatted = this.dateFormat;
// Basic date formatting
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear().toString();
// Replace in correct order to avoid conflicts
formatted = formatted.replace('YYYY', year);
formatted = formatted.replace('YY', year.slice(-2));
formatted = formatted.replace('MM', month);
formatted = formatted.replace('DD', day);
// Time formatting if enabled
if (this.enableTime) {
const hours24 = date.getHours();
const hours12 = hours24 === 0 ? 12 : hours24 > 12 ? hours24 - 12 : hours24;
@@ -186,17 +141,11 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// Timezone formatting if enabled
if (this.enableTimezone) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZoneName: 'short',
timeZone: this.timezone
});
const formatter = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short', timeZone: this.timezone });
const parts = formatter.formatToParts(date);
const tzPart = parts.find(part => part.type === 'timeZoneName');
if (tzPart) {
formatted += ` ${tzPart.value}`;
}
if (tzPart) formatted += ` ${tzPart.value}`;
}
return formatted;
@@ -205,274 +154,101 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
private handleClickOutside = (event: MouseEvent) => {
const path = event.composedPath();
if (!path.includes(this)) {
this.isOpened = false;
document.removeEventListener('click', this.handleClickOutside);
}
};
public async toggleCalendar(): Promise<void> {
if (this.disabled) return;
this.isOpened = !this.isOpened;
if (this.isOpened) {
// Check available space and set position
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
const rect = inputContainer.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
// Determine if we should open upwards (approximate height of 400px)
this.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
this.closePopup();
return;
}
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
} else {
document.removeEventListener('click', this.handleClickOutside);
this.isOpened = true;
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
const rect = inputContainer.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
if (!this.popupInstance) {
this.popupInstance = new DeesInputDatepickerPopup();
}
// Configure popup
this.popupInstance.triggerRect = rect;
this.popupInstance.ownerComponent = this;
this.popupInstance.opensToTop = opensToTop;
this.popupInstance.enableTime = this.enableTime;
this.popupInstance.timeFormat = this.timeFormat;
this.popupInstance.minuteIncrement = this.minuteIncrement;
this.popupInstance.weekStartsOn = this.weekStartsOn;
this.popupInstance.minDate = this.minDate;
this.popupInstance.maxDate = this.maxDate;
this.popupInstance.disabledDates = this.disabledDates;
this.popupInstance.enableTimezone = this.enableTimezone;
this.popupInstance.timezone = this.timezone;
this.popupInstance.events = this.events;
this.popupInstance.selectedDate = this.selectedDate;
this.popupInstance.viewDate = new Date(this.viewDate);
this.popupInstance.selectedHour = this.selectedHour;
this.popupInstance.selectedMinute = this.selectedMinute;
// Listen for popup events
this.popupInstance.addEventListener('date-selected', this.handleDateSelected);
this.popupInstance.addEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.addEventListener('close-request', this.handleCloseRequest);
this.popupInstance.addEventListener('reposition-request', this.handleRepositionRequest);
await this.popupInstance.show();
}
private closePopup(): void {
this.isOpened = false;
if (this.popupInstance) {
this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
}
}
public getDaysInMonth(): Date[] {
const year = this.viewDate.getFullYear();
const month = this.viewDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const days: Date[] = [];
// Adjust for week start
const startOffset = this.weekStartsOn === 1
? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1)
: firstDay.getDay();
// Add days from previous month
for (let i = startOffset; i > 0; i--) {
days.push(new Date(year, month, 1 - i));
}
// Add days of current month
for (let i = 1; i <= lastDay.getDate(); i++) {
days.push(new Date(year, month, i));
}
// Add days from next month to complete the grid (6 rows)
const remainingDays = 42 - days.length;
for (let i = 1; i <= remainingDays; i++) {
days.push(new Date(year, month + 1, i));
}
return days;
}
public isToday(date: Date): boolean {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
public isSelected(date: Date): boolean {
if (!this.selectedDate) return false;
return date.getDate() === this.selectedDate.getDate() &&
date.getMonth() === this.selectedDate.getMonth() &&
date.getFullYear() === this.selectedDate.getFullYear();
}
public isDisabled(date: Date): boolean {
// Check min date
if (this.minDate) {
const min = new Date(this.minDate);
if (date < min) return true;
}
// Check max date
if (this.maxDate) {
const max = new Date(this.maxDate);
if (date > max) return true;
}
// Check disabled dates
if (this.disabledDates && this.disabledDates.length > 0) {
return this.disabledDates.some(disabledStr => {
try {
const disabled = new Date(disabledStr);
return date.getDate() === disabled.getDate() &&
date.getMonth() === disabled.getMonth() &&
date.getFullYear() === disabled.getFullYear();
} catch {
return false;
}
});
}
return false;
}
public getEventsForDate(date: Date): IDateEvent[] {
if (!this.events || this.events.length === 0) return [];
const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
return this.events.filter(event => event.date === dateStr);
}
public selectDate(date: Date): void {
this.selectedDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
this.selectedHour,
this.selectedMinute
);
this.value = this.formatValueWithTimezone(this.selectedDate);
private handleDateSelected = (event: Event): void => {
const date = (event as CustomEvent).detail as Date;
this.selectedDate = date;
this.selectedHour = date.getHours();
this.selectedMinute = date.getMinutes();
this.viewDate = new Date(date);
this.value = this.formatValueWithTimezone(date);
this.changeSubject.next(this);
if (!this.enableTime) {
this.isOpened = false;
}
}
};
public selectToday(): void {
const today = new Date();
this.selectedDate = today;
this.viewDate = new Date(today);
this.selectedHour = today.getHours();
this.selectedMinute = today.getMinutes();
this.value = this.formatValueWithTimezone(this.selectedDate);
this.changeSubject.next(this);
if (!this.enableTime) {
this.isOpened = false;
}
}
public clear(): void {
private handleDateCleared = (): void => {
this.value = '';
this.selectedDate = null;
this.changeSubject.next(this);
this.isOpened = false;
}
};
public previousMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1);
}
private handleCloseRequest = (): void => {
this.closePopup();
};
public nextMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
}
private handleRepositionRequest = (): void => {
if (!this.popupInstance || !this.isOpened) return;
const inputContainer = this.shadowRoot!.querySelector('.input-container') as HTMLElement;
if (!inputContainer) return;
public handleHourInput(e: InputEvent): void {
const input = e.target as HTMLInputElement;
let value = parseInt(input.value) || 0;
if (this.timeFormat === '12h') {
value = Math.max(1, Math.min(12, value));
// Convert to 24h format
if (this.selectedHour >= 12 && value !== 12) {
this.selectedHour = value + 12;
} else if (this.selectedHour < 12 && value === 12) {
this.selectedHour = 0;
} else {
this.selectedHour = value;
}
} else {
this.selectedHour = Math.max(0, Math.min(23, value));
const rect = inputContainer.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) {
this.closePopup();
return;
}
this.updateSelectedDateTime();
}
public handleMinuteInput(e: InputEvent): void {
const input = e.target as HTMLInputElement;
let value = parseInt(input.value) || 0;
value = Math.max(0, Math.min(59, value));
if (this.minuteIncrement && this.minuteIncrement > 1) {
value = Math.round(value / this.minuteIncrement) * this.minuteIncrement;
}
this.selectedMinute = value;
this.updateSelectedDateTime();
}
public setAMPM(period: 'am' | 'pm'): void {
if (period === 'am' && this.selectedHour >= 12) {
this.selectedHour -= 12;
} else if (period === 'pm' && this.selectedHour < 12) {
this.selectedHour += 12;
}
this.updateSelectedDateTime();
}
private updateSelectedDateTime(): void {
if (this.selectedDate) {
this.selectedDate = new Date(
this.selectedDate.getFullYear(),
this.selectedDate.getMonth(),
this.selectedDate.getDate(),
this.selectedHour,
this.selectedMinute
);
this.value = this.formatValueWithTimezone(this.selectedDate);
this.changeSubject.next(this);
}
}
public handleTimezoneChange(e: Event): void {
const select = e.target as HTMLSelectElement;
this.timezone = select.value;
this.updateSelectedDateTime();
}
private formatValueWithTimezone(date: Date): string {
if (!this.enableTimezone) {
return date.toISOString();
}
// Format the date with timezone offset
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
timeZone: this.timezone,
timeZoneName: 'short'
});
const parts = formatter.formatToParts(date);
const dateParts: any = {};
parts.forEach(part => {
dateParts[part.type] = part.value;
});
// Create ISO-like format with timezone
const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
// Get timezone offset
const tzOffset = this.getTimezoneOffset(date, this.timezone);
return `${isoString}${tzOffset}`;
}
private getTimezoneOffset(date: Date, timezone: string): string {
// Create a date in the target timezone
const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60);
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const minutes = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
this.popupInstance.opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
this.popupInstance.triggerRect = rect;
};
public handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ' ') {
@@ -480,7 +256,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
this.toggleCalendar();
} else if (e.key === 'Escape' && this.isOpened) {
e.preventDefault();
this.isOpened = false;
this.closePopup();
}
}
@@ -494,9 +270,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
public handleManualInput(e: InputEvent): void {
const input = e.target as HTMLInputElement;
const inputValue = input.value.trim();
if (!inputValue) {
// Clear the value if input is empty
this.value = '';
this.selectedDate = null;
return;
@@ -504,7 +279,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
const parsedDate = this.parseManualDate(inputValue);
if (parsedDate && !isNaN(parsedDate.getTime())) {
// Update internal state without triggering re-render of input
this.value = parsedDate.toISOString();
this.selectedDate = parsedDate;
this.viewDate = new Date(parsedDate);
@@ -517,7 +291,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
public handleInputBlur(e: FocusEvent): void {
const input = e.target as HTMLInputElement;
const inputValue = input.value.trim();
if (!inputValue) {
this.value = '';
this.selectedDate = null;
@@ -533,10 +307,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
this.selectedHour = parsedDate.getHours();
this.selectedMinute = parsedDate.getMinutes();
this.changeSubject.next(this);
// Update the input with formatted date
input.value = this.formatDate(this.value);
} else {
// Revert to previous valid value on blur if parsing failed
input.value = this.formatDate(this.value);
}
}
@@ -544,22 +316,17 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
private parseManualDate(input: string): Date | null {
if (!input) return null;
// Split date and time parts if present
const parts = input.split(' ');
let datePart = parts[0];
let timePart = parts[1] || '';
let parsedDate: Date | null = null;
// Try different date formats
// Format 1: YYYY-MM-DD (ISO-like)
const isoMatch = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (isoMatch) {
const [_, year, month, day] = isoMatch;
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
// Format 2: DD.MM.YYYY (European)
if (!parsedDate) {
const euMatch = datePart.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (euMatch) {
@@ -568,7 +335,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// Format 3: MM/DD/YYYY (US)
if (!parsedDate) {
const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (usMatch) {
@@ -577,12 +343,8 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
// If no date was parsed, return null
if (!parsedDate || isNaN(parsedDate.getTime())) {
return null;
}
if (!parsedDate || isNaN(parsedDate.getTime())) return null;
// Parse time if present (HH:MM format)
if (timePart) {
const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})$/);
if (timeMatch) {
@@ -591,7 +353,6 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
parsedDate.setMinutes(parseInt(minutes));
}
} else if (!this.enableTime) {
// If time is not enabled and not provided, use current time
const now = new Date();
parsedDate.setHours(now.getHours());
parsedDate.setMinutes(now.getMinutes());
@@ -602,6 +363,34 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
return parsedDate;
}
private formatValueWithTimezone(date: Date): string {
if (!this.enableTimezone) return date.toISOString();
const formatter = new Intl.DateTimeFormat('en-US', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: this.timezone, timeZoneName: 'short',
});
const parts = formatter.formatToParts(date);
const dateParts: any = {};
parts.forEach(part => { dateParts[part.type] = part.value; });
const isoString = `${dateParts.year}-${dateParts.month}-${dateParts.day}T${dateParts.hour}:${dateParts.minute}:${dateParts.second}`;
const tzOffset = this.getTimezoneOffset(date, this.timezone);
return `${isoString}${tzOffset}`;
}
private getTimezoneOffset(date: Date, timezone: string): string {
const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
const offsetMinutes = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60);
const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const minutes = Math.abs(offsetMinutes) % 60;
const sign = offsetMinutes >= 0 ? '+' : '-';
return `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
public getValue(): string {
return this.value;
}
@@ -622,4 +411,16 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
}
}
}
}
async disconnectedCallback() {
await super.disconnectedCallback();
if (this.popupInstance) {
this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
this.popupInstance.hide();
this.popupInstance = null;
}
}
}

View File

@@ -0,0 +1,758 @@
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 { themeDefaultStyles } from '../../00theme.js';
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import type { IDateEvent } from './types.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-datepicker-popup': DeesInputDatepickerPopup;
}
}
@customElement('dees-input-datepicker-popup')
export class DeesInputDatepickerPopup extends DeesElement {
// Properties set by the parent
@property({ attribute: false })
accessor triggerRect: DOMRect | null = null;
@property({ attribute: false })
accessor ownerComponent: HTMLElement | null = null;
@property({ type: Boolean })
accessor enableTime: boolean = false;
@property({ type: String })
accessor timeFormat: '24h' | '12h' = '24h';
@property({ type: Number })
accessor minuteIncrement: number = 1;
@property({ type: Number })
accessor weekStartsOn: 0 | 1 = 1;
@property({ type: String })
accessor minDate: string = '';
@property({ type: String })
accessor maxDate: string = '';
@property({ type: Array })
accessor disabledDates: string[] = [];
@property({ type: Boolean })
accessor enableTimezone: boolean = false;
@property({ type: String })
accessor timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
@property({ type: Array })
accessor events: IDateEvent[] = [];
@property({ type: Boolean })
accessor opensToTop: boolean = false;
// Internal state
@state()
accessor selectedDate: Date | null = null;
@state()
accessor viewDate: Date = new Date();
@state()
accessor selectedHour: number = 0;
@state()
accessor selectedMinute: number = 0;
@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;
}
* {
box-sizing: border-box;
}
.calendar-popup {
position: fixed;
pointer-events: auto;
will-change: transform, opacity;
transition: all 0.15s ease;
opacity: 0;
transform: translateY(-4px);
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
box-shadow: ${cssManager.bdTheme(
'0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)',
'0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)'
)};
border-radius: 6px;
padding: 12px;
user-select: none;
min-width: 280px;
}
.calendar-popup.top {
transform: translateY(4px);
}
.calendar-popup.show {
transform: translateY(0);
opacity: 1;
}
/* Calendar Header */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 8px;
}
.month-year-display {
font-weight: 500;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
flex: 1;
text-align: center;
}
.nav-button {
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
transition: all 0.2s ease;
}
.nav-button:hover {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
}
/* Weekday headers */
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 4px;
}
.weekday {
text-align: center;
font-size: 12px;
font-weight: 400;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
padding: 0 0 8px 0;
}
/* Days grid */
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
border: none;
width: 36px;
height: 36px;
background: transparent;
position: relative;
}
.day:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
.day.other-month {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
opacity: 0.5;
}
.day.today {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
font-weight: 500;
}
.day.selected {
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
font-weight: 500;
}
.day.disabled {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
cursor: not-allowed;
opacity: 0.3;
}
/* Event indicators */
.event-indicator {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2px;
}
.event-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.event-dot.info { background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')}; }
.event-dot.warning { background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')}; }
.event-dot.success { background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')}; }
.event-dot.error { background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')}; }
.event-count {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
color: white;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.event-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')};
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}
.day.has-event:hover .event-tooltip { opacity: 1; }
/* Time selector */
.time-selector {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.time-selector-title {
font-size: 12px;
font-weight: 500;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.time-inputs {
display: flex;
gap: 8px;
align-items: center;
}
.time-input {
width: 65px;
height: 36px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
text-align: center;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
transition: all 0.2s ease;
}
.time-input:focus {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
}
.time-separator {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.am-pm-selector { display: flex; gap: 4px; margin-left: 8px; }
.am-pm-button {
padding: 6px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.am-pm-button.selected {
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
}
.am-pm-button:hover:not(.selected) {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
/* Timezone selector */
.timezone-selector {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.timezone-selector-title {
font-size: 12px;
font-weight: 500;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.timezone-select {
width: 100%;
height: 36px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
cursor: pointer;
}
/* Action buttons */
.calendar-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.action-button {
flex: 1;
height: 36px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.today-button {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
}
.today-button:hover {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
.clear-action-button {
background: transparent;
border: 1px solid transparent;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.clear-action-button:hover {
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')};
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
`,
];
private static readonly MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
private static readonly TIMEZONES = [
{ value: 'UTC', label: 'UTC (Coordinated Universal Time)' },
{ value: 'America/New_York', label: 'Eastern Time (US & Canada)' },
{ value: 'America/Chicago', label: 'Central Time (US & Canada)' },
{ value: 'America/Denver', label: 'Mountain Time (US & Canada)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (US & Canada)' },
{ value: 'Europe/London', label: 'London' },
{ value: 'Europe/Paris', label: 'Paris' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Europe/Moscow', label: 'Moscow' },
{ value: 'Asia/Dubai', label: 'Dubai' },
{ value: 'Asia/Kolkata', label: 'India Standard Time' },
{ value: 'Asia/Shanghai', label: 'China Standard Time' },
{ value: 'Asia/Tokyo', label: 'Tokyo' },
{ value: 'Australia/Sydney', label: 'Sydney' },
{ value: 'Pacific/Auckland', label: 'Auckland' },
];
public render(): TemplateResult {
if (!this.triggerRect) return html``;
const posStyle = this.computePositionStyle();
const weekDays = this.weekStartsOn === 1
? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const days = this.getDaysInMonth();
const isAM = this.selectedHour < 12;
return html`
<div
class="calendar-popup ${this.visible ? 'show' : ''} ${this.opensToTop ? 'top' : 'bottom'}"
style="${posStyle}; z-index: ${this.menuZIndex};"
>
<div class="calendar-header">
<button class="nav-button" @click=${this.previousMonth}>
<dees-icon icon="lucide:chevronLeft" iconSize="16"></dees-icon>
</button>
<div class="month-year-display">
${DeesInputDatepickerPopup.MONTH_NAMES[this.viewDate.getMonth()]} ${this.viewDate.getFullYear()}
</div>
<button class="nav-button" @click=${this.nextMonth}>
<dees-icon icon="lucide:chevronRight" iconSize="16"></dees-icon>
</button>
</div>
<div class="weekdays">
${weekDays.map(day => html`<div class="weekday">${day}</div>`)}
</div>
<div class="days-grid">
${days.map(day => {
const isToday = this.isToday(day);
const isSelected = this.isSelected(day);
const isOtherMonth = day.getMonth() !== this.viewDate.getMonth();
const isDayDisabled = this.isDayDisabled(day);
const dayEvents = this.getEventsForDate(day);
const hasEvents = dayEvents.length > 0;
const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0);
return html`
<div
class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDayDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}"
@click=${() => !isDayDisabled && this.handleSelectDate(day)}
>
${day.getDate()}
${hasEvents ? html`
${totalEventCount > 3 ? html`
<div class="event-count">${totalEventCount}</div>
` : html`
<div class="event-indicator">
${dayEvents.slice(0, 3).map(event => html`
<div class="event-dot ${event.type || 'info'}"></div>
`)}
</div>
`}
${dayEvents[0].title ? html`
<div class="event-tooltip">
${dayEvents[0].title}
${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
</div>
` : ''}
` : ''}
</div>
`;
})}
</div>
${this.enableTime ? html`
<div class="time-selector">
<div class="time-selector-title">Time</div>
<div class="time-inputs">
<input type="number" class="time-input"
.value=${this.timeFormat === '12h'
? (this.selectedHour === 0 ? 12 : this.selectedHour > 12 ? this.selectedHour - 12 : this.selectedHour).toString().padStart(2, '0')
: this.selectedHour.toString().padStart(2, '0')}
@input=${this.handleHourInput}
min="${this.timeFormat === '12h' ? 1 : 0}"
max="${this.timeFormat === '12h' ? 12 : 23}"
/>
<span class="time-separator">:</span>
<input type="number" class="time-input"
.value=${this.selectedMinute.toString().padStart(2, '0')}
@input=${this.handleMinuteInput}
min="0" max="59" step="${this.minuteIncrement || 1}"
/>
${this.timeFormat === '12h' ? html`
<div class="am-pm-selector">
<button class="am-pm-button ${isAM ? 'selected' : ''}" @click=${() => this.setAMPM('am')}>AM</button>
<button class="am-pm-button ${!isAM ? 'selected' : ''}" @click=${() => this.setAMPM('pm')}>PM</button>
</div>
` : ''}
</div>
</div>
` : ''}
${this.enableTimezone ? html`
<div class="timezone-selector">
<div class="timezone-selector-title">Timezone</div>
<select class="timezone-select" .value=${this.timezone} @change=${this.handleTimezoneChange}>
${DeesInputDatepickerPopup.TIMEZONES.map(tz => html`
<option value="${tz.value}" ?selected=${tz.value === this.timezone}>${tz.label}</option>
`)}
</select>
</div>
` : ''}
<div class="calendar-actions">
<button class="action-button today-button" @click=${this.handleSelectToday}>Today</button>
<button class="action-button clear-action-button" @click=${this.handleClear}>Clear</button>
</div>
</div>
`;
}
private computePositionStyle(): string {
const rect = this.triggerRect!;
const left = rect.left;
if (this.opensToTop) {
const bottom = window.innerHeight - rect.top + 4;
return `left: ${left}px; bottom: ${bottom}px; top: auto`;
} else {
const top = rect.bottom + 4;
return `left: ${left}px; top: ${top}px`;
}
}
// Calendar logic
private getDaysInMonth(): Date[] {
const year = this.viewDate.getFullYear();
const month = this.viewDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const days: Date[] = [];
const startOffset = this.weekStartsOn === 1
? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1)
: firstDay.getDay();
for (let i = startOffset; i > 0; i--) days.push(new Date(year, month, 1 - i));
for (let i = 1; i <= lastDay.getDate(); i++) days.push(new Date(year, month, i));
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) days.push(new Date(year, month + 1, i));
return days;
}
private isToday(date: Date): boolean {
const today = new Date();
return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear();
}
private isSelected(date: Date): boolean {
if (!this.selectedDate) return false;
return date.getDate() === this.selectedDate.getDate() && date.getMonth() === this.selectedDate.getMonth() && date.getFullYear() === this.selectedDate.getFullYear();
}
private isDayDisabled(date: Date): boolean {
if (this.minDate) { const min = new Date(this.minDate); if (date < min) return true; }
if (this.maxDate) { const max = new Date(this.maxDate); if (date > max) return true; }
if (this.disabledDates?.length) {
return this.disabledDates.some(ds => {
try { const d = new Date(ds); return date.getDate() === d.getDate() && date.getMonth() === d.getMonth() && date.getFullYear() === d.getFullYear(); }
catch { return false; }
});
}
return false;
}
private getEventsForDate(date: Date): IDateEvent[] {
if (!this.events?.length) return [];
const dateStr = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
return this.events.filter(e => e.date === dateStr);
}
private previousMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1);
}
private nextMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
}
// Event dispatching
private handleSelectDate(day: Date): void {
this.selectedDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), this.selectedHour, this.selectedMinute);
this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
if (!this.enableTime) {
this.dispatchEvent(new CustomEvent('close-request'));
}
}
private handleSelectToday(): void {
const today = new Date();
this.selectedDate = today;
this.viewDate = new Date(today);
this.selectedHour = today.getHours();
this.selectedMinute = today.getMinutes();
this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
if (!this.enableTime) {
this.dispatchEvent(new CustomEvent('close-request'));
}
}
private handleClear(): void {
this.dispatchEvent(new CustomEvent('date-cleared'));
this.dispatchEvent(new CustomEvent('close-request'));
}
private handleHourInput = (e: InputEvent): void => {
const input = e.target as HTMLInputElement;
let value = parseInt(input.value) || 0;
if (this.timeFormat === '12h') {
value = Math.max(1, Math.min(12, value));
if (this.selectedHour >= 12 && value !== 12) this.selectedHour = value + 12;
else if (this.selectedHour < 12 && value === 12) this.selectedHour = 0;
else this.selectedHour = value;
} else {
this.selectedHour = Math.max(0, Math.min(23, value));
}
this.emitTimeUpdate();
};
private handleMinuteInput = (e: InputEvent): void => {
const input = e.target as HTMLInputElement;
let value = parseInt(input.value) || 0;
value = Math.max(0, Math.min(59, value));
if (this.minuteIncrement > 1) value = Math.round(value / this.minuteIncrement) * this.minuteIncrement;
this.selectedMinute = value;
this.emitTimeUpdate();
};
private setAMPM(period: 'am' | 'pm'): void {
if (period === 'am' && this.selectedHour >= 12) this.selectedHour -= 12;
else if (period === 'pm' && this.selectedHour < 12) this.selectedHour += 12;
this.emitTimeUpdate();
}
private handleTimezoneChange = (e: Event): void => {
this.timezone = (e.target as HTMLSelectElement).value;
this.emitTimeUpdate();
};
private emitTimeUpdate(): void {
if (this.selectedDate) {
this.selectedDate = new Date(this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedHour, this.selectedMinute);
this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
}
}
// Show/hide lifecycle
public async show(): Promise<void> {
this.windowLayer = await DeesWindowLayer.createAndShow();
this.windowLayer.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('close-request'));
});
this.menuZIndex = zIndexRegistry.getNextZIndex();
zIndexRegistry.register(this, this.menuZIndex);
this.style.zIndex = this.menuZIndex.toString();
document.body.appendChild(this);
await domtools.plugins.smartdelay.delayFor(0);
this.visible = true;
window.addEventListener('scroll', this.handleScrollOrResize, { capture: true, passive: true });
window.addEventListener('resize', this.handleScrollOrResize, { passive: true });
}
public async hide(): Promise<void> {
if (this.isDestroying) return;
this.isDestroying = true;
window.removeEventListener('scroll', this.handleScrollOrResize, { capture: true } as EventListenerOptions);
window.removeEventListener('resize', this.handleScrollOrResize);
zIndexRegistry.unregister(this);
if (this.windowLayer) {
this.windowLayer.destroy();
this.windowLayer = null;
}
this.visible = false;
await domtools.plugins.smartdelay.delayFor(150);
if (this.parentElement) this.parentElement.removeChild(this);
this.isDestroying = false;
}
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

@@ -1 +1,2 @@
export * from './component.js';
export * from './datepicker-popup.js';

View File

@@ -56,7 +56,6 @@ export const datepickerStyles = [
opacity: 0.5;
}
/* Icon container using flexbox for better positioning */
.icon-container {
position: absolute;
right: 0;
@@ -101,414 +100,5 @@ export const datepickerStyles = [
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
}
.clear-button:disabled {
display: none;
}
/* Calendar Popup Styles */
.calendar-popup {
will-change: transform, opacity;
pointer-events: none;
transition: all 0.2s ease;
opacity: 0;
transform: translateY(-4px);
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
box-shadow: ${cssManager.bdTheme(
'0 10px 15px -3px hsl(0 0% 0% / 0.1), 0 4px 6px -4px hsl(0 0% 0% / 0.1)',
'0 10px 15px -3px hsl(0 0% 0% / 0.2), 0 4px 6px -4px hsl(0 0% 0% / 0.2)'
)};
border-radius: 6px;
padding: 12px;
position: absolute;
user-select: none;
margin-top: 4px;
z-index: 50;
left: 0;
min-width: 280px;
}
.calendar-popup.top {
bottom: calc(100% + 4px);
top: auto;
margin-top: 0;
margin-bottom: 4px;
transform: translateY(4px);
}
.calendar-popup.bottom {
top: 100%;
}
.calendar-popup.show {
pointer-events: all;
transform: translateY(0);
opacity: 1;
}
/* Calendar Header */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 8px;
}
.month-year-display {
font-weight: 500;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
flex: 1;
text-align: center;
}
.nav-button {
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
transition: all 0.2s ease;
}
.nav-button:hover {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
}
.nav-button:active {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
/* Weekday headers */
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
margin-bottom: 4px;
}
.weekday {
text-align: center;
font-size: 12px;
font-weight: 400;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
padding: 0 0 8px 0;
}
/* Days grid */
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 6px;
font-size: 14px;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
border: none;
width: 36px;
height: 36px;
background: transparent;
}
.day:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
.day.other-month {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
opacity: 0.5;
}
.day.today {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
font-weight: 500;
}
.day.selected {
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
font-weight: 500;
}
.day.disabled {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
cursor: not-allowed;
opacity: 0.3;
}
/* Event indicators */
.day.has-event {
position: relative;
}
.event-indicator {
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 2px;
justify-content: center;
}
.event-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.event-dot.info {
background: ${cssManager.bdTheme('hsl(211 70% 52%)', 'hsl(211 70% 62%)')};
}
.event-dot.warning {
background: ${cssManager.bdTheme('hsl(45 90% 45%)', 'hsl(45 90% 55%)')};
}
.event-dot.success {
background: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')};
}
.event-dot.error {
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
}
.event-count {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: ${cssManager.bdTheme('hsl(0 72% 51%)', 'hsl(0 72% 61%)')};
color: white;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* Tooltip for event details */
.event-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 0%)')};
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.event-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 4px solid transparent;
border-top-color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
}
.day.has-event:hover .event-tooltip {
opacity: 1;
}
/* Time selector */
.time-selector {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.time-selector-title {
font-size: 12px;
font-weight: 500;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.time-inputs {
display: flex;
gap: 8px;
align-items: center;
}
.time-input {
width: 65px;
height: 36px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
text-align: center;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
transition: all 0.2s ease;
}
.time-input:hover {
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
.time-input:focus {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
}
.time-separator {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.am-pm-selector {
display: flex;
gap: 4px;
margin-left: 8px;
}
.am-pm-button {
padding: 6px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.am-pm-button.selected {
background: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
color: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(222.2 47.4% 11.2%)')};
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
}
.am-pm-button:hover:not(.selected) {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
/* Action buttons */
.calendar-actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.action-button {
flex: 1;
height: 36px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.today-button {
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
}
.today-button:hover {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.today-button:active {
background: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.clear-button {
background: transparent;
border: 1px solid transparent;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.clear-button:hover {
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')};
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
.clear-button:active {
background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.2)', 'hsl(0 62.8% 30.6% / 0.2)')};
}
/* Timezone selector */
.timezone-selector {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
}
.timezone-selector-title {
font-size: 12px;
font-weight: 500;
margin-bottom: 8px;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.timezone-select {
width: 100%;
height: 36px;
border: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
border-radius: 6px;
padding: 0 12px;
font-size: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')};
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
cursor: pointer;
transition: all 0.2s ease;
}
.timezone-select:hover {
border-color: ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')};
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
}
.timezone-select:focus {
outline: none;
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
}
`,
];
];

View File

@@ -2,19 +2,6 @@ import { html, type TemplateResult } from '@design.estate/dees-element';
import type { DeesInputDatepicker } from './component.js';
export const renderDatepicker = (component: DeesInputDatepicker): TemplateResult => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const weekDays = component.weekStartsOn === 1
? ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const days = component.getDaysInMonth();
const isAM = component.selectedHour < 12;
const timezones = component.getTimezones();
return html`
<div class="input-wrapper">
<dees-label .label=${component.label} .description=${component.description} .required=${component.required}></dees-label>
@@ -39,141 +26,8 @@ export const renderDatepicker = (component: DeesInputDatepicker): TemplateResult
` : ''}
<dees-icon class="calendar-icon" icon="lucide:calendar" iconSize="16"></dees-icon>
</div>
<!-- Calendar Popup -->
<div class="calendar-popup ${component.isOpened ? 'show' : ''} ${component.opensToTop ? 'top' : 'bottom'}">
<!-- Month/Year Navigation -->
<div class="calendar-header">
<button class="nav-button" @click=${component.previousMonth}>
<dees-icon icon="lucide:chevronLeft" iconSize="16"></dees-icon>
</button>
<div class="month-year-display">
${monthNames[component.viewDate.getMonth()]} ${component.viewDate.getFullYear()}
</div>
<button class="nav-button" @click=${component.nextMonth}>
<dees-icon icon="lucide:chevronRight" iconSize="16"></dees-icon>
</button>
</div>
<!-- Weekday Headers -->
<div class="weekdays">
${weekDays.map(day => html`<div class="weekday">${day}</div>`)}
</div>
<!-- Days Grid -->
<div class="days-grid">
${days.map(day => {
const isToday = component.isToday(day);
const isSelected = component.isSelected(day);
const isOtherMonth = day.getMonth() !== component.viewDate.getMonth();
const isDisabled = component.isDisabled(day);
const dayEvents = component.getEventsForDate(day);
const hasEvents = dayEvents.length > 0;
const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0);
return html`
<div
class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}"
@click=${() => !isDisabled && component.selectDate(day)}
>
${day.getDate()}
${hasEvents ? html`
${totalEventCount > 3 ? html`
<div class="event-count">${totalEventCount}</div>
` : html`
<div class="event-indicator">
${dayEvents.slice(0, 3).map(event => html`
<div class="event-dot ${event.type || 'info'}"></div>
`)}
</div>
`}
${dayEvents[0].title ? html`
<div class="event-tooltip">
${dayEvents[0].title}
${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
</div>
` : ''}
` : ''}
</div>
`;
})}
</div>
<!-- Time Selector -->
${component.enableTime ? html`
<div class="time-selector">
<div class="time-selector-title">Time</div>
<div class="time-inputs">
<input
type="number"
class="time-input"
.value=${component.timeFormat === '12h'
? (component.selectedHour === 0 ? 12 : component.selectedHour > 12 ? component.selectedHour - 12 : component.selectedHour).toString().padStart(2, '0')
: component.selectedHour.toString().padStart(2, '0')}
@input=${(e: InputEvent) => component.handleHourInput(e)}
min="${component.timeFormat === '12h' ? 1 : 0}"
max="${component.timeFormat === '12h' ? 12 : 23}"
/>
<span class="time-separator">:</span>
<input
type="number"
class="time-input"
.value=${component.selectedMinute.toString().padStart(2, '0')}
@input=${(e: InputEvent) => component.handleMinuteInput(e)}
min="0"
max="59"
step="${component.minuteIncrement || 1}"
/>
${component.timeFormat === '12h' ? html`
<div class="am-pm-selector">
<button
class="am-pm-button ${isAM ? 'selected' : ''}"
@click=${() => component.setAMPM('am')}
>
AM
</button>
<button
class="am-pm-button ${!isAM ? 'selected' : ''}"
@click=${() => component.setAMPM('pm')}
>
PM
</button>
</div>
` : ''}
</div>
</div>
` : ''}
<!-- Timezone Selector -->
${component.enableTimezone ? html`
<div class="timezone-selector">
<div class="timezone-selector-title">Timezone</div>
<select
class="timezone-select"
.value=${component.timezone}
@change=${(e: Event) => component.handleTimezoneChange(e)}
>
${timezones.map(tz => html`
<option value="${tz.value}" ?selected=${tz.value === component.timezone}>
${tz.label}
</option>
`)}
</select>
</div>
` : ''}
<!-- Action Buttons -->
<div class="calendar-actions">
<button class="action-button today-button" @click=${component.selectToday}>
Today
</button>
<button class="action-button clear-button" @click=${component.clear}>
Clear
</button>
</div>
</div>
</div>
</div>
`;
};

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

@@ -3,6 +3,7 @@ import { demoFunc } from './demo.js';
import { fileuploadStyles } from './styles.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import '../../00group-layout/dees-label/dees-label.js';
import '../../00group-layout/dees-tile/dees-tile.js';
import {
customElement,
@@ -75,14 +76,13 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
.description=${this.description}
.required=${this.required}
></dees-label>
<div
class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}"
role="button"
<dees-tile
class="${this.state === 'dragOver' ? 'dragover' : ''}"
@click=${this.handleDropzoneClick}
@keydown=${this.handleDropzoneKeydown}
tabindex=${this.disabled ? -1 : 0}
aria-disabled=${this.disabled}
aria-label=${`Select files${acceptedSummary ? ` (${acceptedSummary})` : ''}`}
@click=${this.handleDropzoneClick}
@keydown=${this.handleDropzoneKeydown}
>
<input
class="file-input"
@@ -94,32 +94,23 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
@change=${this.handleFileInputChange}
tabindex="-1"
/>
<div class="dropzone__body">
<div class="dropzone__icon">
${this.isLoading
? html`<span class="dropzone__loader" aria-hidden="true"></span>`
: html`<dees-icon icon="lucide:FolderOpen"></dees-icon>`}
</div>
<div class="dropzone__content">
<span class="dropzone__headline">${this.buttonText || 'Select files'}</span>
<span class="dropzone__subline">
Drag and drop files here or
<button
type="button"
class="dropzone__browse"
@click=${this.handleBrowseClick}
?disabled=${this.disabled}
>
browse
</button>
</span>
</div>
</div>
<div class="dropzone__meta">
${metaEntries.map((entry) => html`<span>${entry}</span>`)}
<div slot="header" class="dropzone-header">
${this.isLoading
? html`<span class="dropzone-loader" aria-hidden="true"></span>`
: html`<dees-icon icon="lucide:Upload"></dees-icon>`}
<span class="dropzone-title">Drop files here or</span>
<button
type="button"
class="dropzone-browse"
@click=${(e: MouseEvent) => { e.stopPropagation(); this.openFileSelector(); }}
?disabled=${this.disabled}
>browse</button>
</div>
${this.renderFileList()}
</div>
<div slot="footer" class="dropzone-footer">
${metaEntries.map((entry) => html`<span class="meta-chip">${entry}</span>`)}
</div>
</dees-tile>
${this.validationMessage
? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
: html``}
@@ -129,20 +120,21 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
private renderFileList(): TemplateResult {
if (this.value.length === 0) {
return html``;
return html`
<div class="file-list-empty">
<dees-icon icon="lucide:FileStack"></dees-icon>
<span>No files selected</span>
</div>
`;
}
return html`
<div class="file-list">
<div class="file-list__header">
<div class="file-list-header">
<span>${this.value.length} file${this.value.length === 1 ? '' : 's'} selected</span>
${this.value.length > 0
? html`<button type="button" class="file-list__clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>`
: html``}
</div>
<div class="file-list__items">
${this.value.map((file) => this.renderFileRow(file))}
<button type="button" class="file-list-clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>
</div>
${this.value.map((file) => this.renderFileRow(file))}
</div>
`;
}
@@ -193,21 +185,14 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
if (this.disabled) {
return;
}
// Don't open file selector if clicking on the browse button or file list
if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) {
// Don't open file selector when clicking file list items or actions
const target = event.target as HTMLElement;
if (target.closest('.file-list, .dropzone-header, .dropzone-footer')) {
return;
}
this.openFileSelector();
};
private handleBrowseClick = (event: MouseEvent) => {
if (this.disabled) {
return;
}
event.stopPropagation(); // Stop propagation to prevent double trigger
this.openFileSelector();
};
private handleDropzoneKeydown = (event: KeyboardEvent) => {
if (this.disabled) {
return;
@@ -280,7 +265,7 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
}
private rebindInteractiveElements(): void {
const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null;
const newDropArea = this.shadowRoot?.querySelector('dees-tile') as HTMLElement | null;
if (newDropArea !== this.dropArea) {
this.detachDropListeners();

View File

@@ -1,201 +1,158 @@
import { css, cssManager } from '@design.estate/dees-element';
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
import { themeDefaultStyles } from '../../00theme.js';
export const fileuploadStyles = [
cssManager.defaultStyles,
themeDefaultStyles,
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
position: relative;
display: block;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
.dropzone {
position: relative;
padding: 20px;
border-radius: 12px;
border: 1.5px dashed ${cssManager.bdTheme('hsl(215 16% 80%)', 'hsl(217 20% 25%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')};
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
cursor: pointer;
outline: none;
/* ── Tile integration ── */
dees-tile {
cursor: default;
}
.dropzone:focus-visible {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')},
0 0 0 4px ${cssManager.bdTheme('hsl(217 91% 60% / 0.5)', 'hsl(213 93% 68% / 0.4)')};
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
dees-tile:hover::part(outer) {
border-color: var(--dees-color-border-strong);
}
.dropzone--active {
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
box-shadow: 0 12px 32px ${cssManager.bdTheme('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.35)')};
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.06)', 'hsl(213 93% 68% / 0.12)')};
dees-tile.dragover::part(outer) {
border-color: var(--dees-color-accent-primary);
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(217 91% 60% / 0.15)', 'hsl(213 93% 68% / 0.15)')};
}
.dropzone--has-files {
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 11%)')};
}
.dropzone--disabled {
:host([disabled]) dees-tile {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
pointer-events: none;
}
.dropzone__body {
/* ── Header slot: sleek toolbar ── */
.dropzone-header {
display: flex;
align-items: center;
gap: 16px;
gap: 8px;
height: 32px;
padding: 0 12px;
}
.dropzone__icon {
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.12)', 'hsl(213 93% 68% / 0.12)')};
position: relative;
flex-shrink: 0;
.dropzone-header dees-icon {
width: 14px;
height: 14px;
color: var(--dees-color-text-muted);
}
.dropzone__icon dees-icon {
font-size: 22px;
}
.dropzone__loader {
width: 20px;
height: 20px;
border-radius: 999px;
.dropzone-loader {
width: 14px;
height: 14px;
border-radius: var(--dees-radius-full);
border: 2px solid ${cssManager.bdTheme('rgba(15, 23, 42, 0.15)', 'rgba(255, 255, 255, 0.15)')};
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
border-top-color: var(--dees-color-accent-primary);
animation: loader-spin 0.6s linear infinite;
}
.dropzone__content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.dropzone__headline {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
}
.dropzone__subline {
.dropzone-title {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
color: var(--dees-color-text-muted);
}
.dropzone__browse {
.dropzone-browse {
appearance: none;
border: none;
background: none;
padding: 0;
margin-left: 4px;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
font-size: 13px;
font-family: inherit;
font-weight: 600;
color: var(--dees-color-accent-primary);
cursor: pointer;
text-decoration: none;
}
.dropzone__browse:hover {
.dropzone-browse:hover {
text-decoration: underline;
}
.dropzone__browse:disabled {
.dropzone-browse:disabled {
cursor: not-allowed;
opacity: 0.6;
opacity: 0.5;
}
.dropzone__meta {
margin-top: 14px;
/* ── Content slot: file list in rounded inset ── */
.file-list-empty {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')};
padding: 24px 16px;
color: var(--dees-color-text-muted);
font-size: 13px;
}
.dropzone__meta span {
padding: 4px 10px;
border-radius: 999px;
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(213 93% 18%)')};
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
.file-list-empty dees-icon {
font-size: 24px;
opacity: 0.4;
}
.file-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
}
.file-list__header {
.file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 68%)')};
color: var(--dees-color-text-muted);
}
.file-list__clear {
.file-list-clear {
appearance: none;
border: none;
background: none;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
color: var(--dees-color-accent-primary);
cursor: pointer;
font-weight: 500;
font-size: 13px;
font-size: 12px;
padding: 0;
font-family: inherit;
}
.file-list__clear:hover {
.file-list-clear:hover {
text-decoration: underline;
}
.file-list__items {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.5)', 'hsl(215 20% 16% / 0.5)')};
border: 1px solid ${cssManager.bdTheme('hsl(213 27% 92%)', 'hsl(217 25% 26%)')};
border-radius: 8px;
transition: background 0.15s ease;
padding: 6px 12px;
transition: background var(--dees-transition-fast) ease;
}
.file-row:hover {
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.8)', 'hsl(215 20% 16% / 0.8)')};
background: var(--dees-color-row-hover);
}
.file-thumb {
width: 36px;
height: 36px;
border-radius: 8px;
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')};
width: 32px;
height: 32px;
border-radius: var(--dees-radius-sm);
background: var(--dees-color-bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
@@ -204,16 +161,15 @@ export const fileuploadStyles = [
}
.file-thumb dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')};
font-size: 16px;
color: var(--dees-color-text-muted);
display: block;
width: 18px;
height: 18px;
width: 16px;
height: 16px;
line-height: 1;
flex-shrink: 0;
}
.thumb-image {
width: 100%;
height: 100%;
@@ -223,14 +179,14 @@ export const fileuploadStyles = [
.file-meta {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 0;
}
.file-name {
font-weight: 600;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
font-weight: 500;
font-size: 13px;
color: var(--dees-color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -241,8 +197,8 @@ export const fileuploadStyles = [
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
font-size: 11px;
color: var(--dees-color-text-muted);
}
.file-size {
@@ -250,39 +206,40 @@ export const fileuploadStyles = [
}
.file-type {
padding: 2px 8px;
border-radius: 999px;
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 32% 28%)')};
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
padding: 1px 6px;
border-radius: var(--dees-radius-full);
border: 1px solid var(--dees-color-border-default);
color: var(--dees-color-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
line-height: 1;
font-size: 10px;
}
.file-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.remove-button {
width: 28px;
height: 28px;
border-radius: 6px;
width: 24px;
height: 24px;
border-radius: var(--dees-radius-xs);
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, transform 0.15s ease, color 0.15s ease;
color: ${cssManager.bdTheme('hsl(215 16% 52%)', 'hsl(215 16% 68%)')};
transition: background var(--dees-transition-fast) ease,
color var(--dees-transition-fast) ease;
color: var(--dees-color-text-muted);
}
.remove-button:hover {
background: ${cssManager.bdTheme('hsl(0 72% 50% / 0.08)', 'hsl(0 62% 32% / 0.15)')};
color: ${cssManager.bdTheme('hsl(0 72% 46%)', 'hsl(0 70% 70%)')};
color: var(--dees-color-accent-error);
}
.remove-button:active {
@@ -298,9 +255,28 @@ export const fileuploadStyles = [
flex-shrink: 0;
}
/* ── Footer slot: meta chips ── */
.dropzone-footer {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px 12px;
align-items: center;
}
.meta-chip {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--dees-radius-full);
color: var(--dees-color-text-muted);
background: var(--dees-color-bg-tertiary);
border: 1px solid var(--dees-color-border-subtle);
}
/* ── Validation ── */
.validation-message {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
color: var(--dees-color-accent-error);
line-height: 1.5;
}

View File

@@ -262,7 +262,85 @@ export const demoFunc = () => html`
></dees-input-list>
</dees-panel>
<dees-panel .title=${'9. Empty State'} .subtitle=${'How the component looks with no items'}>
<dees-panel .title=${'9. Candidates with Tab Completion'} .subtitle=${'Terminal-style autocomplete — Tab accepts, Shift+Tab cycles'}>
<div class="grid-layout">
<dees-input-list
id="candidate-list"
.label=${'Assign Team Members'}
.placeholder=${'Type a name... (Tab to complete)'}
.candidates=${[
{ viewKey: 'Alice Smith', payload: { id: 1, role: 'Engineer', department: 'Frontend' } },
{ viewKey: 'Bob Johnson', payload: { id: 2, role: 'Designer', department: 'UX' } },
{ viewKey: 'Carol Williams', payload: { id: 3, role: 'Product Manager', department: 'Product' } },
{ viewKey: 'David Brown', payload: { id: 4, role: 'Engineer', department: 'Backend' } },
{ viewKey: 'Eve Davis', payload: { id: 5, role: 'QA Engineer', department: 'Quality' } },
{ viewKey: 'Frank Miller', payload: { id: 6, role: 'DevOps', department: 'Infrastructure' } },
{ viewKey: 'Grace Wilson', payload: { id: 7, role: 'Designer', department: 'UX' } },
{ viewKey: 'Henry Moore', payload: { id: 8, role: 'Engineer', department: 'Frontend' } },
]}
.value=${['Alice Smith', 'Carol Williams']}
.maxItems=${5}
.description=${'Type to see ghost completion. Tab to accept, Shift+Tab to cycle, Enter to add.'}
@change=${(e: CustomEvent) => {
const preview = document.querySelector('#candidate-json');
if (preview) {
const list = (e.target as any);
const candidates = list.getAddedCandidates();
preview.textContent = JSON.stringify(candidates, null, 2);
}
}}
></dees-input-list>
<div>
<div style="font-size: 13px; font-weight: 500; margin-bottom: 8px; color: inherit;">Selected Candidates (with payloads)</div>
<div class="output-preview" id="candidate-json">[]</div>
<div class="feature-note">
Try typing "D" — ghost text shows "avid Brown". Press Shift+Tab to cycle to other D-matches. Tab accepts, Enter adds.
</div>
</div>
</div>
</dees-panel>
<dees-panel .title=${'10. Technology Stack'} .subtitle=${'Larger candidate pool with Shift+Tab cycling'}>
<dees-input-list
.label=${'Select Technologies'}
.placeholder=${'Type to autocomplete...'}
.candidates=${[
{ viewKey: 'TypeScript', payload: { category: 'language' } },
{ viewKey: 'React', payload: { category: 'framework' } },
{ viewKey: 'Vue.js', payload: { category: 'framework' } },
{ viewKey: 'Angular', payload: { category: 'framework' } },
{ viewKey: 'Node.js', payload: { category: 'runtime' } },
{ viewKey: 'Deno', payload: { category: 'runtime' } },
{ viewKey: 'Docker', payload: { category: 'devops' } },
{ viewKey: 'PostgreSQL', payload: { category: 'database' } },
{ viewKey: 'MongoDB', payload: { category: 'database' } },
{ viewKey: 'Redis', payload: { category: 'database' } },
{ viewKey: 'Kubernetes', payload: { category: 'devops' } },
]}
.description=${'Try "D" — cycles through Deno/Docker. "R" — cycles through React/Redis.'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'11. Freeform + Candidates'} .subtitle=${'Allow adding items not in the candidate list (shown with a question mark)'}>
<dees-input-list
.label=${'Tags'}
.placeholder=${'Type a tag... (freeform allowed)'}
.allowFreeform=${true}
.candidates=${[
{ viewKey: 'bug', payload: { color: 'red' } },
{ viewKey: 'feature', payload: { color: 'blue' } },
{ viewKey: 'docs', payload: { color: 'green' } },
{ viewKey: 'refactor', payload: { color: 'purple' } },
{ viewKey: 'performance', payload: { color: 'orange' } },
{ viewKey: 'security', payload: { color: 'red' } },
]}
.value=${['bug', 'my-custom-tag', 'feature']}
.description=${'Known tags get a checkmark, custom tags get a question mark. Tab to complete, Enter to add freeform.'}
></dees-input-list>
</dees-panel>
<dees-panel .title=${'12. Empty State'} .subtitle=${'How the component looks with no items'}>
<dees-input-list
.label=${'Your Ideas'}
.placeholder=${'Share your ideas...'}

View File

@@ -9,9 +9,15 @@ import {
} from '@design.estate/dees-element';
import { DeesInputBase } from '../dees-input-base/dees-input-base.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import '../../00group-layout/dees-tile/dees-tile.js';
import { demoFunc } from './dees-input-list.demo.js';
import { themeDefaultStyles } from '../../00theme.js';
export interface IListCandidate {
viewKey: string;
payload?: any;
}
declare global {
interface HTMLElementTagNameMap {
'dees-input-list': DeesInputList;
@@ -46,12 +52,27 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
@property({ type: Boolean })
accessor confirmDelete: boolean = false;
@property({ type: Array })
accessor candidates: IListCandidate[] = [];
@property({ type: Boolean })
accessor allowFreeform: boolean = false;
@property({ type: String })
accessor validationText: string = '';
private addedCandidatesMap: Map<string, IListCandidate> = new Map();
private matchingCandidates: IListCandidate[] = [];
@state()
accessor inputValue: string = '';
@state()
accessor ghostText: string = '';
@state()
accessor currentCandidateIndex: number = -1;
@state()
accessor editingIndex: number = -1;
@@ -99,26 +120,19 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
width: 100%;
}
.list-container {
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%)')};
border-radius: 6px;
overflow: hidden;
transition: all 0.15s ease;
dees-tile:hover::part(outer) {
border-color: var(--dees-color-border-strong);
}
.list-container:hover:not(.disabled) {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.list-container:focus-within {
dees-tile:focus-within::part(outer) {
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)')};
}
.list-container.disabled {
:host([disabled]) dees-tile {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.list-items {
@@ -131,8 +145,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border-bottom: 1px solid var(--dees-color-border-subtle);
background: transparent;
transition: transform 0.2s ease, background 0.15s ease, box-shadow 0.15s ease;
position: relative;
overflow: hidden;
@@ -143,7 +157,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
}
.list-items:not(.is-dragging) .list-item:hover:not(.disabled) {
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
background: var(--dees-color-row-hover);
}
/* Dragging item - follows cursor */
@@ -167,6 +181,28 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
}
.candidate-check {
width: 14px;
height: 14px;
color: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3%)', 'hsl(142.1 70.6% 45.3%)')};
flex-shrink: 0;
}
.candidate-unknown {
width: 14px;
height: 14px;
color: ${cssManager.bdTheme('hsl(45 93% 47%)', 'hsl(45 93% 58%)')};
flex-shrink: 0;
}
.item-bullet {
width: 14px;
height: 14px;
color: var(--dees-color-text-muted);
flex-shrink: 0;
opacity: 0.5;
}
.drag-handle {
display: flex;
align-items: center;
@@ -269,12 +305,10 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
display: flex;
gap: 6px;
padding: 6px 10px;
background: ${cssManager.bdTheme('hsl(0 0% 97.5%)', 'hsl(0 0% 6.9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
}
.add-input {
flex: 1;
width: 100%;
padding: 4px 8px;
font-size: 13px;
line-height: 18px;
@@ -368,6 +402,38 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
.list-items.dropping .list-item {
transition: none !important;
}
/* ── Terminal-style inline autocomplete ── */
.autocomplete-wrapper {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
}
.ghost-text {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 4px 8px;
font-size: 13px;
line-height: 18px;
font-family: inherit;
white-space: nowrap;
pointer-events: none;
overflow: hidden;
}
.ghost-typed {
visibility: hidden;
}
.ghost-completion {
color: ${cssManager.bdTheme('hsl(0 0% 63.9%)', 'hsl(0 0% 45.1%)')};
opacity: 0.5;
}
`,
];
@@ -376,7 +442,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
<div class="input-wrapper">
${this.label ? html`<dees-label .label=${this.label} .required=${this.required}></dees-label>` : ''}
<div class="list-container ${this.disabled ? 'disabled' : ''}">
<dees-tile .heading="${this.value.length} item${this.value.length !== 1 ? 's' : ''}">
<div class="list-items">
${this.value.length > 0 ? this.value.map((item, index) => html`
<div
@@ -392,7 +458,17 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
<dees-icon .icon=${'lucide:gripVertical'}></dees-icon>
</div>
` : ''}
${this.candidates.length > 0 ? html`
${this.candidates.some(c => c.viewKey === item) ? html`
<dees-icon class="candidate-check" .icon=${'lucide:check'}></dees-icon>
` : html`
<dees-icon class="candidate-unknown" .icon=${'lucide:helpCircle'}></dees-icon>
`}
` : !this.sortable || this.disabled ? html`
<dees-icon class="item-bullet" .icon=${'lucide:dot'}></dees-icon>
` : ''}
<div class="item-content">
${this.editingIndex === index ? html`
<input
@@ -438,16 +514,23 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
</div>
${!this.disabled && (!this.maxItems || this.value.length < this.maxItems) ? html`
<div class="add-item-container">
<input
type="text"
class="add-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleAddKeyDown}
?disabled=${this.disabled}
/>
<div slot="footer" class="add-item-container">
<div class="autocomplete-wrapper">
${this.ghostText ? html`
<span class="ghost-text">
<span class="ghost-typed">${this.inputValue}</span><span class="ghost-completion">${this.ghostText}</span>
</span>
` : ''}
<input
type="text"
class="add-input"
.placeholder=${this.placeholder}
.value=${this.inputValue}
@input=${this.handleInput}
@keydown=${this.handleAddKeyDown}
?disabled=${this.disabled}
/>
</div>
<button
class="add-button"
@click=${this.addItem}
@@ -457,7 +540,7 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
</button>
</div>
` : ''}
</div>
</dees-tile>
${this.validationText ? html`
<div class="validation-message">${this.validationText}</div>
@@ -472,11 +555,82 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
private handleInput(e: InputEvent) {
this.inputValue = (e.target as HTMLInputElement).value;
this.updateGhostText();
}
private updateGhostText(): void {
if (this.candidates.length === 0 || !this.inputValue) {
this.ghostText = '';
this.currentCandidateIndex = -1;
this.matchingCandidates = [];
return;
}
const search = this.inputValue.toLowerCase();
this.matchingCandidates = this.candidates
.filter(c => {
if (this.value.includes(c.viewKey)) return false;
return c.viewKey.toLowerCase().startsWith(search);
})
.sort((a, b) => a.viewKey.length - b.viewKey.length);
if (this.matchingCandidates.length > 0) {
this.currentCandidateIndex = 0;
this.ghostText = this.matchingCandidates[0].viewKey.slice(this.inputValue.length);
} else {
this.currentCandidateIndex = -1;
this.ghostText = '';
}
}
private handleAddKeyDown(e: KeyboardEvent) {
// Tab/Shift+Tab: autocomplete handling when candidates are active
if (e.key === 'Tab' && this.candidates.length > 0 && this.inputValue) {
e.preventDefault();
if (e.shiftKey && this.matchingCandidates.length > 0) {
// Shift+Tab: cycle to next candidate
this.currentCandidateIndex = (this.currentCandidateIndex + 1) % this.matchingCandidates.length;
const candidate = this.matchingCandidates[this.currentCandidateIndex];
this.ghostText = candidate.viewKey.slice(this.inputValue.length);
} else if (!e.shiftKey && this.ghostText && this.matchingCandidates.length > 0) {
// Tab: accept the completion into the input
const candidate = this.matchingCandidates[this.currentCandidateIndex];
this.inputValue = candidate.viewKey;
this.ghostText = '';
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
if (input) input.value = candidate.viewKey;
}
return;
}
// Escape: clear ghost text
if (e.key === 'Escape' && this.ghostText) {
e.preventDefault();
this.ghostText = '';
this.currentCandidateIndex = -1;
this.matchingCandidates = [];
return;
}
// Enter: add item
if (e.key === 'Enter' && this.inputValue.trim()) {
e.preventDefault();
if (this.candidates.length > 0) {
// Try exact candidate match first
const match = this.candidates.find(
c => c.viewKey.toLowerCase() === this.inputValue.trim().toLowerCase()
);
if (match) {
this.selectCandidate(match);
} else if (this.allowFreeform) {
// Allow freeform entry (won't have a candidate checkmark)
this.ghostText = '';
this.currentCandidateIndex = -1;
this.matchingCandidates = [];
this.addItem();
}
return;
}
this.addItem();
}
}
@@ -491,6 +645,52 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
}
}
private selectCandidate(candidate: IListCandidate): void {
if (this.maxItems && this.value.length >= this.maxItems) {
this.validationText = `Maximum ${this.maxItems} items allowed`;
setTimeout(() => this.validationText = '', 3000);
return;
}
if (!this.allowDuplicates && this.value.includes(candidate.viewKey)) {
this.validationText = 'This item already exists in the list';
setTimeout(() => this.validationText = '', 3000);
return;
}
this.addedCandidatesMap.set(candidate.viewKey, candidate);
this.value = [...this.value, candidate.viewKey];
this.inputValue = '';
this.ghostText = '';
this.currentCandidateIndex = -1;
this.matchingCandidates = [];
this.validationText = '';
this.emitChange();
// Re-focus input after Lit re-renders
this.updateComplete.then(() => {
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
if (input) { input.value = ''; input.focus(); }
});
}
/**
* Get the full candidate object for an item by its viewKey.
* Returns undefined if the item was added as a plain string.
*/
public getCandidateForItem(viewKey: string): IListCandidate | undefined {
return this.addedCandidatesMap.get(viewKey);
}
/**
* Get all added candidates with their payloads.
*/
public getAddedCandidates(): IListCandidate[] {
return this.value
.map(v => this.addedCandidatesMap.get(v))
.filter((c): c is IListCandidate => c !== undefined);
}
private addItem() {
const trimmedValue = this.inputValue.trim();
if (!trimmedValue) return;
@@ -510,15 +710,13 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
this.value = [...this.value, trimmedValue];
this.inputValue = '';
this.validationText = '';
// Clear the input
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
if (input) {
input.value = '';
input.focus();
}
this.emitChange();
// Re-focus input after Lit re-renders
this.updateComplete.then(() => {
const input = this.shadowRoot?.querySelector('.add-input') as HTMLInputElement;
if (input) { input.value = ''; input.focus(); }
});
}
private startEdit(index: number) {
@@ -570,6 +768,8 @@ export class DeesInputList extends DeesInputBase<DeesInputList> {
if (!confirmed) return;
}
const removedKey = this.value[index];
this.addedCandidatesMap.delete(removedKey);
this.value = this.value.filter((_, i) => i !== index);
this.emitChange();
}

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 {

View File

@@ -338,6 +338,7 @@ export class DeesIcon extends DeesElement {
justify-content: center;
line-height: 1;
vertical-align: middle;
pointer-events: none;
}
/* Improve rendering performance */

View File

@@ -253,6 +253,7 @@ export const themeDefaultStyles: CSSResult = css`
--dees-color-hover: ${cssManager.bdTheme('rgba(0, 0, 0, 0.04)', 'rgba(255, 255, 255, 0.06)')};
--dees-color-active: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.08)')};
--dees-color-pressed: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.12)')};
--dees-color-row-hover: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.06)', 'hsl(217.2 91.2% 59.8% / 0.08)')};
/* ========================================
* Colors — Focus Ring