Compare commits

...

8 Commits

Author SHA1 Message Date
4190324cb4 1.12.3
Some checks failed
Default (tags) / security (push) Failing after 20s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-19 17:36:03 +00:00
1b108fcc8c fix(dees-input-fileupload): Show selected files inside dropzone and improve file upload UX 2025-09-19 17:36:03 +00:00
0b2675c7e5 fix(dees-input-fileupload): enhance dropzone styles and improve file list rendering 2025-09-19 17:35:58 +00:00
12b0aa0aad Refactor dees-input-fileupload component and styles
- Updated demo.ts to enhance layout and styling, including renaming classes and adjusting spacing.
- Removed unused template rendering logic from template.ts.
- Simplified index.ts by removing the export of renderFileupload.
- Revamped styles in styles.ts for improved design consistency and responsiveness.
- Enhanced file upload functionality with better descriptions and validation messages.
2025-09-19 17:31:26 +00:00
987ae70e7a feat: add DeesInputFileupload and DeesInputRichtext components
- Implemented DeesInputFileupload component with file upload functionality, including drag-and-drop support, file previews, and clear all option.
- Developed DeesInputRichtext component featuring a rich text editor with a formatting toolbar, link management, and word count display.
- Created demo for DeesInputRichtext showcasing various use cases including basic editing, placeholder text, different heights, and disabled state.
- Added styles for both components to ensure a consistent and user-friendly interface.
- Introduced types for toolbar buttons in the rich text editor for better type safety and maintainability.
2025-09-19 15:26:21 +00:00
3ba673282a fix: update dees-wcctools dependency to version 1.2.0; adjust workspace dependencies and refactor demo function 2025-09-19 14:16:48 +00:00
20a52d1b3e 1.12.2
Some checks failed
Default (tags) / security (push) Failing after 21s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-09-18 16:04:02 +00:00
dafcf3834c fix(dees-input-wysiwyg): Integrate output format preview into WYSIWYG demo; update plan and add local dev settings 2025-09-18 16:04:02 +00:00
41 changed files with 3562 additions and 3301 deletions

View File

@@ -1,5 +1,22 @@
# Changelog
## 2025-09-19 - 1.12.3 - fix(dees-input-fileupload)
Show selected files inside dropzone and improve file upload UX
- Render the selected file list inside the dropzone container so files are displayed inline with the drop area
- Add dropzone--has-files class and styles to visually indicate when files are present
- Avoid opening the file selector when clicking on the browse button or inside the file list (prevents accidental re-opening)
- Refine file list and file-row styles (sizes, paddings, border radius, hover/background behavior and thumbnail/icon sizes) for a more compact and consistent appearance
- Simplify empty-state handling by returning an empty template when no files are present (file list is only rendered when files exist)
## 2025-09-18 - 1.12.2 - fix(dees-input-wysiwyg)
Integrate output format preview into WYSIWYG demo; update plan and add local dev settings
- Wire output format preview into the WYSIWYG demo (ts_web/elements/dees-input-wysiwyg.demo.ts) by calling setupOutputFormatDemo(editors.meeting, editors.recipe) so HTML/Markdown preview controls are initialized.
- Update readme.plan.md: mark the Output Formats review tasks as completed and document that preview controls were added.
- Add a local settings file to allow running local tooling tasks (grants permission for pnpm run scripts and related local commands).
- No library API or runtime component behavior changed — this is a demo/documentation and local-settings update.
## 2025-09-18 - 1.12.1 - fix(ci)
Add local settings to allow running pnpm scripts and enable dev chat permission

View File

