486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
import {
|
|
customElement,
|
|
DeesElement,
|
|
type TemplateResult,
|
|
html,
|
|
css,
|
|
state,
|
|
cssManager,
|
|
} from '@design.estate/dees-element';
|
|
import { themeDefaultStyles } from '../00theme.js';
|
|
import '../dees-icon/dees-icon.js';
|
|
import type {
|
|
IActionBarOptions,
|
|
IActionBarResult,
|
|
IActionBarQueueItem,
|
|
IActionBarAction,
|
|
} from './actionbar.interfaces.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-actionbar': DeesActionbar;
|
|
}
|
|
}
|
|
|
|
@customElement('dees-actionbar')
|
|
export class DeesActionbar extends DeesElement {
|
|
// STATIC
|
|
public static demo = () => {
|
|
const getActionbar = (e: Event) => {
|
|
const button = e.currentTarget as HTMLElement;
|
|
const container = button.closest('.demo-container');
|
|
return container?.querySelector('dees-actionbar') as DeesActionbar | null;
|
|
};
|
|
|
|
const showActionBar = async (e: Event) => {
|
|
const actionbar = getActionbar(e);
|
|
if (!actionbar) return;
|
|
const result = await actionbar.show({
|
|
message: 'File changed externally. Reload?',
|
|
type: 'warning',
|
|
icon: 'lucide:alertTriangle',
|
|
actions: [
|
|
{ id: 'reload', label: 'Reload', primary: true },
|
|
{ id: 'ignore', label: 'Ignore' },
|
|
],
|
|
timeout: { duration: 5000, defaultActionId: 'reload' },
|
|
dismissible: true,
|
|
});
|
|
console.log('Action bar result:', result);
|
|
};
|
|
|
|
const showErrorBar = async (e: Event) => {
|
|
const actionbar = getActionbar(e);
|
|
if (!actionbar) return;
|
|
const result = await actionbar.show({
|
|
message: 'Process failed with exit code 1',
|
|
type: 'error',
|
|
icon: 'lucide:xCircle',
|
|
actions: [
|
|
{ id: 'retry', label: 'Retry', primary: true },
|
|
{ id: 'dismiss', label: 'Dismiss' },
|
|
],
|
|
timeout: { duration: 10000, defaultActionId: 'dismiss' },
|
|
});
|
|
console.log('Error bar result:', result);
|
|
};
|
|
|
|
const showQuestionBar = async (e: Event) => {
|
|
const actionbar = getActionbar(e);
|
|
if (!actionbar) return;
|
|
const result = await actionbar.show({
|
|
message: 'Save changes before closing?',
|
|
type: 'question',
|
|
icon: 'lucide:helpCircle',
|
|
actions: [
|
|
{ id: 'save', label: 'Save', primary: true },
|
|
{ id: 'discard', label: 'Discard' },
|
|
{ id: 'cancel', label: 'Cancel' },
|
|
],
|
|
});
|
|
console.log('Question bar result:', result);
|
|
};
|
|
|
|
return html`
|
|
<style>
|
|
.demo-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 300px;
|
|
border: 1px solid #333;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.demo-content {
|
|
flex: 1;
|
|
padding: 16px;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
}
|
|
</style>
|
|
<div class="demo-container">
|
|
<div class="demo-content">
|
|
<dees-button @click=${showActionBar}>Warning</dees-button>
|
|
<dees-button @click=${showErrorBar}>Error</dees-button>
|
|
<dees-button @click=${showQuestionBar}>Question</dees-button>
|
|
</div>
|
|
<dees-actionbar></dees-actionbar>
|
|
</div>
|
|
`;
|
|
};
|
|
|
|
// Queue of pending action bars
|
|
private queue: IActionBarQueueItem[] = [];
|
|
|
|
// Current active bar state
|
|
@state() accessor currentBar: IActionBarOptions | null = null;
|
|
@state() accessor timeRemaining: number = 0;
|
|
@state() accessor progressPercent: number = 100;
|
|
@state() accessor isVisible: boolean = false;
|
|
|
|
// Timeout handling
|
|
private timeoutInterval: ReturnType<typeof setInterval> | null = null;
|
|
private currentResolve: ((result: IActionBarResult) => void) | null = null;
|
|
|
|
public static styles = [
|
|
themeDefaultStyles,
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
overflow: hidden;
|
|
max-height: 0;
|
|
transition: max-height 0.2s ease-out;
|
|
}
|
|
|
|
:host(.visible) {
|
|
max-height: 100px; /* Enough for actionbar content */
|
|
}
|
|
|
|
.actionbar-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(0 0% 12%)')};
|
|
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 20%)')};
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 3px;
|
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 18%)')};
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
background: ${cssManager.bdTheme('hsl(210 100% 50%)', 'hsl(210 100% 60%)')};
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.progress-bar-fill.warning {
|
|
background: ${cssManager.bdTheme('hsl(38 92% 50%)', 'hsl(38 92% 55%)')};
|
|
}
|
|
|
|
.progress-bar-fill.error {
|
|
background: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')};
|
|
}
|
|
|
|
.progress-bar-fill.question {
|
|
background: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 60%)')};
|
|
}
|
|
|
|
.content {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
gap: 12px;
|
|
min-height: 32px;
|
|
}
|
|
|
|
.message-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.message-icon {
|
|
flex-shrink: 0;
|
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
|
}
|
|
|
|
.message-icon.info {
|
|
color: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 60%)')};
|
|
}
|
|
|
|
.message-icon.warning {
|
|
color: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 55%)')};
|
|
}
|
|
|
|
.message-icon.error {
|
|
color: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')};
|
|
}
|
|
|
|
.message-icon.question {
|
|
color: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 60%)')};
|
|
}
|
|
|
|
.message-text {
|
|
font-size: 13px;
|
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 85%)')};
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.actions-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.action-button {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 10px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: 1px solid transparent;
|
|
transition: all 0.15s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.action-button.secondary {
|
|
background: transparent;
|
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
|
|
border-color: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(0 0% 30%)')};
|
|
}
|
|
|
|
.action-button.secondary:hover {
|
|
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 18%)')};
|
|
}
|
|
|
|
.action-button.primary {
|
|
background: ${cssManager.bdTheme('hsl(210 100% 50%)', 'hsl(210 100% 55%)')};
|
|
color: white;
|
|
}
|
|
|
|
.action-button.primary:hover {
|
|
background: ${cssManager.bdTheme('hsl(210 100% 45%)', 'hsl(210 100% 50%)')};
|
|
}
|
|
|
|
.action-button.primary.warning {
|
|
background: ${cssManager.bdTheme('hsl(38 92% 45%)', 'hsl(38 92% 50%)')};
|
|
}
|
|
|
|
.action-button.primary.warning:hover {
|
|
background: ${cssManager.bdTheme('hsl(38 92% 40%)', 'hsl(38 92% 45%)')};
|
|
}
|
|
|
|
.action-button.primary.error {
|
|
background: ${cssManager.bdTheme('hsl(0 70% 50%)', 'hsl(0 70% 55%)')};
|
|
}
|
|
|
|
.action-button.primary.error:hover {
|
|
background: ${cssManager.bdTheme('hsl(0 70% 45%)', 'hsl(0 70% 50%)')};
|
|
}
|
|
|
|
.action-button.primary.question {
|
|
background: ${cssManager.bdTheme('hsl(270 70% 50%)', 'hsl(270 70% 55%)')};
|
|
}
|
|
|
|
.action-button.primary.question:hover {
|
|
background: ${cssManager.bdTheme('hsl(270 70% 45%)', 'hsl(270 70% 50%)')};
|
|
}
|
|
|
|
.countdown {
|
|
font-size: 11px;
|
|
opacity: 0.8;
|
|
margin-left: 2px;
|
|
}
|
|
|
|
.dismiss-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.dismiss-button:hover {
|
|
background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(0 0% 22%)')};
|
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
|
|
}
|
|
`,
|
|
];
|
|
|
|
public render(): TemplateResult {
|
|
if (!this.currentBar) {
|
|
return html``;
|
|
}
|
|
|
|
const bar = this.currentBar;
|
|
const type = bar.type || 'info';
|
|
const hasTimeout = bar.timeout && this.timeRemaining > 0;
|
|
|
|
return html`
|
|
<div class="actionbar-item">
|
|
${hasTimeout ? html`
|
|
<div class="progress-bar">
|
|
<div
|
|
class="progress-bar-fill ${type}"
|
|
style="width: ${this.progressPercent}%"
|
|
></div>
|
|
</div>
|
|
` : ''}
|
|
<div class="content">
|
|
<div class="message-section">
|
|
${bar.icon ? html`
|
|
<dees-icon
|
|
class="message-icon ${type}"
|
|
.icon=${bar.icon}
|
|
iconSize="16"
|
|
></dees-icon>
|
|
` : ''}
|
|
<span class="message-text">${bar.message}</span>
|
|
</div>
|
|
<div class="actions-section">
|
|
${bar.actions.map(action => this.renderActionButton(action, bar, hasTimeout))}
|
|
${bar.dismissible ? html`
|
|
<div
|
|
class="dismiss-button"
|
|
@click=${() => this.handleDismiss()}
|
|
title="Dismiss"
|
|
>
|
|
<dees-icon .icon=${'lucide:x'} iconSize="14"></dees-icon>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderActionButton(
|
|
action: IActionBarAction,
|
|
bar: IActionBarOptions,
|
|
hasTimeout: boolean | undefined
|
|
): TemplateResult {
|
|
const isPrimary = action.primary;
|
|
const type = bar.type || 'info';
|
|
const isDefaultAction = bar.timeout?.defaultActionId === action.id;
|
|
const showCountdown = hasTimeout && isDefaultAction;
|
|
const seconds = Math.ceil(this.timeRemaining / 1000);
|
|
|
|
return html`
|
|
<button
|
|
class="action-button ${isPrimary ? `primary ${type}` : 'secondary'}"
|
|
@click=${() => this.handleAction(action.id, false)}
|
|
>
|
|
${action.icon ? html`
|
|
<dees-icon .icon=${action.icon} iconSize="12"></dees-icon>
|
|
` : ''}
|
|
<span>${action.label}</span>
|
|
${showCountdown ? html`
|
|
<span class="countdown">(${seconds}s)</span>
|
|
` : ''}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// ========== Public API ==========
|
|
|
|
/**
|
|
* Show an action bar with the given options.
|
|
* Returns a promise that resolves when an action is taken.
|
|
*/
|
|
public async show(options: IActionBarOptions): Promise<IActionBarResult> {
|
|
return new Promise((resolve) => {
|
|
// Add to queue
|
|
this.queue.push({ options, resolve });
|
|
|
|
// If no current bar, process queue
|
|
if (!this.currentBar) {
|
|
this.processQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dismiss the current action bar without triggering any action.
|
|
*/
|
|
public dismiss(): void {
|
|
this.handleDismiss();
|
|
}
|
|
|
|
/**
|
|
* Clear all pending action bars in the queue.
|
|
*/
|
|
public clearQueue(): void {
|
|
// Resolve all queued items with dismiss
|
|
for (const item of this.queue) {
|
|
item.resolve({ actionId: 'dismissed', timedOut: false });
|
|
}
|
|
this.queue = [];
|
|
}
|
|
|
|
// ========== Private Methods ==========
|
|
|
|
private processQueue(): void {
|
|
if (this.queue.length === 0) {
|
|
this.currentBar = null;
|
|
this.currentResolve = null;
|
|
this.isVisible = false;
|
|
this.classList.remove('visible');
|
|
return;
|
|
}
|
|
|
|
const item = this.queue.shift()!;
|
|
this.currentBar = item.options;
|
|
this.currentResolve = item.resolve;
|
|
this.isVisible = true;
|
|
this.classList.add('visible');
|
|
|
|
// Setup timeout if configured
|
|
if (item.options.timeout) {
|
|
this.startTimeout(item.options.timeout.duration, item.options.timeout.defaultActionId);
|
|
}
|
|
}
|
|
|
|
private startTimeout(duration: number, defaultActionId: string): void {
|
|
this.timeRemaining = duration;
|
|
this.progressPercent = 100;
|
|
|
|
const startTime = Date.now();
|
|
const updateInterval = 50; // Update every 50ms for smooth animation
|
|
|
|
this.timeoutInterval = setInterval(() => {
|
|
const elapsed = Date.now() - startTime;
|
|
this.timeRemaining = Math.max(0, duration - elapsed);
|
|
this.progressPercent = (this.timeRemaining / duration) * 100;
|
|
|
|
if (this.timeRemaining <= 0) {
|
|
this.clearTimeoutInterval();
|
|
this.handleAction(defaultActionId, true);
|
|
}
|
|
}, updateInterval);
|
|
}
|
|
|
|
private clearTimeoutInterval(): void {
|
|
if (this.timeoutInterval) {
|
|
clearInterval(this.timeoutInterval);
|
|
this.timeoutInterval = null;
|
|
}
|
|
}
|
|
|
|
private handleAction(actionId: string, timedOut: boolean): void {
|
|
this.clearTimeoutInterval();
|
|
|
|
if (this.currentResolve) {
|
|
this.currentResolve({ actionId, timedOut });
|
|
}
|
|
|
|
// Process next item in queue
|
|
this.processQueue();
|
|
}
|
|
|
|
private handleDismiss(): void {
|
|
this.handleAction('dismissed', false);
|
|
}
|
|
|
|
public async disconnectedCallback(): Promise<void> {
|
|
await super.disconnectedCallback();
|
|
this.clearTimeoutInterval();
|
|
}
|
|
}
|