@@ -1,6 +1,6 @@
{
"name": "@design.estate/dees-catalog",
"version": "1.12.1",
"version": "1.12.3",
"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",
@@ -18,7 +18,7 @@
"dependencies": {
"@design.estate/dees-domtools": "^2.3.3",
"@design.estate/dees-element": "^2.1.2",
"@design.estate/dees-wcctools": "^1.1.1",
"@design.estate/dees-wcctools": "^1.2.0",
"@fortawesome/fontawesome-svg-core": "^7.0.1",
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-regular-svg-icons": "^7.0.1",

12
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^2.1.2
version: 2.1.2
'@design.estate/dees-wcctools':
specifier: ^1.1.1
version: 1.1.1
specifier: ^1.2.0
version: 1.2.0
'@fortawesome/fontawesome-svg-core':
specifier: ^7.0.1
version: 7.0.1
@@ -450,8 +450,8 @@ packages:
'@design.estate/dees-element@2.1.2':
resolution: {integrity: sha512-ZiwvE411RJPHaYio26asQLnSmtJ6G1HRLYWbxW/HvCMbFtrcrXysP1y4PQ9KjdNfiQ4yoWPjTtwYMJjLE0NcbA==}
'@design.estate/dees-wcctools@1.1.1':
resolution: {integrity: sha512-oT0gPQ9suaCi0D2jNHPjE0ugn0xUm43yPfQt7vQgrOZZ6EOQ3zWkYVOp8NbGOVwKTvMvZKyjdDmqJG4NFHPvcg==}
'@design.estate/dees-wcctools@1.2.0':
resolution: {integrity: sha512-E01IPNzGJ1TtCxsBiJAaDkIkveu1VwDv24CLfBt+UzjJnZGOJqDKYJlgE3XV1aBs4G/cI5bQ8j8rGqdGwp2FCg==}
'@emnapi/core@1.4.3':
resolution: {integrity: sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==}
@@ -6486,7 +6486,7 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@1.1.1':
'@design.estate/dees-wcctools@1.2.0':
dependencies:
'@design.estate/dees-domtools': 2.3.3
'@design.estate/dees-element': 2.1.2
@@ -6810,8 +6810,10 @@ snapshots:
transitivePeerDependencies:
- '@nuxt/kit'
- '@swc/helpers'
- bufferutil
- react
- supports-color
- utf-8-validate
- vue
'@hapi/bourne@3.0.0': {}

View File

@@ -1,2 +1,4 @@
onlyBuiltDependencies:
- esbuild
- mongodb-memory-server
- puppeteer

Binary file not shown.

View File

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

View File

@@ -5,19 +5,19 @@ import {
property,
state,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import { demoFunc } from './dees-appui-appbar.demo.js';
import * as interfaces from '../interfaces/index.js';
import * as plugins from '../00plugins.js';
import { demoFunc } from './demo.js';
import { appuiAppbarStyles } from './styles.js';
import { renderAppuiAppbar } from './template.js';
// Import required components
import './dees-icon.js';
import './dees-windowcontrols.js';
import './dees-appui-profiledropdown.js';
import '../dees-icon.js';
import '../dees-windowcontrols.js';
import '../dees-appui-profiledropdown.js';
declare global {
interface HTMLElementTagNameMap {
@@ -73,259 +73,16 @@ export class DeesAppuiBar extends DeesElement {
@state()
private isProfileDropdownOpen: boolean = false;
public static styles = [
cssManager.defaultStyles,
css`
:host {
/* CSS Variables for theming */
--appbar-height: 40px;
--appbar-font-size: 12px;
display: block;
position: relative;
width: 100%;
height: var(--appbar-height);
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
background: ${cssManager.bdTheme('#ffffff', '#000000')};
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
font-size: var(--appbar-font-size);
display: grid;
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
-webkit-app-region: drag;
user-select: none;
}
.menus {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
cursor: default;
}
.menuItem {
position: relative;
line-height: 24px;
padding: 0px 12px;
margin: 8px 0px;
border-radius: 4px;
-webkit-app-region: no-drag;
transition: all 0.2s ease;
cursor: default;
outline: none;
display: flex;
align-items: center;
gap: 4px;
}
/* Optional: Style for menu items with icons (not typically used for top-level items) */
.menuItem dees-icon {
font-size: 14px;
opacity: 0.8;
}
.menuItem:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem.active {
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.menuItem:focus-visible {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
}
/* Dropdown styles */
.dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: ${cssManager.bdTheme('#ffffff', '#000000')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
border-radius: 4px;
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
margin-top: 4px;
z-index: 1000;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.dropdown-item {
padding: 8px 16px;
cursor: default;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.1s;
}
.dropdown-item:hover,
.dropdown-item.focused {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.dropdown-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
.dropdown-item[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.dropdown-item .shortcut {
margin-left: auto;
opacity: 0.6;
font-size: 11px;
}
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-item {
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
cursor: default;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.breadcrumb-separator {
margin: 0 8px;
opacity: 0.5;
}
/* Account section */
.account {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 16px;
gap: 12px;
}
.search-icon {
cursor: default;
opacity: 0.7;
transition: opacity 0.2s;
}
.search-icon:hover {
opacity: 1;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.user-info:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.user-avatar {
position: relative;
width: 24px;
height: 24px;
border-radius: 50%;
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.user-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
}
.user-status.online {
background: #4caf50;
}
.user-status.offline {
background: #757575;
}
.user-status.busy {
background: #f44336;
}
.user-status.away {
background: #ff9800;
}
`,
];
public static styles = appuiAppbarStyles;
// INSTANCE
public render(): TemplateResult {
return html`
<div class="menus">
${this.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
${this.renderMenuItems()}
</div>
<div class="breadcrumbs">
${this.renderBreadcrumbs()}
</div>
<div class="account">
${this.renderAccountSection()}
</div>
`;
return renderAppuiAppbar(this);
}
private renderMenuItems(): TemplateResult {
public renderMenuItems(): TemplateResult {
return html`
${this.menuItems.map((item, index) => this.renderMenuItem(item, `menu-${index}`))}
`;
@@ -398,7 +155,7 @@ export class DeesAppuiBar extends DeesElement {
`;
}
private renderBreadcrumbs(): TemplateResult {
public renderBreadcrumbs(): TemplateResult {
if (!this.breadcrumbs) {
return html``;
}
@@ -417,7 +174,7 @@ export class DeesAppuiBar extends DeesElement {
`;
}
private renderAccountSection(): TemplateResult {
public renderAccountSection(): TemplateResult {
return html`
${this.showSearch ? html`
<dees-icon

View File

@@ -1,7 +1,8 @@
import { html, css } from '@design.estate/dees-element';
import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { IAppBarMenuItem } from './interfaces/appbarmenuitem.js';
import type { DeesAppuiBar } from './component.js';
import type { IAppBarMenuItem } from '../interfaces/appbarmenuitem.js';
import '@design.estate/dees-wcctools/demotools';
import './component.js';
export const demoFunc = () => {
// Sample menu items with various configurations

View File

@@ -0,0 +1,3 @@
export * from './component.js';
export { appuiAppbarStyles } from './styles.js';
export { renderAppuiAppbar } from './template.js';

View File

@@ -0,0 +1,238 @@
import { css, cssManager } from '@design.estate/dees-element';
export const appuiAppbarStyles = [
cssManager.defaultStyles,
css`
:host {
/* CSS Variables for theming */
--appbar-height: 40px;
--appbar-font-size: 12px;
display: block;
position: relative;
width: 100%;
height: var(--appbar-height);
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
background: ${cssManager.bdTheme('#ffffff', '#000000')};
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
font-size: var(--appbar-font-size);
display: grid;
grid-template-columns: ${cssManager.cssGridColumns(3, 20)};
-webkit-app-region: drag;
user-select: none;
}
.menus {
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
cursor: default;
}
.menuItem {
position: relative;
line-height: 24px;
padding: 0px 12px;
margin: 8px 0px;
border-radius: 4px;
-webkit-app-region: no-drag;
transition: all 0.2s ease;
cursor: default;
outline: none;
display: flex;
align-items: center;
gap: 4px;
}
/* Optional: Style for menu items with icons (not typically used for top-level items) */
.menuItem dees-icon {
font-size: 14px;
opacity: 0.8;
}
.menuItem:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem.active {
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.menuItem[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.menuItem:focus-visible {
box-shadow: 0 0 0 2px ${cssManager.bdTheme('#00000080', '#ffffff80')};
}
/* Dropdown styles */
.dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: ${cssManager.bdTheme('#ffffff', '#000000')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#202020')};
border-radius: 4px;
box-shadow: ${cssManager.bdTheme('0 4px 12px rgba(0, 0, 0, 0.15)', '0 4px 12px rgba(0, 0, 0, 0.3)')};
margin-top: 4px;
z-index: 1000;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.dropdown.open {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.dropdown-item {
padding: 8px 16px;
cursor: default;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.1s;
}
.dropdown-item:hover,
.dropdown-item.focused {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.dropdown-divider {
height: 1px;
background: ${cssManager.bdTheme('#e0e0e0', '#202020')};
margin: 4px 0;
}
.dropdown-item[disabled] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.dropdown-item .shortcut {
margin-left: auto;
opacity: 0.6;
font-size: 11px;
}
/* Breadcrumbs */
.breadcrumbs {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-item {
color: ${cssManager.bdTheme('#00000080', '#ffffff80')};
cursor: default;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: ${cssManager.bdTheme('#000000', '#ffffff')};
}
.breadcrumb-separator {
margin: 0 8px;
opacity: 0.5;
}
/* Account section */
.account {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 16px;
gap: 12px;
}
.search-icon {
cursor: default;
opacity: 0.7;
transition: opacity 0.2s;
}
.search-icon:hover {
opacity: 1;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.user-info:hover {
background: ${cssManager.bdTheme('#00000010', '#ffffff20')};
}
.user-avatar {
position: relative;
width: 24px;
height: 24px;
border-radius: 50%;
background: ${cssManager.bdTheme('#00000020', '#ffffff30')};
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
}
.user-avatar img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.user-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#000000')};
}
.user-status.online {
background: #4caf50;
}
.user-status.offline {
background: #757575;
}
.user-status.busy {
background: #f44336;
}
.user-status.away {
background: #ff9800;
}
`,
];

View File

@@ -0,0 +1,18 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import type { DeesAppuiBar } from './component.js';
export const renderAppuiAppbar = (component: DeesAppuiBar): TemplateResult => {
return html`
<div class="menus">
${component.showWindowControls ? html`<dees-windowcontrols></dees-windowcontrols>` : ''}
${component.renderMenuItems()}
</div>
<div class="breadcrumbs">
${component.renderBreadcrumbs()}
</div>
<div class="account">
${component.renderAccountSection()}
</div>
`;
};

View File

@@ -10,7 +10,7 @@ import {
} from '@design.estate/dees-element';
import * as interfaces from './interfaces/index.js';
import * as plugins from './00plugins.js';
import type { DeesAppuiBar } from './dees-appui-appbar.js';
import type { DeesAppuiBar } from './dees-appui-appbar/index.js';
import type { DeesAppuiMainmenu } from './dees-appui-mainmenu.js';
import type { DeesAppuiMainselector } from './dees-appui-mainselector.js';
import type { DeesAppuiMaincontent } from './dees-appui-maincontent.js';
@@ -18,7 +18,7 @@ import type { DeesAppuiActivitylog } from './dees-appui-activitylog.js';
import { demoFunc } from './dees-appui-base.demo.js';
// Import child components
import './dees-appui-appbar.js';
import './dees-appui-appbar/index.js';
import './dees-appui-mainmenu.js';
import './dees-appui-mainselector.js';
import './dees-appui-maincontent.js';

View File

@@ -1,16 +1,15 @@
import {
DeesElement,
css,
cssManager,
customElement,
html,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { demoFunc } from './dees-chart-area.demo.js';
import { demoFunc } from './demo.js';
import { chartAreaStyles } from './styles.js';
import { renderChartArea } from './template.js';
import ApexCharts from 'apexcharts';
@@ -141,73 +140,14 @@ export class DeesChartArea extends DeesElement {
}
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-weight: 400;
font-size: 14px;
}
.mainbox {
position: relative;
width: 100%;
height: 400px;
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: 8px;
overflow: hidden;
}
.chartTitle {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: left;
padding: 16px 24px;
z-index: 10;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 63.9%)')};
}
.chartContainer {
position: absolute;
top: 0px;
left: 0px;
bottom: 0px;
right: 0px;
padding: 44px 16px 16px 0px;
overflow: hidden;
background: transparent; /* Ensure container doesn't override chart background */
}
/* ApexCharts theme overrides */
.apexcharts-canvas {
background: transparent !important;
}
.apexcharts-inner {
background: transparent !important;
}
.apexcharts-graphical {
background: transparent !important;
}
`,
];
public static styles = chartAreaStyles;
public render(): TemplateResult {
return html`
<div class="mainbox">
<div class="chartTitle">${this.label}</div>
<div class="chartContainer"></div>
</div>
`;
return renderChartArea(this);
}
public async firstUpdated() {
await this.domtoolsPromise;

View File

@@ -1,6 +1,7 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import type { DeesChartArea } from './dees-chart-area.js';
import type { DeesChartArea } from './component.js';
import '@design.estate/dees-wcctools/demotools';
import './component.js';
export const demoFunc = () => {
// Initial dataset values

View File

@@ -0,0 +1,3 @@
export * from './component.js';
export { chartAreaStyles } from './styles.js';
export { renderChartArea } from './template.js';

View File

@@ -0,0 +1,60 @@
import { css, cssManager } from '@design.estate/dees-element';
export const chartAreaStyles = [
cssManager.defaultStyles,
css`
:host {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
font-weight: 400;
font-size: 14px;
}
.mainbox {
position: relative;
width: 100%;
height: 400px;
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: 8px;
overflow: hidden;
}
.chartTitle {
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: left;
padding: 16px 24px;
z-index: 10;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 63.9%)')};
}
.chartContainer {
position: absolute;
top: 0px;
left: 0px;
bottom: 0px;
right: 0px;
padding: 44px 16px 16px 0px;
overflow: hidden;
background: transparent; /* Ensure container doesn't override chart background */
}
/* ApexCharts theme overrides */
.apexcharts-canvas {
background: transparent !important;
}
.apexcharts-inner {
background: transparent !important;
}
.apexcharts-graphical {
background: transparent !important;
}
`,
];

View File

@@ -0,0 +1,12 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import type { DeesChartArea } from './component.js';
export const renderChartArea = (component: DeesChartArea): TemplateResult => {
return html`
<div class="mainbox">
<div class="chartTitle">${component.label}</div>
<div class="chartContainer"></div>
</div>
`;
};

View File

@@ -9,12 +9,12 @@ import {
import * as domtools from '@design.estate/dees-domtools';
import { DeesInputCheckbox } from './dees-input-checkbox.js';
import { DeesInputDatepicker } from './dees-input-datepicker.js';
import { DeesInputDatepicker } from './dees-input-datepicker/index.js';
import { DeesInputText } from './dees-input-text.js';
import { DeesInputQuantitySelector } from './dees-input-quantityselector.js';
import { DeesInputRadiogroup } from './dees-input-radiogroup.js';
import { DeesInputDropdown } from './dees-input-dropdown.js';
import { DeesInputFileupload } from './dees-input-fileupload.js';
import { DeesInputFileupload } from './dees-input-fileupload/index.js';
import { DeesInputIban } from './dees-input-iban.js';
import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
import { DeesInputPhone } from './dees-input-phone.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
import {
customElement,
type TemplateResult,
property,
state,
} from '@design.estate/dees-element';
import { DeesInputBase } from '../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 '../dees-icon.js';
import '../dees-label.js';
declare global {
interface HTMLElementTagNameMap {
'dees-input-datepicker': DeesInputDatepicker;
}
}
@customElement('dees-input-datepicker')
export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
public static demo = demoFunc;
@property({ type: String })
public value: string = '';
@property({ type: Boolean })
public enableTime: boolean = false;
@property({ type: String })
public timeFormat: '24h' | '12h' = '24h';
@property({ type: Number })
public minuteIncrement: number = 1;
@property({ type: String })
public dateFormat: string = 'YYYY-MM-DD';
@property({ type: String })
public minDate: string = '';
@property({ type: String })
public maxDate: string = '';
@property({ type: Array })
public disabledDates: string[] = [];
@property({ type: Number })
public weekStartsOn: 0 | 1 = 1; // Default to Monday
@property({ type: String })
public placeholder: string = 'YYYY-MM-DD';
@property({ type: Boolean })
public enableTimezone: boolean = false;
@property({ type: String })
public timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
@property({ type: Array })
public events: IDateEvent[] = [];
@state()
public isOpened: boolean = false;
@state()
public opensToTop: boolean = false;
@state()
public selectedDate: Date | null = null;
@state()
public viewDate: Date = new Date();
@state()
public selectedHour: number = 0;
@state()
public selectedMinute: number = 0;
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);
if (!isNaN(date.getTime())) {
this.selectedDate = date;
this.viewDate = new Date(date);
this.selectedHour = date.getHours();
this.selectedMinute = date.getMinutes();
}
} catch {
// Invalid date
}
} else {
const now = new Date();
this.viewDate = new Date(now);
this.selectedHour = now.getHours();
this.selectedMinute = 0;
}
}
public formatDate(isoString: string): string {
if (!isoString) return '';
try {
const date = new Date(isoString);
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;
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours24 >= 12 ? 'PM' : 'AM';
if (this.timeFormat === '12h') {
formatted += ` ${hours12}:${minutes} ${ampm}`;
} else {
formatted += ` ${hours24.toString().padStart(2, '0')}:${minutes}`;
}
}
// Timezone formatting if enabled
if (this.enableTimezone) {
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}`;
}
}
return formatted;
} catch {
return '';
}
}
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;
// Add click outside listener
setTimeout(() => {
document.addEventListener('click', this.handleClickOutside);
}, 0);
} else {
document.removeEventListener('click', this.handleClickOutside);
}
}
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);
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 {
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);
}
public nextMonth(): void {
this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
}
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));
}
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')}`;
}
public handleKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggleCalendar();
} else if (e.key === 'Escape' && this.isOpened) {
e.preventDefault();
this.isOpened = false;
}
}
public clearValue(e: Event): void {
e.stopPropagation();
this.value = '';
this.selectedDate = null;
this.changeSubject.next(this);
}
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;
}
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);
this.selectedHour = parsedDate.getHours();
this.selectedMinute = parsedDate.getMinutes();
this.changeSubject.next(this);
}
}
public handleInputBlur(e: FocusEvent): void {
const input = e.target as HTMLInputElement;
const inputValue = input.value.trim();
if (!inputValue) {
this.value = '';
this.selectedDate = null;
this.changeSubject.next(this);
return;
}
const parsedDate = this.parseManualDate(inputValue);
if (parsedDate && !isNaN(parsedDate.getTime())) {
this.value = parsedDate.toISOString();
this.selectedDate = parsedDate;
this.viewDate = new Date(parsedDate);
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);
}
}
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) {
const [_, day, month, year] = euMatch;
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
// Format 3: MM/DD/YYYY (US)
if (!parsedDate) {
const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (usMatch) {
const [_, month, day, year] = usMatch;
parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
// If no date was parsed, 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) {
const [_, hours, minutes] = timeMatch;
parsedDate.setHours(parseInt(hours));
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());
parsedDate.setSeconds(0);
parsedDate.setMilliseconds(0);
}
return parsedDate;
}
public getValue(): string {
return this.value;
}
public setValue(value: string): void {
this.value = value;
if (value) {
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
this.selectedDate = date;
this.viewDate = new Date(date);
this.selectedHour = date.getHours();
this.selectedMinute = date.getMinutes();
}
} catch {
// Invalid date
}
}
}
}

View File

@@ -1,8 +1,8 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './dees-input-datepicker.js';
import type { DeesInputDatepicker } from './dees-input-datepicker.js';
import '../dees-panel.js';
import './component.js';
import type { DeesInputDatepicker } from './component.js';
export const demoFunc = () => html`
<style>

View File

@@ -0,0 +1,4 @@
export * from './component.js';
export { datepickerStyles } from './styles.js';
export { renderDatepicker } from './template.js';
export type { IDateEvent } from './types.js';

View File

@@ -0,0 +1,514 @@
import { css, cssManager } from '@design.estate/dees-element';
import { DeesInputBase } from '../dees-input-base.js';
export const datepickerStyles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
}
.input-container {
position: relative;
width: 100%;
}
.date-input {
width: 100%;
height: 40px;
padding: 0 12px;
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%)')};
border-radius: 6px;
font-size: 14px;
line-height: 1.5;
color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')};
cursor: pointer;
transition: all 0.2s ease;
outline: none;
font-family: inherit;
}
.date-input::placeholder {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
}
.date-input:hover:not(:disabled) {
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%)')};
}
.date-input:focus,
.date-input.open {
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')};
outline: 2px solid transparent;
outline-offset: 2px;
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')},
0 0 0 4px ${cssManager.bdTheme('hsl(222.2 47.4% 11.2% / 0.1)', 'hsl(210 20% 98% / 0.1)')};
}
.date-input:disabled {
background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')};
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
cursor: not-allowed;
opacity: 0.5;
}
/* Icon container using flexbox for better positioning */
.icon-container {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 4px;
padding: 0 12px;
pointer-events: none;
}
.icon-container > * {
pointer-events: auto;
}
.calendar-icon {
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
.clear-button {
width: 20px;
height: 20px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')};
transition: opacity 0.2s ease, background-color 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.clear-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%)')};
}
.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

@@ -0,0 +1,179 @@
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>
<div class="input-container">
<input
type="text"
class="date-input ${component.isOpened ? 'open' : ''}"
.value=${component.formatDate(component.value)}
.placeholder=${component.placeholder}
?disabled=${component.disabled}
@click=${component.toggleCalendar}
@keydown=${component.handleKeydown}
@input=${component.handleManualInput}
@blur=${component.handleInputBlur}
style="padding-right: ${component.value ? '64px' : '40px'}"
/>
<div class="icon-container">
${component.value && !component.disabled ? html`
<button class="clear-button" @click=${component.clearValue} title="Clear">
<dees-icon icon="lucide:x" iconSize="14"></dees-icon>
</button>
` : ''}
<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,7 @@
export interface IDateEvent {
date: string; // ISO date string (YYYY-MM-DD)
title?: string;
description?: string;
type?: 'info' | 'warning' | 'success' | 'error';
count?: number; // Number of events on this day
}

View File

@@ -1,204 +0,0 @@
import { html, css, cssManager } from '@design.estate/dees-element';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.upload-box {
padding: 16px;
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
border-radius: 4px;
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
}
.upload-box h4 {
margin-top: 0;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#333', '#fff')};
font-size: 16px;
}
.info-section {
margin-top: 32px;
padding: 16px;
background: ${cssManager.bdTheme('#fff3cd', '#332701')};
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#664400')};
border-radius: 4px;
color: ${cssManager.bdTheme('#856404', '#ffecb5')};
}
`}
</style>
<div class="demo-container">
<dees-panel .title=${'1. Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}>
<dees-input-fileupload
.label=${'Attachments'}
.description=${'Upload any files by clicking or dragging them here'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Single File Only'}
.description=${'Only one file can be uploaded at a time'}
.multiple=${false}
.buttonText=${'Choose File'}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'2. File Type Restrictions'} .subtitle=${'Upload areas with specific file type requirements'}>
<div class="upload-grid">
<div class="upload-box">
<h4>Images Only</h4>
<dees-input-fileupload
.label=${'Profile Picture'}
.description=${'JPG, PNG or GIF (max 5MB)'}
.accept=${'image/jpeg,image/png,image/gif'}
.maxSize=${5 * 1024 * 1024}
.multiple=${false}
.buttonText=${'Select Image'}
></dees-input-fileupload>
</div>
<div class="upload-box">
<h4>Documents Only</h4>
<dees-input-fileupload
.label=${'Resume'}
.description=${'PDF or Word documents only'}
.accept=${".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
.buttonText=${'Select Document'}
></dees-input-fileupload>
</div>
</div>
</dees-panel>
<dees-panel .title=${'3. Validation & Limits'} .subtitle=${'File size limits and validation examples'}>
<dees-input-fileupload
.label=${'Small Files Only'}
.description=${'Maximum file size: 1MB'}
.maxSize=${1024 * 1024}
.buttonText=${'Upload Small File'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Limited Upload'}
.description=${'Maximum 3 files, each up to 2MB'}
.maxFiles=${3}
.maxSize=${2 * 1024 * 1024}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Required Upload'}
.description=${'This field is required'}
.required=${true}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'4. States & Styling'} .subtitle=${'Different states and validation feedback'}>
<dees-input-fileupload
.label=${'Disabled Upload'}
.description=${'File upload is currently disabled'}
.disabled=${true}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Pre-filled Example'}
.description=${'Component with pre-loaded files'}
.value=${[
new File(['Hello World'], 'example.txt', { type: 'text/plain' }),
new File(['Test Data'], 'data.json', { type: 'application/json' })
]}
></dees-input-fileupload>
</dees-panel>
<dees-panel .title=${'5. Form Integration'} .subtitle=${'Complete form with various file upload scenarios'}>
<dees-form>
<h3 style="margin-top: 0; margin-bottom: 24px; color: ${cssManager.bdTheme('#333', '#fff')};">Job Application Form</h3>
<dees-input-text
.label=${'Full Name'}
.required=${true}
.key=${'fullName'}
></dees-input-text>
<dees-input-text
.label=${'Email'}
.inputType=${'email'}
.required=${true}
.key=${'email'}
></dees-input-text>
<dees-input-fileupload
.label=${'Resume'}
.description=${'Required: PDF format only (max 10MB)'}
.required=${true}
.accept=${'application/pdf'}
.maxSize=${10 * 1024 * 1024}
.multiple=${false}
.key=${'resume'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Portfolio'}
.description=${'Optional: Upload up to 5 work samples (images or PDFs, max 5MB each)'}
.accept=${'image/*,application/pdf'}
.maxFiles=${5}
.maxSize=${5 * 1024 * 1024}
.key=${'portfolio'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'References'}
.description=${'Upload reference letters (optional)'}
.accept=${".pdf,.doc,.docx"}
.key=${'references'}
></dees-input-fileupload>
<dees-input-text
.label=${'Additional Comments'}
.inputType=${'textarea'}
.description=${'Any additional information you would like to share'}
.key=${'comments'}
></dees-input-text>
<dees-form-submit .text=${'Submit Application'}></dees-form-submit>
</dees-form>
<div class="info-section">
<h4 style="margin-top: 0;">Enhanced Features:</h4>
<ul style="margin: 0; padding-left: 20px;">
<li>Drag & drop with visual feedback</li>
<li>File type restrictions via accept attribute</li>
<li>File size validation with custom limits</li>
<li>Maximum file count restrictions</li>
<li>Image preview thumbnails</li>
<li>File type-specific icons</li>
<li>Clear all button for multiple files</li>
<li>Proper validation states and messages</li>
<li>Keyboard accessible</li>
<li>Single or multiple file modes</li>
</ul>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@@ -1,721 +0,0 @@
import * as colors from './00colors.js';
import * as plugins from './00plugins.js';
import { DeesContextmenu } from './dees-contextmenu.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-fileupload.demo.js';
import {
customElement,
DeesElement,
type TemplateResult,
property,
html,
css,
unsafeCSS,
cssManager,
type CSSResult,
domtools,
} from '@design.estate/dees-element';
declare global {
interface HTMLElementTagNameMap {
'dees-input-fileupload': DeesInputFileupload;
}
}
@customElement('dees-input-fileupload')
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
public static demo = demoFunc;
@property({
attribute: false,
})
public value: File[] = [];
@property()
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
@property({ type: Boolean })
private isLoading: boolean = false;
@property({
type: String,
})
public buttonText: string = 'Upload File...';
@property({ type: String })
public accept: string = '';
@property({ type: Boolean })
public multiple: boolean = true;
@property({ type: Number })
public maxSize: number = 0; // 0 means no limit
@property({ type: Number })
public maxFiles: number = 0; // 0 means no limit
@property({ type: String, reflect: true })
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
constructor() {
super();
}
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
position: relative;
display: block;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
}
.hidden {
display: none;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.maincontainer {
position: relative;
border-radius: 6px;
padding: 16px;
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
border: 1px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')};
transition: all 0.15s ease;
}
.maincontainer:hover {
border-color: ${cssManager.bdTheme('hsl(215 20.2% 55.1%)', 'hsl(215 20.2% 45.1%)')};
}
:host([disabled]) .maincontainer {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
:host([validationState="invalid"]) .maincontainer {
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
:host([validationState="valid"]) .maincontainer {
border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
}
:host([validationState="warn"]) .maincontainer {
border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
}
.maincontainer::after {
top: 1px;
right: 1px;
left: 1px;
bottom: 1px;
transform: scale3d(0.98, 0.95, 1);
position: absolute;
content: '';
display: block;
border: 2px dashed transparent;
border-radius: 5px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
background: transparent;
}
.maincontainer.dragOver {
border-color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.05)', 'hsl(213.1 93.9% 67.8% / 0.05)')};
}
.maincontainer.dragOver::after {
transform: scale3d(1, 1, 1);
border: 2px dashed ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
}
.uploadButton {
position: relative;
padding: 10px 20px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 7.8%)')};
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
text-align: center;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
line-height: 20px;
}
.uploadButton:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.uploadButton:active {
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')};
}
.uploadButton dees-icon {
font-size: 16px;
}
.files-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.uploadCandidate {
display: grid;
grid-template-columns: 40px 1fr auto;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 16.8%)')};
padding: 12px;
text-align: left;
border-radius: 6px;
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
cursor: default;
transition: all 0.15s ease;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
position: relative;
overflow: hidden;
}
.uploadCandidate:hover {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(215 20.2% 20.8%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.uploadCandidate .icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
.uploadCandidate.image-file .icon {
color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
}
.uploadCandidate.pdf-file .icon {
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
}
.uploadCandidate.doc-file .icon {
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
}
.uploadCandidate .info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.uploadCandidate .filename {
font-weight: 500;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uploadCandidate .filesize {
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
.uploadCandidate .actions {
display: flex;
align-items: center;
gap: 8px;
}
.remove-button {
width: 32px;
height: 32px;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
}
.remove-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-all-button {
margin-bottom: 8px;
text-align: right;
}
.clear-all-button button {
background: none;
border: none;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.15s ease;
}
.clear-all-button 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%)')};
}
.validation-message {
font-size: 13px;
margin-top: 6px;
color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
line-height: 1.5;
}
.drop-hint {
text-align: center;
padding: 40px 20px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
font-size: 14px;
}
.drop-hint dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.2;
}
.image-preview {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.description-text {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
margin-top: 6px;
line-height: 1.5;
}
/* Loading state styles */
.uploadButton.loading {
pointer-events: none;
opacity: 0.8;
}
.uploadButton .button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.02);
opacity: 0.9;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.uploadButton.loading {
animation: pulse 1s ease-in-out infinite;
}
`,
];
public render(): TemplateResult {
const hasFiles = this.value.length > 0;
const showClearAll = hasFiles && this.value.length > 1;
return html`
<div class="input-wrapper">
${this.label ? html`
<dees-label .label=${this.label}></dees-label>
` : ''}
<div class="hidden">
<input
type="file"
?multiple=${this.multiple}
accept="${this.accept}"
>
</div>
<div class="maincontainer ${this.state === 'dragOver' ? 'dragOver' : ''}">
${hasFiles ? html`
${showClearAll ? html`
<div class="clear-all-button">
<button @click=${this.clearAll}>Clear All</button>
</div>
` : ''}
<div class="files-container">
${this.value.map((fileArg) => {
const fileType = this.getFileType(fileArg);
const isImage = fileType === 'image';
return html`
<div class="uploadCandidate ${fileType}-file">
<div class="icon">
${isImage && this.canShowPreview(fileArg) ? html`
<img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}">
` : html`
<dees-icon .icon=${this.getFileIcon(fileArg)}></dees-icon>
`}
</div>
<div class="info">
<div class="filename" title="${fileArg.name}">${fileArg.name}</div>
<div class="filesize">${this.formatFileSize(fileArg.size)}</div>
</div>
<div class="actions">
<button
class="remove-button"
@click=${() => this.removeFile(fileArg)}
title="Remove file"
>
<dees-icon .icon=${'lucide:x'}></dees-icon>
</button>
</div>
</div>
`;
})}
</div>
` : html`
<div class="drop-hint">
<dees-icon .icon=${'lucide:cloud-upload'}></dees-icon>
<div>Drag files here or click to browse</div>
</div>
`}
<div class="uploadButton ${this.isLoading ? 'loading' : ''}" @click=${this.openFileSelector}>
<div class="button-content">
${this.isLoading ? html`
<div class="loading-spinner"></div>
<span>Opening...</span>
` : html`
<dees-icon .icon=${'lucide:upload'}></dees-icon>
${this.buttonText}
`}
</div>
</div>
</div>
${this.description ? html`
<div class="description-text">${this.description}</div>
` : ''}
${this.validationState === 'invalid' && this.validationMessage ? html`
<div class="validation-message">${this.validationMessage}</div>
` : ''}
</div>
`;
}
private validationMessage: string = '';
// Utility methods
private formatFileSize(bytes: number): string {
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
private getFileType(file: File): string {
const type = file.type.toLowerCase();
if (type.startsWith('image/')) return 'image';
if (type === 'application/pdf') return 'pdf';
if (type.includes('word') || type.includes('document')) return 'doc';
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.startsWith('video/')) return 'video';
if (type.startsWith('audio/')) return 'audio';
if (type.includes('zip') || type.includes('compressed')) return 'archive';
return 'file';
}
private getFileIcon(file: File): string {
const type = this.getFileType(file);
const iconMap = {
'image': 'lucide:image',
'pdf': 'lucide:file-text',
'doc': 'lucide:file-text',
'spreadsheet': 'lucide:table',
'presentation': 'lucide:presentation',
'video': 'lucide:video',
'audio': 'lucide:music',
'archive': 'lucide:archive',
'file': 'lucide:file'
};
return iconMap[type] || 'lucide:file';
}
private canShowPreview(file: File): boolean {
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews
}
private validateFile(file: File): boolean {
// Check file size
if (this.maxSize > 0 && file.size > this.maxSize) {
this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`;
this.validationState = 'invalid';
return false;
}
// Check file type
if (this.accept) {
const acceptedTypes = this.accept.split(',').map(s => s.trim());
let isAccepted = false;
for (const acceptType of acceptedTypes) {
if (acceptType.startsWith('.')) {
// Extension check
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
isAccepted = true;
break;
}
} else if (acceptType.endsWith('/*')) {
// MIME type wildcard check
const mimePrefix = acceptType.slice(0, -2);
if (file.type.startsWith(mimePrefix)) {
isAccepted = true;
break;
}
} else if (file.type === acceptType) {
// Exact MIME type check
isAccepted = true;
break;
}
}
if (!isAccepted) {
this.validationMessage = `File type not accepted. Please upload: ${this.accept}`;
this.validationState = 'invalid';
return false;
}
}
return true;
}
public async openFileSelector() {
if (this.disabled || this.isLoading) return;
// Set loading state
this.isLoading = true;
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
// Set up a focus handler to detect when the dialog is closed without selection
const handleFocus = () => {
setTimeout(() => {
// Check if no file was selected
if (!inputFile.files || inputFile.files.length === 0) {
this.isLoading = false;
}
window.removeEventListener('focus', handleFocus);
}, 300);
};
window.addEventListener('focus', handleFocus);
inputFile.click();
}
private removeFile(file: File) {
const index = this.value.indexOf(file);
if (index > -1) {
this.value.splice(index, 1);
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
}
}
private clearAll() {
this.value = [];
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
}
public async updateValue(eventArg: Event) {
const target: any = eventArg.target;
this.value = target.value;
this.changeSubject.next(this);
}
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
inputFile.addEventListener('change', async (event: Event) => {
const target = event.target as HTMLInputElement;
const newFiles = Array.from(target.files);
// Always reset loading state when file dialog interaction completes
this.isLoading = false;
await this.addFiles(newFiles);
// Reset the input value to allow selecting the same file again if needed
target.value = '';
});
// Handle drag and drop
const dropArea = this.shadowRoot.querySelector('.maincontainer');
const handlerFunction = async (eventArg: DragEvent) => {
eventArg.preventDefault();
eventArg.stopPropagation();
switch (eventArg.type) {
case 'dragenter':
case 'dragover':
this.state = 'dragOver';
break;
case 'dragleave':
// Check if we're actually leaving the drop area
const rect = dropArea.getBoundingClientRect();
const x = eventArg.clientX;
const y = eventArg.clientY;
if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
this.state = 'idle';
}
break;
case 'drop':
this.state = 'idle';
const files = Array.from(eventArg.dataTransfer.files);
await this.addFiles(files);
break;
}
};
dropArea.addEventListener('dragenter', handlerFunction, false);
dropArea.addEventListener('dragleave', handlerFunction, false);
dropArea.addEventListener('dragover', handlerFunction, false);
dropArea.addEventListener('drop', handlerFunction, false);
}
private async addFiles(files: File[]) {
const filesToAdd: File[] = [];
for (const file of files) {
if (this.validateFile(file)) {
filesToAdd.push(file);
}
}
if (filesToAdd.length === 0) return;
// Check max files limit
if (this.maxFiles > 0) {
const totalFiles = this.value.length + filesToAdd.length;
if (totalFiles > this.maxFiles) {
const allowedCount = this.maxFiles - this.value.length;
if (allowedCount <= 0) {
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
this.validationState = 'invalid';
return;
}
filesToAdd.splice(allowedCount);
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
this.validationState = 'warn';
}
}
// Add files
if (!this.multiple && filesToAdd.length > 0) {
this.value = [filesToAdd[0]];
} else {
this.value.push(...filesToAdd);
}
this.requestUpdate();
this.validate();
this.changeSubject.next(this);
// Update button text
if (this.value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
}
}
public async validate(): Promise<boolean> {
this.validationMessage = '';
if (this.required && this.value.length === 0) {
this.validationState = 'invalid';
this.validationMessage = 'Please select at least one file';
return false;
}
// Validate all files
for (const file of this.value) {
if (!this.validateFile(file)) {
return false;
}
}
this.validationState = 'valid';
return true;
}
public getValue(): File[] {
return this.value;
}
public setValue(value: File[]): void {
this.value = value;
this.requestUpdate();
if (value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
} else {
this.buttonText = 'Upload File...';
}
}
public updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('value')) {
this.validate();
}
}
}

View File

@@ -0,0 +1,619 @@
import { DeesInputBase } from '../dees-input-base.js';
import { demoFunc } from './demo.js';
import { fileuploadStyles } from './styles.js';
import '../dees-icon.js';
import '../dees-label.js';
import {
customElement,
html,
property,
state,
type TemplateResult,
} from '@design.estate/dees-element';
declare global {
interface HTMLElementTagNameMap {
'dees-input-fileupload': DeesInputFileupload;
}
}
@customElement('dees-input-fileupload')
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
public static demo = demoFunc;
@property({ attribute: false })
public value: File[] = [];
@state()
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
@state()
public isLoading: boolean = false;
@property({ type: String })
public buttonText: string = 'Select files';
@property({ type: String })
public accept: string = '';
@property({ type: Boolean })
public multiple: boolean = true;
@property({ type: Number })
public maxSize: number = 0; // 0 means no limit
@property({ type: Number })
public maxFiles: number = 0; // 0 means no limit
@property({ type: String, reflect: true })
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
public validationMessage: string = '';
private previewUrlMap: WeakMap<File, string> = new WeakMap();
private dropArea: HTMLElement | null = null;
public static styles = fileuploadStyles;
public render(): TemplateResult {
const acceptedSummary = this.getAcceptedSummary();
const metaEntries: string[] = [
this.multiple ? 'Multiple files supported' : 'Single file only',
this.maxSize > 0 ? `Max ${this.formatFileSize(this.maxSize)}` : 'No size limit',
];
if (acceptedSummary) {
metaEntries.push(`Accepts ${acceptedSummary}`);
}
return html`
<div class="input-wrapper">
<dees-label
.label=${this.label}
.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"
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"
style="position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; top: 0; left: 0; overflow: hidden;"
type="file"
?multiple=${this.multiple}
accept=${this.accept || ''}
?disabled=${this.disabled}
@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>
${this.renderFileList()}
</div>
${this.validationMessage
? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
: html``}
</div>
`;
}
private renderFileList(): TemplateResult {
if (this.value.length === 0) {
return html``;
}
return html`
<div class="file-list">
<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))}
</div>
</div>
`;
}
private renderFileRow(file: File): TemplateResult {
const fileType = this.getFileType(file);
const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null;
return html`
<div class="file-row ${fileType}-file">
<div class="file-thumb" aria-hidden="true">
${previewUrl
? html`<img class="thumb-image" src=${previewUrl} alt=${`Preview of ${file.name}`}>`
: html`<dees-icon icon=${this.getFileIcon(file)}></dees-icon>`}
</div>
<div class="file-meta">
<div class="file-name" title=${file.name}>${file.name}</div>
<div class="file-details">
<span class="file-size">${this.formatFileSize(file.size)}</span>
${fileType !== 'file' ? html`<span class="file-type">${fileType}</span>` : html``}
</div>
</div>
<div class="file-actions">
<button
type="button"
class="remove-button"
@click=${() => this.removeFile(file)}
aria-label=${`Remove ${file.name}`}
>
<dees-icon icon="lucide:X"></dees-icon>
</button>
</div>
</div>
`;
}
private handleFileInputChange = async (event: Event) => {
this.isLoading = false;
const target = event.target as HTMLInputElement;
const files = Array.from(target.files ?? []);
if (files.length > 0) {
await this.addFiles(files);
}
target.value = '';
};
private handleDropzoneClick = (event: MouseEvent) => {
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')) {
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;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.openFileSelector();
}
};
private handleClearAll = (event: MouseEvent) => {
event.preventDefault();
this.clearAll();
};
private handleDragEvent = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (this.disabled) {
return;
}
if (event.type === 'dragenter' || event.type === 'dragover') {
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.state = 'dragOver';
return;
}
if (event.type === 'dragleave') {
if (!this.dropArea) {
this.state = 'idle';
return;
}
const rect = this.dropArea.getBoundingClientRect();
const { clientX = 0, clientY = 0 } = event;
if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) {
this.state = 'idle';
}
return;
}
if (event.type === 'drop') {
this.state = 'idle';
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) {
await this.addFiles(files);
}
}
};
private attachDropListeners(): void {
if (!this.dropArea) {
return;
}
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
this.dropArea!.addEventListener(eventName, this.handleDragEvent);
});
}
private detachDropListeners(): void {
if (!this.dropArea) {
return;
}
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
this.dropArea!.removeEventListener(eventName, this.handleDragEvent);
});
}
private rebindInteractiveElements(): void {
const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null;
if (newDropArea !== this.dropArea) {
this.detachDropListeners();
this.dropArea = newDropArea;
this.attachDropListeners();
}
}
public formatFileSize(bytes: number): string {
const units = ['Bytes', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 Bytes';
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, exponent);
return `${Math.round(size * 100) / 100} ${units[exponent]}`;
}
public getFileType(file: File): string {
const type = file.type.toLowerCase();
if (type.startsWith('image/')) return 'image';
if (type === 'application/pdf') return 'pdf';
if (type.includes('word') || type.includes('document')) return 'doc';
if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet';
if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation';
if (type.startsWith('video/')) return 'video';
if (type.startsWith('audio/')) return 'audio';
if (type.includes('zip') || type.includes('compressed')) return 'archive';
return 'file';
}
public getFileIcon(file: File): string {
const fileType = this.getFileType(file);
const iconMap: Record<string, string> = {
image: 'lucide:FileImage',
pdf: 'lucide:FileText',
doc: 'lucide:FileText',
spreadsheet: 'lucide:FileSpreadsheet',
presentation: 'lucide:FileBarChart',
video: 'lucide:FileVideo',
audio: 'lucide:FileAudio',
archive: 'lucide:FileArchive',
file: 'lucide:File',
};
return iconMap[fileType] ?? 'lucide:File';
}
public canShowPreview(file: File): boolean {
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024;
}
private validateFile(file: File): boolean {
if (this.maxSize > 0 && file.size > this.maxSize) {
this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`;
this.validationState = 'invalid';
return false;
}
if (this.accept) {
const acceptedTypes = this.accept
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
if (acceptedTypes.length > 0) {
let isAccepted = false;
for (const acceptType of acceptedTypes) {
if (acceptType.startsWith('.')) {
if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) {
isAccepted = true;
break;
}
} else if (acceptType.endsWith('/*')) {
const prefix = acceptType.slice(0, -2);
if (file.type.startsWith(prefix)) {
isAccepted = true;
break;
}
} else if (file.type === acceptType) {
isAccepted = true;
break;
}
}
if (!isAccepted) {
this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`;
this.validationState = 'invalid';
return false;
}
}
}
return true;
}
private getPreviewUrl(file: File): string {
let url = this.previewUrlMap.get(file);
if (!url) {
url = URL.createObjectURL(file);
this.previewUrlMap.set(file, url);
}
return url;
}
private releasePreview(file: File): void {
const url = this.previewUrlMap.get(file);
if (url) {
URL.revokeObjectURL(url);
this.previewUrlMap.delete(file);
}
}
private getAcceptedSummary(): string | null {
if (!this.accept) {
return null;
}
const formatted = Array.from(
new Set(
this.accept
.split(',')
.map((token) => token.trim())
.filter((token) => token.length > 0)
.map((token) => this.formatAcceptToken(token))
)
).filter(Boolean);
if (formatted.length === 0) {
return null;
}
if (formatted.length === 1) {
return formatted[0];
}
if (formatted.length === 2) {
return `${formatted[0]}, ${formatted[1]}`;
}
return `${formatted.slice(0, 2).join(', ')}`;
}
private formatAcceptToken(token: string): string {
if (token === '*/*') {
return 'All files';
}
if (token.endsWith('/*')) {
const family = token.split('/')[0];
if (!family) {
return 'All files';
}
return `${family.charAt(0).toUpperCase()}${family.slice(1)} files`;
}
if (token.startsWith('.')) {
return token.slice(1).toUpperCase();
}
if (token.includes('pdf')) return 'PDF';
if (token.includes('zip')) return 'ZIP';
if (token.includes('json')) return 'JSON';
if (token.includes('msword')) return 'DOC';
if (token.includes('wordprocessingml')) return 'DOCX';
if (token.includes('excel')) return 'XLS';
if (token.includes('presentation')) return 'PPT';
const segments = token.split('/');
const lastSegment = segments.pop() ?? token;
return lastSegment.toUpperCase();
}
private attachLifecycleListeners(): void {
this.rebindInteractiveElements();
}
public firstUpdated(changedProperties: Map<string, unknown>) {
super.firstUpdated(changedProperties);
this.attachLifecycleListeners();
}
public updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('value')) {
void this.validate();
}
this.rebindInteractiveElements();
}
public async disconnectedCallback(): Promise<void> {
this.detachDropListeners();
this.value.forEach((file) => this.releasePreview(file));
this.previewUrlMap = new WeakMap();
await super.disconnectedCallback();
}
public async openFileSelector() {
if (this.disabled || this.isLoading) {
return;
}
this.isLoading = true;
// Ensure we have the latest reference to the file input
const inputFile = this.shadowRoot?.querySelector('.file-input') as HTMLInputElement | null;
if (!inputFile) {
this.isLoading = false;
return;
}
const handleFocus = () => {
setTimeout(() => {
if (!inputFile.files || inputFile.files.length === 0) {
this.isLoading = false;
}
window.removeEventListener('focus', handleFocus);
}, 300);
};
window.addEventListener('focus', handleFocus);
// Click the input to open file selector
inputFile.click();
}
public removeFile(file: File) {
const index = this.value.indexOf(file);
if (index > -1) {
this.releasePreview(file);
this.value.splice(index, 1);
this.requestUpdate('value');
void this.validate();
this.changeSubject.next(this);
}
}
public clearAll() {
const existingFiles = [...this.value];
this.value = [];
existingFiles.forEach((file) => this.releasePreview(file));
this.requestUpdate('value');
void this.validate();
this.changeSubject.next(this);
this.buttonText = 'Select files';
}
public async updateValue(eventArg: Event) {
const target = eventArg.target as HTMLInputElement;
this.value = Array.from(target.files ?? []);
this.changeSubject.next(this);
}
public setValue(value: File[]): void {
this.value.forEach((file) => this.releasePreview(file));
this.value = value;
if (value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
} else {
this.buttonText = 'Select files';
}
this.requestUpdate('value');
void this.validate();
}
public getValue(): File[] {
return this.value;
}
private async addFiles(files: File[]) {
const filesToAdd: File[] = [];
for (const file of files) {
if (this.validateFile(file)) {
filesToAdd.push(file);
}
}
if (filesToAdd.length === 0) {
this.isLoading = false;
return;
}
if (this.maxFiles > 0) {
const totalFiles = this.value.length + filesToAdd.length;
if (totalFiles > this.maxFiles) {
const allowedCount = this.maxFiles - this.value.length;
if (allowedCount <= 0) {
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
this.validationState = 'invalid';
this.isLoading = false;
return;
}
filesToAdd.splice(allowedCount);
this.validationMessage = `Only ${allowedCount} more file(s) can be added`;
this.validationState = 'warn';
}
}
if (!this.multiple && filesToAdd.length > 0) {
this.value.forEach((file) => this.releasePreview(file));
this.value = [filesToAdd[0]];
} else {
this.value.push(...filesToAdd);
}
this.validationMessage = '';
this.validationState = null;
this.requestUpdate('value');
await this.validate();
this.changeSubject.next(this);
this.isLoading = false;
if (this.value.length > 0) {
this.buttonText = this.multiple ? 'Add more files' : 'Replace file';
} else {
this.buttonText = 'Select files';
}
}
public async validate(): Promise<boolean> {
this.validationMessage = '';
if (this.required && this.value.length === 0) {
this.validationState = 'invalid';
this.validationMessage = 'Please select at least one file';
return false;
}
for (const file of this.value) {
if (!this.validateFile(file)) {
return false;
}
}
this.validationState = this.value.length > 0 ? 'valid' : null;
return true;
}
}

View File

@@ -0,0 +1,159 @@
import { css, cssManager, html } from '@design.estate/dees-element';
import './component.js';
import '../dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>
<style>
${css`
.demo-shell {
display: flex;
flex-direction: column;
gap: 32px;
padding: 24px;
max-width: 1160px;
margin: 0 auto;
}
.demo-grid {
display: grid;
gap: 24px;
}
@media (min-width: 960px) {
.demo-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.demo-stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.demo-note {
margin-top: 16px;
padding: 16px;
border-radius: 12px;
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(215 20% 26%)')};
background: ${cssManager.bdTheme('hsl(213 100% 97%)', 'hsl(215 20% 12%)')};
color: ${cssManager.bdTheme('hsl(215 25% 32%)', 'hsl(215 20% 82%)')};
font-size: 13px;
line-height: 1.55;
}
.demo-note strong {
color: ${cssManager.bdTheme('hsl(217 91% 45%)', 'hsl(213 93% 68%)')};
font-weight: 600;
}
`}
</style>
<div class="demo-shell">
<dees-panel
.title=${'Modern file uploader'}
.subtitle=${'Shadcn-inspired layout with drag & drop, previews and validation'}
>
<div class="demo-grid demo-grid--two">
<div class="demo-stack">
<dees-input-fileupload
.label=${'Attachments'}
.description=${'Upload supporting documents for your request'}
.accept=${'image/*,.pdf,.zip'}
.maxSize=${10 * 1024 * 1024}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Brand assets'}
.description=${'Upload high-resolution imagery (JPG/PNG)'}
.accept=${'image/jpeg,image/png'}
.multiple=${false}
.maxSize=${5 * 1024 * 1024}
.buttonText=${'Select cover image'}
></dees-input-fileupload>
</div>
<div class="demo-stack">
<dees-input-fileupload
.label=${'Audio uploads'}
.description=${'Share podcast drafts (MP3/WAV, max 25MB each)'}
.accept=${'audio/*'}
.maxSize=${25 * 1024 * 1024}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Disabled example'}
.description=${'Uploader is disabled while moderation is pending'}
.disabled=${true}
></dees-input-fileupload>
</div>
</div>
</dees-panel>
<dees-panel
.title=${'Form integration'}
.subtitle=${'Combine file uploads with the rest of the DEES form ecosystem'}
>
<div class="demo-grid">
<dees-form>
<div class="demo-stack">
<dees-input-text
.label=${'Project name'}
.description=${'How should we refer to this project internally?'}
.required=${true}
.key=${'projectName'}
></dees-input-text>
<dees-input-text
.label=${'Contact email'}
.inputType=${'email'}
.required=${true}
.key=${'contactEmail'}
></dees-input-text>
<dees-input-fileupload
.label=${'Statement of work'}
.description=${'Upload a signed statement of work (PDF, max 15MB)'}
.required=${true}
.accept=${'application/pdf'}
.maxSize=${15 * 1024 * 1024}
.multiple=${false}
.key=${'sow'}
></dees-input-fileupload>
<dees-input-fileupload
.label=${'Creative references'}
.description=${'Optional. Upload up to five visual references'}
.accept=${'image/*'}
.maxFiles=${5}
.maxSize=${8 * 1024 * 1024}
.key=${'references'}
></dees-input-fileupload>
<dees-input-text
.label=${'Notes'}
.description=${'Add optional context for reviewers'}
.inputType=${'textarea'}
.key=${'notes'}
></dees-input-text>
<dees-form-submit .text=${'Submit briefing'}></dees-form-submit>
</div>
</dees-form>
<div class="demo-note">
<strong>Good to know:</strong>
<ul>
<li>Drag & drop highlights the dropzone and supports keyboard activation.</li>
<li>Accepted file types are summarised automatically from the <code>accept</code> attribute.</li>
<li>Image uploads show live previews generated via <code>URL.createObjectURL</code>.</li>
<li>File size and file-count limits surface inline validation messages.</li>
<li>The component stays compatible with <code>dees-form</code> value accessors.</li>
</ul>
</div>
</div>
</dees-panel>
</div>
</dees-demowrapper>
`;

View File

@@ -0,0 +1,2 @@
export * from './component.js';
export { fileuploadStyles } from './styles.js';

View File

@@ -0,0 +1,313 @@
import { css, cssManager } from '@design.estate/dees-element';
import { DeesInputBase } from '../dees-input-base.js';
export const fileuploadStyles = [
...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;
}
.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%)')};
}
.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)')};
}
.dropzone--has-files {
background: ${cssManager.bdTheme('hsl(0 0% 99%)', 'hsl(215 20% 11%)')};
}
.dropzone--disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
.dropzone__body {
display: flex;
align-items: center;
gap: 16px;
}
.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__icon dees-icon {
font-size: 22px;
}
.dropzone__loader {
width: 20px;
height: 20px;
border-radius: 999px;
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%)')};
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 {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
}
.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-weight: 600;
cursor: pointer;
text-decoration: none;
}
.dropzone__browse:hover {
text-decoration: underline;
}
.dropzone__browse:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.dropzone__meta {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')};
}
.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 {
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 {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 68%)')};
}
.file-list__clear {
appearance: none;
border: none;
background: none;
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
cursor: pointer;
font-weight: 500;
font-size: 13px;
padding: 0;
}
.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;
}
.file-row:hover {
background: ${cssManager.bdTheme('hsl(0 0% 100% / 0.8)', 'hsl(215 20% 16% / 0.8)')};
}
.file-thumb {
width: 36px;
height: 36px;
border-radius: 8px;
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')};
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.file-thumb dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')};
display: block;
width: 18px;
height: 18px;
line-height: 1;
flex-shrink: 0;
}
.thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.file-name {
font-weight: 600;
font-size: 14px;
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-details {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
}
.file-size {
font-variant-numeric: tabular-nums;
}
.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%)')};
text-transform: uppercase;
letter-spacing: 0.08em;
line-height: 1;
}
.file-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.remove-button {
width: 28px;
height: 28px;
border-radius: 6px;
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%)')};
}
.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%)')};
}
.remove-button:active {
transform: scale(0.96);
}
.remove-button dees-icon {
display: block;
width: 14px;
height: 14px;
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.validation-message {
font-size: 13px;
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
line-height: 1.5;
}
@keyframes loader-spin {
to {
transform: rotate(360deg);
}
}
`,
];

View File

@@ -1,714 +0,0 @@
import * as colors from './00colors.js';
import { DeesInputBase } from './dees-input-base.js';
import { demoFunc } from './dees-input-richtext.demo.js';
import './dees-icon.js';
import {
customElement,
type TemplateResult,
property,
html,
css,
cssManager,
state,
query,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Typography from '@tiptap/extension-typography';
declare global {
interface HTMLElementTagNameMap {
'dees-input-richtext': DeesInputRichtext;
}
}
interface IToolbarButton {
name: string;
icon?: string;
action?: () => void;
isActive?: () => boolean;
title: string;
isDivider?: boolean;
}
@customElement('dees-input-richtext')
export class DeesInputRichtext extends DeesInputBase<string> {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
reflect: true,
})
public value: string = '';
@property({
type: String,
})
public placeholder: string = '';
@property({
type: Boolean,
})
public showWordCount: boolean = true;
@property({
type: Number,
})
public minHeight: number = 200;
@state()
private showLinkInput: boolean = false;
@state()
private wordCount: number = 0;
@query('.editor-content')
private editorElement: HTMLElement;
@query('.link-input input')
private linkInputElement: HTMLInputElement;
private editor: Editor;
public static styles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.input-wrapper {
position: relative;
}
.label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.editor-container {
display: flex;
flex-direction: column;
min-height: ${cssManager.bdTheme('200px', '200px')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
overflow: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.editor-container:hover {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.editor-container.focused {
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
align-items: center;
position: relative;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
transition: all 0.15s ease;
user-select: none;
}
.toolbar-button dees-icon {
width: 16px;
height: 16px;
}
.toolbar-button: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%)')};
}
.toolbar-button.active {
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin: 0 4px;
}
.editor-content {
flex: 1;
padding: 16px;
overflow-y: auto;
min-height: var(--min-height, 200px);
}
.editor-content .ProseMirror {
outline: none;
line-height: 1.6;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
min-height: 100%;
}
.editor-content .ProseMirror p {
margin: 0.5em 0;
}
.editor-content .ProseMirror p:first-child {
margin-top: 0;
}
.editor-content .ProseMirror p:last-child {
margin-bottom: 0;
}
.editor-content .ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.2;
}
.editor-content .ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.3;
}
.editor-content .ProseMirror h3 {
font-size: 1.25em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.4;
}
.editor-content .ProseMirror ul,
.editor-content .ProseMirror ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.editor-content .ProseMirror li {
margin: 0.25em 0;
}
.editor-content .ProseMirror blockquote {
border-left: 4px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin: 1em 0;
padding-left: 1em;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
font-style: italic;
}
.editor-content .ProseMirror code {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-radius: 3px;
padding: 0.2em 0.4em;
font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9em;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.editor-content .ProseMirror pre {
background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
border-radius: 6px;
padding: 1em;
margin: 1em 0;
overflow-x: auto;
}
.editor-content .ProseMirror pre code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
}
.editor-content .ProseMirror a {
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
text-decoration: underline;
cursor: pointer;
}
.editor-content .ProseMirror a:hover {
color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')};
}
.editor-footer {
padding: 8px 12px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
display: flex;
justify-content: space-between;
align-items: center;
}
.word-count {
font-weight: 500;
}
.link-input {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 12px;
z-index: 1000;
}
.link-input.show {
display: block;
}
.link-input input {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
outline: none;
font-size: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.link-input input:focus {
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
}
.link-input-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.link-input-buttons button {
padding: 6px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
cursor: pointer;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
transition: all 0.15s ease;
font-weight: 500;
}
.link-input-buttons button: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%)')};
}
.link-input-buttons button.primary {
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
}
.link-input-buttons button.primary:hover {
background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.description {
margin-top: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
line-height: 1.4;
}
:host([disabled]) .editor-container {
opacity: 0.6;
cursor: not-allowed;
}
:host([disabled]) .toolbar-button,
:host([disabled]) .editor-content {
pointer-events: none;
}
`,
];
public render(): TemplateResult {
return html`
<div class="input-wrapper">
${this.label ? html`<label class="label">${this.label}</label>` : ''}
<div class="editor-container ${this.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${this.minHeight}px">
<div class="editor-toolbar">
${this.renderToolbar()}
<div class="link-input ${this.showLinkInput ? 'show' : ''}">
<input type="url" placeholder="Enter URL..." @keydown=${this.handleLinkInputKeydown} />
<div class="link-input-buttons">
<button class="primary" @click=${this.saveLink}>Save</button>
<button @click=${this.removeLink}>Remove</button>
<button @click=${this.hideLinkInput}>Cancel</button>
</div>
</div>
</div>
<div class="editor-content"></div>
${this.showWordCount
? html`
<div class="editor-footer">
<span class="word-count">${this.wordCount} word${this.wordCount !== 1 ? 's' : ''}</span>
</div>
`
: ''}
</div>
${this.description ? html`<div class="description">${this.description}</div>` : ''}
</div>
`;
}
private renderToolbar(): TemplateResult {
const buttons: IToolbarButton[] = this.getToolbarButtons();
return html`
${buttons.map((button) => {
if (button.isDivider) {
return html`<div class="toolbar-divider"></div>`;
}
return html`
<button
class="toolbar-button ${button.isActive?.() ? 'active' : ''}"
@click=${button.action}
title=${button.title}
?disabled=${this.disabled || !this.editor}
>
<dees-icon .icon=${button.icon}></dees-icon>
</button>
`;
})}
`;
}
private getToolbarButtons(): IToolbarButton[] {
if (!this.editor) return [];
return [
{
name: 'bold',
icon: 'lucide:bold',
title: 'Bold (Ctrl+B)',
action: () => this.editor.chain().focus().toggleBold().run(),
isActive: () => this.editor.isActive('bold'),
},
{
name: 'italic',
icon: 'lucide:italic',
title: 'Italic (Ctrl+I)',
action: () => this.editor.chain().focus().toggleItalic().run(),
isActive: () => this.editor.isActive('italic'),
},
{
name: 'underline',
icon: 'lucide:underline',
title: 'Underline (Ctrl+U)',
action: () => this.editor.chain().focus().toggleUnderline().run(),
isActive: () => this.editor.isActive('underline'),
},
{
name: 'strike',
icon: 'lucide:strikethrough',
title: 'Strikethrough',
action: () => this.editor.chain().focus().toggleStrike().run(),
isActive: () => this.editor.isActive('strike'),
},
{ name: 'divider1', title: '', isDivider: true },
{
name: 'h1',
icon: 'lucide:heading1',
title: 'Heading 1',
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => this.editor.isActive('heading', { level: 1 }),
},
{
name: 'h2',
icon: 'lucide:heading2',
title: 'Heading 2',
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => this.editor.isActive('heading', { level: 2 }),
},
{
name: 'h3',
icon: 'lucide:heading3',
title: 'Heading 3',
action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => this.editor.isActive('heading', { level: 3 }),
},
{ name: 'divider2', title: '', isDivider: true },
{
name: 'bulletList',
icon: 'lucide:list',
title: 'Bullet List',
action: () => this.editor.chain().focus().toggleBulletList().run(),
isActive: () => this.editor.isActive('bulletList'),
},
{
name: 'orderedList',
icon: 'lucide:listOrdered',
title: 'Numbered List',
action: () => this.editor.chain().focus().toggleOrderedList().run(),
isActive: () => this.editor.isActive('orderedList'),
},
{
name: 'blockquote',
icon: 'lucide:quote',
title: 'Quote',
action: () => this.editor.chain().focus().toggleBlockquote().run(),
isActive: () => this.editor.isActive('blockquote'),
},
{
name: 'code',
icon: 'lucide:code',
title: 'Code',
action: () => this.editor.chain().focus().toggleCode().run(),
isActive: () => this.editor.isActive('code'),
},
{
name: 'codeBlock',
icon: 'lucide:fileCode',
title: 'Code Block',
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
isActive: () => this.editor.isActive('codeBlock'),
},
{ name: 'divider3', title: '', isDivider: true },
{
name: 'link',
icon: 'lucide:link',
title: 'Add Link',
action: () => this.toggleLink(),
isActive: () => this.editor.isActive('link'),
},
{
name: 'alignLeft',
icon: 'lucide:alignLeft',
title: 'Align Left',
action: () => this.editor.chain().focus().setTextAlign('left').run(),
isActive: () => this.editor.isActive({ textAlign: 'left' }),
},
{
name: 'alignCenter',
icon: 'lucide:alignCenter',
title: 'Align Center',
action: () => this.editor.chain().focus().setTextAlign('center').run(),
isActive: () => this.editor.isActive({ textAlign: 'center' }),
},
{
name: 'alignRight',
icon: 'lucide:alignRight',
title: 'Align Right',
action: () => this.editor.chain().focus().setTextAlign('right').run(),
isActive: () => this.editor.isActive({ textAlign: 'right' }),
},
{ name: 'divider4', title: '', isDivider: true },
{
name: 'undo',
icon: 'lucide:undo',
title: 'Undo (Ctrl+Z)',
action: () => this.editor.chain().focus().undo().run(),
},
{
name: 'redo',
icon: 'lucide:redo',
title: 'Redo (Ctrl+Y)',
action: () => this.editor.chain().focus().redo().run(),
},
];
}
public async firstUpdated() {
await this.updateComplete;
this.initializeEditor();
}
private initializeEditor(): void {
if (this.disabled) return;
this.editor = new Editor({
element: this.editorElement,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'editor-link',
},
}),
Typography,
],
content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''),
onUpdate: ({ editor }) => {
this.value = editor.getHTML();
this.updateWordCount();
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
},
onSelectionUpdate: () => {
this.requestUpdate();
},
onFocus: () => {
this.requestUpdate();
},
onBlur: () => {
this.requestUpdate();
},
});
this.updateWordCount();
}
private updateWordCount(): void {
if (!this.editor) return;
const text = this.editor.getText();
this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
}
private toggleLink(): void {
if (!this.editor) return;
if (this.editor.isActive('link')) {
const href = this.editor.getAttributes('link').href;
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = href || '';
this.linkInputElement.focus();
this.linkInputElement.select();
}
});
} else {
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = '';
this.linkInputElement.focus();
}
});
}
}
private saveLink(): void {
if (!this.editor || !this.linkInputElement) return;
const url = this.linkInputElement.value;
if (url) {
this.editor.chain().focus().setLink({ href: url }).run();
}
this.hideLinkInput();
}
private removeLink(): void {
if (!this.editor) return;
this.editor.chain().focus().unsetLink().run();
this.hideLinkInput();
}
private hideLinkInput(): void {
this.showLinkInput = false;
this.editor?.commands.focus();
}
private handleLinkInputKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();
this.saveLink();
} else if (e.key === 'Escape') {
e.preventDefault();
this.hideLinkInput();
}
}
public setValue(value: string): void {
this.value = value;
if (this.editor && value !== this.editor.getHTML()) {
this.editor.commands.setContent(value);
}
}
public getValue(): string {
return this.value;
}
public clear(): void {
this.setValue('');
}
public focus(): void {
this.editor?.commands.focus();
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.editor) {
this.editor.destroy();
}
}
}

View File

@@ -0,0 +1,384 @@
import { DeesInputBase } from '../dees-input-base.js';
import { demoFunc } from './demo.js';
import { richtextStyles } from './styles.js';
import { renderRichtext } from './template.js';
import type { IToolbarButton } from './types.js';
import '../dees-icon.js';
import {
customElement,
type TemplateResult,
property,
html,
state,
query,
} from '@design.estate/dees-element';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Link from '@tiptap/extension-link';
import Typography from '@tiptap/extension-typography';
declare global {
interface HTMLElementTagNameMap {
'dees-input-richtext': DeesInputRichtext;
}
}
@customElement('dees-input-richtext')
export class DeesInputRichtext extends DeesInputBase<string> {
public static demo = demoFunc;
// INSTANCE
@property({
type: String,
reflect: true,
})
public value: string = '';
@property({
type: String,
})
public placeholder: string = '';
@property({
type: Boolean,
})
public showWordCount: boolean = true;
@property({
type: Number,
})
public minHeight: number = 200;
@state()
public showLinkInput: boolean = false;
@state()
public wordCount: number = 0;
@query('.editor-content')
private editorElement: HTMLElement;
@query('.link-input input')
private linkInputElement: HTMLInputElement;
public editor: Editor;
public static styles = richtextStyles;
public render(): TemplateResult {
return renderRichtext(this);
}
public renderToolbar(): TemplateResult {
const buttons: IToolbarButton[] = this.getToolbarButtons();
return html`
${buttons.map((button) => {
if (button.isDivider) {
return html`<div class="toolbar-divider"></div>`;
}
return html`
<button
class="toolbar-button ${button.isActive?.() ? 'active' : ''}"
@click=${button.action}
title=${button.title}
?disabled=${this.disabled || !this.editor}
>
<dees-icon .icon=${button.icon}></dees-icon>
</button>
`;
})}
`;
}
private getToolbarButtons(): IToolbarButton[] {
if (!this.editor) return [];
return [
{
name: 'bold',
icon: 'lucide:bold',
title: 'Bold (Ctrl+B)',
action: () => this.editor.chain().focus().toggleBold().run(),
isActive: () => this.editor.isActive('bold'),
},
{
name: 'italic',
icon: 'lucide:italic',
title: 'Italic (Ctrl+I)',
action: () => this.editor.chain().focus().toggleItalic().run(),
isActive: () => this.editor.isActive('italic'),
},
{
name: 'underline',
icon: 'lucide:underline',
title: 'Underline (Ctrl+U)',
action: () => this.editor.chain().focus().toggleUnderline().run(),
isActive: () => this.editor.isActive('underline'),
},
{
name: 'strike',
icon: 'lucide:strikethrough',
title: 'Strikethrough',
action: () => this.editor.chain().focus().toggleStrike().run(),
isActive: () => this.editor.isActive('strike'),
},
{ name: 'divider1', title: '', isDivider: true },
{
name: 'h1',
icon: 'lucide:heading1',
title: 'Heading 1',
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => this.editor.isActive('heading', { level: 1 }),
},
{
name: 'h2',
icon: 'lucide:heading2',
title: 'Heading 2',
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => this.editor.isActive('heading', { level: 2 }),
},
{
name: 'h3',
icon: 'lucide:heading3',
title: 'Heading 3',
action: () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => this.editor.isActive('heading', { level: 3 }),
},
{ name: 'divider2', title: '', isDivider: true },
{
name: 'bulletList',
icon: 'lucide:list',
title: 'Bullet List',
action: () => this.editor.chain().focus().toggleBulletList().run(),
isActive: () => this.editor.isActive('bulletList'),
},
{
name: 'orderedList',
icon: 'lucide:listOrdered',
title: 'Numbered List',
action: () => this.editor.chain().focus().toggleOrderedList().run(),
isActive: () => this.editor.isActive('orderedList'),
},
{
name: 'blockquote',
icon: 'lucide:quote',
title: 'Quote',
action: () => this.editor.chain().focus().toggleBlockquote().run(),
isActive: () => this.editor.isActive('blockquote'),
},
{
name: 'code',
icon: 'lucide:code',
title: 'Code',
action: () => this.editor.chain().focus().toggleCode().run(),
isActive: () => this.editor.isActive('code'),
},
{
name: 'codeBlock',
icon: 'lucide:fileCode',
title: 'Code Block',
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
isActive: () => this.editor.isActive('codeBlock'),
},
{ name: 'divider3', title: '', isDivider: true },
{
name: 'link',
icon: 'lucide:link',
title: 'Add Link',
action: () => this.toggleLink(),
isActive: () => this.editor.isActive('link'),
},
{
name: 'alignLeft',
icon: 'lucide:alignLeft',
title: 'Align Left',
action: () => this.editor.chain().focus().setTextAlign('left').run(),
isActive: () => this.editor.isActive({ textAlign: 'left' }),
},
{
name: 'alignCenter',
icon: 'lucide:alignCenter',
title: 'Align Center',
action: () => this.editor.chain().focus().setTextAlign('center').run(),
isActive: () => this.editor.isActive({ textAlign: 'center' }),
},
{
name: 'alignRight',
icon: 'lucide:alignRight',
title: 'Align Right',
action: () => this.editor.chain().focus().setTextAlign('right').run(),
isActive: () => this.editor.isActive({ textAlign: 'right' }),
},
{ name: 'divider4', title: '', isDivider: true },
{
name: 'undo',
icon: 'lucide:undo',
title: 'Undo (Ctrl+Z)',
action: () => this.editor.chain().focus().undo().run(),
},
{
name: 'redo',
icon: 'lucide:redo',
title: 'Redo (Ctrl+Y)',
action: () => this.editor.chain().focus().redo().run(),
},
];
}
public async firstUpdated() {
await this.updateComplete;
this.initializeEditor();
}
private initializeEditor(): void {
if (this.disabled) return;
this.editor = new Editor({
element: this.editorElement,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Underline,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'editor-link',
},
}),
Typography,
],
content: this.value || (this.placeholder ? `<p>${this.placeholder}</p>` : ''),
onUpdate: ({ editor }) => {
this.value = editor.getHTML();
this.updateWordCount();
this.dispatchEvent(
new CustomEvent('input', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
this.dispatchEvent(
new CustomEvent('change', {
detail: { value: this.value },
bubbles: true,
composed: true,
})
);
},
onSelectionUpdate: () => {
this.requestUpdate();
},
onFocus: () => {
this.requestUpdate();
},
onBlur: () => {
this.requestUpdate();
},
});
this.updateWordCount();
}
private updateWordCount(): void {
if (!this.editor) return;
const text = this.editor.getText();
this.wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
}
private toggleLink(): void {
if (!this.editor) return;
if (this.editor.isActive('link')) {
const href = this.editor.getAttributes('link').href;
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = href || '';
this.linkInputElement.focus();
this.linkInputElement.select();
}
});
} else {
this.showLinkInput = true;
requestAnimationFrame(() => {
if (this.linkInputElement) {
this.linkInputElement.value = '';
this.linkInputElement.focus();
}
});
}
}
public saveLink(): void {
if (!this.editor || !this.linkInputElement) return;
const url = this.linkInputElement.value;
if (url) {
this.editor.chain().focus().setLink({ href: url }).run();
}
this.hideLinkInput();
}
public removeLink(): void {
if (!this.editor) return;
this.editor.chain().focus().unsetLink().run();
this.hideLinkInput();
}
public hideLinkInput(): void {
this.showLinkInput = false;
this.editor?.commands.focus();
}
public handleLinkInputKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();
this.saveLink();
} else if (e.key === 'Escape') {
e.preventDefault();
this.hideLinkInput();
}
}
public setValue(value: string): void {
this.value = value;
if (this.editor && value !== this.editor.getHTML()) {
this.editor.commands.setContent(value);
}
}
public getValue(): string {
return this.value;
}
public clear(): void {
this.setValue('');
}
public focus(): void {
this.editor?.commands.focus();
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
if (this.editor) {
this.editor.destroy();
}
}
}

View File

@@ -1,6 +1,7 @@
import { html, css } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import './dees-panel.js';
import './component.js';
import '../dees-panel.js';
export const demoFunc = () => html`
<dees-demowrapper>

View File

@@ -0,0 +1,4 @@
export * from './component.js';
export { richtextStyles } from './styles.js';
export { renderRichtext } from './template.js';
export type { IToolbarButton } from './types.js';

View File

@@ -0,0 +1,303 @@
import { css, cssManager } from '@design.estate/dees-element';
import { DeesInputBase } from '../dees-input-base.js';
export const richtextStyles = [
...DeesInputBase.baseStyles,
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.input-wrapper {
position: relative;
}
.label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.editor-container {
display: flex;
flex-direction: column;
min-height: ${cssManager.bdTheme('200px', '200px')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
overflow: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.editor-container:hover {
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
}
.editor-container.focused {
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px 12px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
align-items: center;
position: relative;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
transition: all 0.15s ease;
user-select: none;
}
.toolbar-button dees-icon {
width: 16px;
height: 16px;
}
.toolbar-button: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%)')};
}
.toolbar-button.active {
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-divider {
width: 1px;
height: 24px;
background: ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin: 0 4px;
}
.editor-content {
flex: 1;
padding: 16px;
overflow-y: auto;
min-height: var(--min-height, 200px);
}
.editor-content .ProseMirror {
outline: none;
line-height: 1.6;
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
min-height: 100%;
}
.editor-content .ProseMirror p {
margin: 0.5em 0;
}
.editor-content .ProseMirror p:first-child {
margin-top: 0;
}
.editor-content .ProseMirror p:last-child {
margin-bottom: 0;
}
.editor-content .ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.2;
}
.editor-content .ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.3;
}
.editor-content .ProseMirror h3 {
font-size: 1.25em;
font-weight: bold;
margin: 1em 0 0.5em 0;
line-height: 1.4;
}
.editor-content .ProseMirror ul,
.editor-content .ProseMirror ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.editor-content .ProseMirror li {
margin: 0.25em 0;
}
.editor-content .ProseMirror blockquote {
border-left: 4px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
margin: 1em 0;
padding-left: 1em;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
font-style: italic;
}
.editor-content .ProseMirror code {
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
border-radius: 3px;
padding: 0.2em 0.4em;
font-family: 'Intel One Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 0.9em;
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.editor-content .ProseMirror pre {
background: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 98%)', 'hsl(0 0% 3.9%)')};
border-radius: 6px;
padding: 1em;
margin: 1em 0;
overflow-x: auto;
}
.editor-content .ProseMirror pre code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
}
.editor-content .ProseMirror a {
color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
text-decoration: underline;
cursor: pointer;
}
.editor-content .ProseMirror a:hover {
color: ${cssManager.bdTheme('hsl(222.2 47.4% 41.2%)', 'hsl(217.2 91.2% 69.8%)')};
}
.editor-footer {
padding: 8px 12px;
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
display: flex;
justify-content: space-between;
align-items: center;
}
.word-count {
font-weight: 500;
}
.link-input {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 12px;
z-index: 1000;
}
.link-input.show {
display: block;
}
.link-input input {
width: 100%;
padding: 8px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 6px;
outline: none;
font-size: 14px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.link-input input:focus {
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 9% / 0.05)', 'hsl(0 0% 98% / 0.05)')};
}
.link-input-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.link-input-buttons button {
padding: 6px 12px;
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
border-radius: 4px;
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
cursor: pointer;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
transition: all 0.15s ease;
font-weight: 500;
}
.link-input-buttons button: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%)')};
}
.link-input-buttons button.primary {
background: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
color: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 98%)')};
}
.link-input-buttons button.primary:hover {
background: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
border-color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 93.9%)')};
}
.description {
margin-top: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
line-height: 1.4;
}
:host([disabled]) .editor-container {
opacity: 0.6;
cursor: not-allowed;
}
:host([disabled]) .toolbar-button,
:host([disabled]) .editor-content {
pointer-events: none;
}
`,
];

View File

@@ -0,0 +1,33 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import type { DeesInputRichtext } from './component.js';
export const renderRichtext = (component: DeesInputRichtext): TemplateResult => {
return html`
<div class="input-wrapper">
${component.label ? html`<label class="label">${component.label}</label>` : ''}
<div class="editor-container ${component.editor?.isFocused ? 'focused' : ''}" style="--min-height: ${component.minHeight}px">
<div class="editor-toolbar">
${component.renderToolbar()}
<div class="link-input ${component.showLinkInput ? 'show' : ''}">
<input type="url" placeholder="Enter URL..." @keydown=${component.handleLinkInputKeydown} />
<div class="link-input-buttons">
<button class="primary" @click=${component.saveLink}>Save</button>
<button @click=${component.removeLink}>Remove</button>
<button @click=${component.hideLinkInput}>Cancel</button>
</div>
</div>
</div>
<div class="editor-content"></div>
${component.showWordCount
? html`
<div class="editor-footer">
<span class="word-count">${component.wordCount} word${component.wordCount !== 1 ? 's' : ''}</span>
</div>
`
: ''}
</div>
${component.description ? html`<div class="description">${component.description}</div>` : ''}
</div>
`;
};

View File

@@ -0,0 +1,8 @@
export interface IToolbarButton {
name: string;
icon?: string;
action?: () => void;
isActive?: () => boolean;
title: string;
isDivider?: boolean;
}

View File

@@ -383,7 +383,10 @@ export const demoFunc = (): TemplateResult => html`
if (editors.exportDemo) {
setupExportDemo(elementArg, editors.exportDemo);
}
// Setup output format preview buttons
setupOutputFormatDemo(elementArg, editors.meeting, editors.recipe);
// Populate initial content
populateInitialContent(editors);

View File

@@ -2,8 +2,7 @@ import { html, css, cssManager } from '@design.estate/dees-element';
import { DeesToast } from './dees-toast.js';
import './dees-button.js';
export const demoFunc = async () => {
return html`
export const demoFunc = () => html`
<style>
.demo-container {
padding: 32px;
@@ -259,4 +258,3 @@ export const demoFunc = async () => {
</div>
</div>
`;
};

View File

@@ -1,6 +1,6 @@
export * from './00zindex.js';
export * from './dees-appui-activitylog.js';
export * from './dees-appui-appbar.js';
export * from './dees-appui-appbar/index.js';
export * from './dees-appui-base.js';
export * from './dees-appui-maincontent.js';
export * from './dees-appui-mainmenu.js';
@@ -12,7 +12,7 @@ export * from './dees-badge.js';
export * from './dees-button-exit.js';
export * from './dees-button-group.js';
export * from './dees-button.js';
export * from './dees-chart-area.js';
export * from './dees-chart-area/index.js';
export * from './dees-chart-log.js';
export * from './dees-chips.js';
export * from './dees-contextmenu.js';
@@ -28,9 +28,9 @@ export * from './dees-heading.js';
export * from './dees-hint.js';
export * from './dees-icon.js';
export * from './dees-input-checkbox.js';
export * from './dees-input-datepicker.js';
export * from './dees-input-datepicker/index.js';
export * from './dees-input-dropdown.js';
export * from './dees-input-fileupload.js';
export * from './dees-input-fileupload/index.js';
export * from './dees-input-iban.js';
export * from './dees-input-list.js';
export * from './profilepicture/dees-input-profilepicture.js';
@@ -40,7 +40,7 @@ export * from './dees-input-wysiwyg.js';
export * from './dees-progressbar.js';
export * from './dees-input-quantityselector.js';
export * from './dees-input-radiogroup.js';
export * from './dees-input-richtext.js';
export * from './dees-input-richtext/index.js';
export * from './dees-input-tags.js';
export * from './dees-input-text.js';
export * from './dees-label.js';