Compare commits

..

6 Commits

Author SHA1 Message Date
3f5cb4570b v3.80.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 14:21:16 +00:00
428d2741d1 feat(stepper,updater): add progress-aware stepper flows and updater countdown states 2026-04-16 14:21:16 +00:00
2f4c47f0d2 v3.79.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 10:14:46 +00:00
2be1ce6908 feat(dees-progressbar): add status panels, terminal output, and legacy progress input support 2026-04-16 10:14:46 +00:00
c0375508f0 v3.78.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-14 13:58:00 +00:00
3e86ba034b fix(dees-table): stabilize live updates by reusing row DOM and avoiding redundant layout recalculations 2026-04-14 13:58:00 +00:00
12 changed files with 1634 additions and 296 deletions

View File

@@ -1,5 +1,29 @@
# Changelog
## 2026-04-16 - 3.80.0 - feat(stepper,updater)
add progress-aware stepper flows and updater countdown states
- extend dees-stepper with embedded progressbar rendering, progress state helpers, and automatic progression for async validation steps
- rework dees-updater to run as a non-cancelable two-step flow with live progress updates, optional external links, and configurable close or reload completion actions
- refresh stepper and updater demos plus documentation to showcase auto-advancing progress steps and ready-state countdown behavior
## 2026-04-16 - 3.79.0 - feat(dees-progressbar)
add status panels, terminal output, and legacy progress input support
- Extend dees-progressbar with label, statusText, terminalLines, statusRows, indeterminate, and showPercentage properties.
- Support legacy value input and normalized progress values while clamping and formatting percentages consistently.
- Add fixed-height status and terminal-style output with spinner animation and auto-scroll behavior for live activity updates.
- Refresh the progressbar demo and readme examples to showcase determinate, indeterminate, terminal, and compatibility usage patterns.
## 2026-04-14 - 3.78.3 - fix(dees-table)
stabilize live updates by reusing row DOM and avoiding redundant layout recalculations
- reuse keyed table rows across live-sorted updates so existing row elements persist while cells reorder
- limit flash animation restarts to changed cells by tracking per-cell flash tokens
- avoid repeated column width measurements unless table layout inputs actually change
- replace async header and footer action rendering with direct mapped output to prevent comment node growth during updates
- add Chromium live update tests covering width measurement stability, comment growth, and row DOM reuse
## 2026-04-12 - 3.78.2 - fix(deps)
bump @design.estate/dees-wcctools to ^3.9.0

View File

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

View File

@@ -1508,31 +1508,56 @@ const layer = await DeesWindowLayer.createAndShow({
### Navigation Components
#### `DeesStepper`
Multi-step navigation component for guided user flows.
Multi-step navigation component for guided user flows, including optional auto-advancing progress steps that can render `dees-progressbar` status output between form steps.
```typescript
<dees-stepper
.steps=${[
{ key: 'personal', label: 'Personal Info', content: html`<div>Form 1</div>` },
{ key: 'address', label: 'Address', content: html`<div>Form 2</div>` },
{ key: 'confirm', label: 'Confirmation', content: html`<div>Review</div>` }
{
title: 'Account Setup',
content: html`<dees-form>...</dees-form>`,
menuOptions: [{ name: 'Continue', action: async (stepper) => stepper?.goNext() }]
},
{
title: 'Provision Workspace',
content: html`<p>Preparing your environment...</p>`,
progressStep: {
label: 'Workspace setup',
indeterminate: true,
statusRows: 4,
terminalLines: ['Allocating workspace']
},
validationFunc: async (stepper, _element, signal) => {
stepper.updateProgressStep({ percentage: 35, statusText: 'Installing dependencies...' });
stepper.appendProgressStepLine('Installing dependencies');
if (signal?.aborted) return;
stepper.updateProgressStep({ percentage: 100, indeterminate: false, statusText: 'Workspace ready.' });
}
}
]}
currentStep="personal"
@step-change=${handleStepChange}
@complete=${handleComplete}
></dees-stepper>
```
#### `DeesProgressbar`
Progress indicator component for tracking completion status.
Progress indicator component for tracking completion status, with optional fixed-height status text or terminal-style recent activity output.
```typescript
<dees-progressbar
value={75}
.percentage=${75}
label="Uploading"
showPercentage
type="determinate" // Options: determinate, indeterminate
status="normal" // Options: normal, success, warning, error
statusText="Uploading thumbnails to edge cache..."
.statusRows=${2}
></dees-progressbar>
<dees-progressbar
label="Installing dependencies"
.indeterminate=${true}
.statusRows=${4}
.terminalLines=${[
'Resolving workspace packages',
'Downloading tarballs',
'Linking local binaries'
]}
></dees-progressbar>
```
@@ -1570,6 +1595,33 @@ Theme provider component that wraps children and provides CSS custom properties
- Works with dark/light mode
- Overrides cascade to all child components
#### `DeesUpdater`
Updater controller that opens a non-cancelable `dees-stepper` flow with a progress step and a ready step.
```typescript
const updater = await DeesUpdater.createAndShow({
currentVersion: '3.79.0',
updatedVersion: '3.80.0',
moreInfoUrl: 'https://code.foss.global/design.estate/dees-catalog',
changelogUrl: 'https://code.foss.global/design.estate/dees-catalog/-/blob/main/changelog.md',
successAction: 'reload',
successDelayMs: 10000,
});
updater.updateProgress({
percentage: 35,
statusText: 'Downloading signed bundle...',
terminalLines: ['Checking release manifest', 'Downloading signed bundle']
});
updater.appendProgressLine('Verifying checksum');
updater.updateProgress({ percentage: 72, statusText: 'Verifying checksum...' });
await updater.markUpdateReady();
```
After `markUpdateReady()`, the updater switches to a second countdown step with a determinate progress bar and runs the configured success action when the timer reaches zero.
---
### Workspace / IDE Components 💻

View File

@@ -0,0 +1,167 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as deesCatalog from '../ts_web/index.js';
import type {
Column,
ISortDescriptor,
} from '../ts_web/elements/00group-dataview/dees-table/index.js';
interface ITestRow {
id: string;
score: number;
label: string;
}
const testColumns: Column<ITestRow>[] = [
{ key: 'id', header: 'ID' },
{ key: 'score', header: 'Score' },
{ key: 'label', header: 'Label' },
];
const scoreSort: ISortDescriptor[] = [{ key: 'score', dir: 'desc' }];
const waitForNextFrame = async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
};
const waitForMacrotask = async () => {
await new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), 0);
});
};
const settleTable = async (table: deesCatalog.DeesTable<ITestRow>) => {
await table.updateComplete;
await waitForNextFrame();
await waitForMacrotask();
await table.updateComplete;
};
const createRows = (iteration: number): ITestRow[] => {
const cycle = iteration % 3;
if (cycle === 0) {
return [
{ id: 'alpha', score: 60, label: `Alpha ${iteration}` },
{ id: 'beta', score: 20, label: `Beta ${iteration}` },
{ id: 'gamma', score: 40, label: `Gamma ${iteration}` },
];
}
if (cycle === 1) {
return [
{ id: 'alpha', score: 30, label: `Alpha ${iteration}` },
{ id: 'beta', score: 70, label: `Beta ${iteration}` },
{ id: 'gamma', score: 50, label: `Gamma ${iteration}` },
];
}
return [
{ id: 'alpha', score: 55, label: `Alpha ${iteration}` },
{ id: 'beta', score: 35, label: `Beta ${iteration}` },
{ id: 'gamma', score: 75, label: `Gamma ${iteration}` },
];
};
const createTable = (
rows: ITestRow[],
highlightUpdates: 'none' | 'flash'
): deesCatalog.DeesTable<ITestRow> => {
const table = new deesCatalog.DeesTable<ITestRow>();
table.searchable = false;
table.columns = testColumns;
table.rowKey = 'id';
table.sortBy = scoreSort;
table.highlightUpdates = highlightUpdates;
table.data = rows;
document.body.appendChild(table);
return table;
};
const countComments = (root: Node): number => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
let count = 0;
while (walker.nextNode()) count++;
return count;
};
const getBodyRows = (table: deesCatalog.DeesTable<ITestRow>): HTMLTableRowElement[] =>
Array.from(
table.shadowRoot?.querySelectorAll('tbody tr[data-row-idx]') ?? []
) as HTMLTableRowElement[];
const getRenderedRowIds = (table: deesCatalog.DeesTable<ITestRow>): string[] =>
getBodyRows(table).map((row) => row.cells[0]?.textContent?.trim() ?? '');
const getRenderedRowMap = (
table: deesCatalog.DeesTable<ITestRow>
): Map<string, HTMLTableRowElement> => {
const rowMap = new Map<string, HTMLTableRowElement>();
for (const row of getBodyRows(table)) {
const rowId = row.cells[0]?.textContent?.trim() ?? '';
if (rowId) rowMap.set(rowId, row);
}
return rowMap;
};
tap.test('dees-table avoids repeated width measurement and comment growth on live updates', async () => {
const table = new deesCatalog.DeesTable<ITestRow>();
let widthMeasureCalls = 0;
const originalDetermineColumnWidths = table.determineColumnWidths.bind(table);
table.determineColumnWidths = (async () => {
widthMeasureCalls++;
await originalDetermineColumnWidths();
}) as typeof table.determineColumnWidths;
table.searchable = false;
table.columns = testColumns;
table.rowKey = 'id';
table.sortBy = scoreSort;
table.highlightUpdates = 'none';
table.data = createRows(0);
document.body.appendChild(table);
try {
await settleTable(table);
const initialWidthMeasureCalls = widthMeasureCalls;
const initialCommentCount = countComments(table.shadowRoot!);
expect(initialWidthMeasureCalls).toBeGreaterThan(0);
for (let iteration = 1; iteration <= 10; iteration++) {
table.data = createRows(iteration);
await settleTable(table);
}
expect(widthMeasureCalls).toEqual(initialWidthMeasureCalls);
expect(countComments(table.shadowRoot!)).toEqual(initialCommentCount);
} finally {
table.remove();
}
});
tap.test('dees-table reuses row DOM while flashing live-sorted updates', async () => {
const table = createTable(createRows(0), 'flash');
try {
await settleTable(table);
const initialRowMap = getRenderedRowMap(table);
table.data = createRows(1);
await settleTable(table);
const updatedRowMap = getRenderedRowMap(table);
expect(getRenderedRowIds(table)).toEqual(['beta', 'gamma', 'alpha']);
expect(updatedRowMap.get('alpha')).toEqual(initialRowMap.get('alpha'));
expect(updatedRowMap.get('beta')).toEqual(initialRowMap.get('beta'));
expect(updatedRowMap.get('gamma')).toEqual(initialRowMap.get('gamma'));
} finally {
table.remove();
}
});
export default tap.start();

View File

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

View File

@@ -293,9 +293,9 @@ export class DeesTable<T> extends DeesElement {
private accessor __floatingActive: boolean = false;
// ─── Flash-on-update state (only populated when highlightUpdates === 'flash') ──
/** rowId → set of colKey strings currently flashing. */
/** rowId → (colKey → flash token) for cells currently flashing. */
@state()
private accessor __flashingCells: Map<string, Set<string>> = new Map();
private accessor __flashingCells: Map<string, Map<string, number>> = new Map();
/** rowId → (colKey → last-seen resolved cell value). Populated per diff pass. */
private __prevSnapshot?: Map<string, Map<string, unknown>>;
@@ -303,7 +303,7 @@ export class DeesTable<T> extends DeesElement {
/** Single shared timer that clears __flashingCells after highlightDuration ms. */
private __flashClearTimer?: ReturnType<typeof setTimeout>;
/** Monotonic counter bumped each flash batch so directives.keyed recreates the cell node and restarts the animation. */
/** Monotonic counter bumped per flash batch so only changed cells restart their animation. */
private __flashTick: number = 0;
/** One-shot console.warn gate for missing rowKey in flash mode. */
@@ -317,7 +317,7 @@ export class DeesTable<T> extends DeesElement {
columns: any;
augment: boolean;
displayFunction: any;
data: any;
displayShapeKey: string;
out: Column<T>[];
};
private __memoViewData?: {
@@ -329,8 +329,13 @@ export class DeesTable<T> extends DeesElement {
effectiveColumns: Column<T>[];
out: T[];
};
/** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */
private __columnsSizedFor?: { data: any; columns: any };
/** Tracks the layout inputs that `determineColumnWidths()` last sized for. */
private __columnsSizedFor?: {
effectiveColumns: Column<T>[];
showSelectionCheckbox: boolean;
inRowActionCount: number;
table: HTMLTableElement;
};
// ─── Virtualization state ────────────────────────────────────────────
/** Estimated row height (px). Measured once from the first rendered row. */
@@ -409,15 +414,7 @@ export class DeesTable<T> extends DeesElement {
const view: T[] = (this as any)._lastViewData ?? [];
const item = view.find((r) => this.getRowId(r) === this.__focusedCell!.rowId);
if (!item) return;
const allCols: Column<T>[] =
Array.isArray(this.columns) && this.columns.length > 0
? computeEffectiveColumnsFn(
this.columns,
this.augmentFromDisplayFunction,
this.displayFunction,
this.data
)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const allCols = this.__getEffectiveColumns();
const col = allCols.find((c) => String(c.key) === this.__focusedCell!.colKey);
if (!col || !this.__isColumnEditable(col)) return;
eventArg.preventDefault();
@@ -469,15 +466,24 @@ export class DeesTable<T> extends DeesElement {
* that affect it. Avoids re-running `computeEffectiveColumnsFn` /
* `computeColumnsFromDisplayFunctionFn` on every Lit update.
*/
private __getDisplayFunctionShapeKey(): string {
if (!this.data || this.data.length === 0) return '';
const firstTransformedItem = this.displayFunction(this.data[0]) ?? {};
return Object.keys(firstTransformedItem).join('\u0000');
}
private __getEffectiveColumns(): Column<T>[] {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const displayShapeKey = !usingColumns || this.augmentFromDisplayFunction
? this.__getDisplayFunctionShapeKey()
: '';
const cache = this.__memoEffectiveCols;
if (
cache &&
cache.columns === this.columns &&
cache.augment === this.augmentFromDisplayFunction &&
cache.displayFunction === this.displayFunction &&
cache.data === this.data
cache.displayShapeKey === displayShapeKey
) {
return cache.out;
}
@@ -493,7 +499,7 @@ export class DeesTable<T> extends DeesElement {
columns: this.columns,
augment: this.augmentFromDisplayFunction,
displayFunction: this.displayFunction,
data: this.data,
displayShapeKey,
out,
};
return out;
@@ -543,6 +549,9 @@ export class DeesTable<T> extends DeesElement {
public render(): TemplateResult {
const effectiveColumns = this.__getEffectiveColumns();
const viewData = this.__getViewData(effectiveColumns);
const headerActions = this.getActionsForType('header');
const footerActions = this.getActionsForType('footer');
const inRowActions = this.getActionsForType('inRow');
(this as any)._lastViewData = viewData;
// Virtualization slice — only the rows in `__virtualRange` actually
@@ -572,29 +581,22 @@ export class DeesTable<T> extends DeesElement {
<div class="heading heading2">${this.heading2}</div>
</div>
<div class="headerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type?.includes('header')) continue;
resultArray.push(
html`<div
class="headerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
${headerActions.map(
(action) => html`<div
class="headerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
)}
</div>
</div>
<div class="headingSeparation"></div>
@@ -658,11 +660,11 @@ export class DeesTable<T> extends DeesElement {
: html``}
${directives.repeat(
renderRows,
(itemArg, sliceIdx) => `${this.getRowId(itemArg)}::${renderStart + sliceIdx}`,
(itemArg) => this.getRowId(itemArg),
(itemArg, sliceIdx) => {
const rowIndex = renderStart + sliceIdx;
const rowId = this.getRowId(itemArg);
const flashSet = this.__flashingCells.get(rowId);
const flashTokens = this.__flashingCells.get(rowId);
return html`
<tr
data-row-idx=${rowIndex}
@@ -694,7 +696,8 @@ export class DeesTable<T> extends DeesElement {
const isEditing =
this.__editingCell?.rowId === rowId &&
this.__editingCell?.colKey === editKey;
const isFlashing = !!flashSet?.has(editKey);
const flashToken = flashTokens?.get(editKey);
const isFlashing = flashToken !== undefined;
const useFlashBorder = isFlashing && !!col.flashBorder;
const cellClasses = [
isEditable ? 'editable' : '',
@@ -720,7 +723,7 @@ export class DeesTable<T> extends DeesElement {
>
${isFlashing
? directives.keyed(
`${rowId}:${editKey}:${this.__flashTick}`,
`${rowId}:${editKey}:${flashToken}`,
innerHtml
)
: innerHtml}
@@ -728,11 +731,11 @@ export class DeesTable<T> extends DeesElement {
`;
})}
${(() => {
if (this.dataActions && this.dataActions.length > 0) {
if (inRowActions.length > 0) {
return html`
<td class="actionsCol">
<div class="actionsContainer">
${this.getActionsForType('inRow').map(
${inRowActions.map(
(actionArg) => html`
<div
class="action"
@@ -780,29 +783,22 @@ export class DeesTable<T> extends DeesElement {
selected
</div>
<div class="footerActions">
${directives.resolveExec(async () => {
const resultArray: TemplateResult[] = [];
for (const action of this.dataActions) {
if (!action.type?.includes('footer')) continue;
resultArray.push(
html`<div
class="footerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
);
}
return resultArray;
})}
${footerActions.map(
(action) => html`<div
class="footerAction"
@click=${() => {
action.actionFunc({
item: this.selectedDataRow,
table: this,
});
}}
>
${action.iconName
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
${action.name}`
: action.name}
</div>`
)}
</div>
</div>
</dees-tile>
@@ -1160,7 +1156,7 @@ export class DeesTable<T> extends DeesElement {
/**
* Measures the height of the first rendered body row and stores it for
* subsequent virtualization math. Idempotent — only measures once per
* `data`/`columns` pair (cleared in `updated()` when those change).
* rendered table layout (cleared in `updated()` when that layout changes).
*/
private __measureRowHeight() {
if (!this.virtualized || this.__rowHeightMeasured) return;
@@ -1426,20 +1422,16 @@ export class DeesTable<T> extends DeesElement {
if (newlyFlashing.size === 0) return;
// Merge with any in-flight flashes from a rapid second update so a cell
// that changes twice before its animation ends gets a single clean
// restart (via __flashTick / directives.keyed) instead of stacking.
// that changes twice before its animation ends gets a clean restart,
// while unrelated cells keep their existing DOM subtree.
const flashToken = ++this.__flashTick;
const nextFlashingCells = new Map(this.__flashingCells);
for (const [rowId, cols] of newlyFlashing) {
const existing = this.__flashingCells.get(rowId);
if (existing) {
for (const c of cols) existing.add(c);
} else {
this.__flashingCells.set(rowId, cols);
}
const existing = new Map(nextFlashingCells.get(rowId) ?? []);
for (const colKey of cols) existing.set(colKey, flashToken);
nextFlashingCells.set(rowId, existing);
}
this.__flashTick++;
// Reactivity nudge: we've mutated the Map in place, so give Lit a fresh
// reference so the @state change fires for render.
this.__flashingCells = new Map(this.__flashingCells);
this.__flashingCells = nextFlashingCells;
if (this.__flashClearTimer) clearTimeout(this.__flashClearTimer);
this.__flashClearTimer = setTimeout(() => {
this.__flashingCells = new Map();
@@ -1449,6 +1441,9 @@ export class DeesTable<T> extends DeesElement {
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
super.updated(changedProperties);
const effectiveColumns = this.__getEffectiveColumns();
const currentTable = this.shadowRoot?.querySelector('table') ?? null;
const inRowActionCount = this.getActionsForType('inRow').length;
// Feed highlightDuration into the CSS variable so JS and CSS stay in
// sync via a single source of truth.
@@ -1456,15 +1451,23 @@ export class DeesTable<T> extends DeesElement {
this.style.setProperty('--dees-table-flash-duration', `${this.highlightDuration}ms`);
}
// Only re-measure column widths when the data or schema actually changed
// (or on first paint). `determineColumnWidths` is the single biggest
// first-paint cost — it forces multiple layout flushes per row.
const dataOrColsChanged =
!this.__columnsSizedFor ||
this.__columnsSizedFor.data !== this.data ||
this.__columnsSizedFor.columns !== this.columns;
if (dataOrColsChanged) {
this.__columnsSizedFor = { data: this.data, columns: this.columns };
// Only re-measure column widths when layout-affecting inputs changed or
// when a new <table> element was rendered after previously having none.
const columnLayoutChanged =
!!currentTable && (
!this.__columnsSizedFor ||
this.__columnsSizedFor.effectiveColumns !== effectiveColumns ||
this.__columnsSizedFor.showSelectionCheckbox !== this.showSelectionCheckbox ||
this.__columnsSizedFor.inRowActionCount !== inRowActionCount ||
this.__columnsSizedFor.table !== currentTable
);
if (currentTable && columnLayoutChanged) {
this.__columnsSizedFor = {
effectiveColumns,
showSelectionCheckbox: this.showSelectionCheckbox,
inRowActionCount,
table: currentTable,
};
this.determineColumnWidths();
// Force re-measure of row height; structure may have changed.
this.__rowHeightMeasured = false;
@@ -1502,7 +1505,7 @@ export class DeesTable<T> extends DeesElement {
if (
!this.fixedHeight &&
this.data.length > 0 &&
(this.__floatingActive || dataOrColsChanged)
(this.__floatingActive || columnLayoutChanged)
) {
this.__syncFloatingHeader();
}
@@ -1804,10 +1807,7 @@ export class DeesTable<T> extends DeesElement {
* Used by the modal helper to render human-friendly labels.
*/
private _lookupColumnByKey(key: string): Column<T> | undefined {
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
const effective = usingColumns
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const effective = this.__getEffectiveColumns();
return effective.find((c) => String(c.key) === key);
}
@@ -2543,9 +2543,7 @@ export class DeesTable<T> extends DeesElement {
const view: T[] = (this as any)._lastViewData ?? [];
if (view.length === 0) return;
// Recompute editable columns from the latest effective set.
const allCols: Column<T>[] = Array.isArray(this.columns) && this.columns.length > 0
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
const allCols = this.__getEffectiveColumns();
const editableCols = this.__editableColumns(allCols);
if (editableCols.length === 0) return;

View File

@@ -1,11 +1,245 @@
import { html } from '@design.estate/dees-element';
import { DeesProgressbar } from '../dees-progressbar/dees-progressbar.js';
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesProgressbar } from './dees-progressbar.js';
export const demoFunc = () => {
const terminalSnapshots = [
['Resolving workspace packages'],
['Resolving workspace packages', 'Downloading ui-assets.tar.gz'],
['Resolving workspace packages', 'Downloading ui-assets.tar.gz', 'Verifying checksum'],
['Resolving workspace packages', 'Downloading ui-assets.tar.gz', 'Verifying checksum', 'Extracting release bundle'],
['Resolving workspace packages', 'Downloading ui-assets.tar.gz', 'Verifying checksum', 'Extracting release bundle', 'Restarting application'],
];
const getUploadStatus = (percentage: number): string => {
if (percentage >= 100) {
return 'Upload complete. Finalizing package manifest...';
}
if (percentage >= 82) {
return 'Verifying checksums before handoff...';
}
if (percentage >= 55) {
return 'Uploading thumbnails to edge cache...';
}
if (percentage >= 25) {
return 'Streaming source files to the remote worker...';
}
return 'Preparing archive and dependency graph...';
};
return html`
<dees-progressbar
.percentage=${50}
></dees-progressbar>
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
const liveProgressbar = elementArg.querySelector('#live-progress') as DeesProgressbar | null;
const terminalProgressbar = elementArg.querySelector('#terminal-progress') as DeesProgressbar | null;
const demoElement = elementArg as HTMLElement & {
__progressbarDemoIntervalId?: number;
};
if (!liveProgressbar || !terminalProgressbar) {
return;
}
if (demoElement.__progressbarDemoIntervalId) {
window.clearInterval(demoElement.__progressbarDemoIntervalId);
}
let livePercentage = 12;
let terminalSnapshotIndex = 0;
const updateDemo = () => {
liveProgressbar.percentage = livePercentage;
liveProgressbar.statusText = getUploadStatus(livePercentage);
terminalProgressbar.terminalLines = [...terminalSnapshots[terminalSnapshotIndex]];
terminalProgressbar.percentage = Math.min(100, (terminalSnapshotIndex + 1) * 20);
terminalProgressbar.indeterminate = terminalSnapshotIndex < terminalSnapshots.length - 1;
livePercentage = livePercentage >= 100 ? 12 : Math.min(100, livePercentage + 11);
terminalSnapshotIndex = terminalSnapshotIndex >= terminalSnapshots.length - 1 ? 0 : terminalSnapshotIndex + 1;
};
updateDemo();
demoElement.__progressbarDemoIntervalId = window.setInterval(updateDemo, 1400);
}}>
<style>
${css`
.demoBox {
position: relative;
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
width: 100%;
height: 100%;
box-sizing: border-box;
padding: 40px;
display: flex;
flex-direction: column;
gap: 24px;
}
.demoIntro {
max-width: 720px;
color: ${cssManager.bdTheme('hsl(215 20% 30%)', 'hsl(215 18% 76%)')};
font-size: 14px;
line-height: 1.6;
}
.showcaseGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px;
}
.showcaseCard {
background: ${cssManager.bdTheme('rgba(255,255,255,0.78)', 'rgba(255,255,255,0.04)')};
border: 1px solid ${cssManager.bdTheme('hsl(210 22% 86%)', 'hsl(210 10% 18%)')};
border-radius: 16px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
backdrop-filter: blur(8px);
}
.showcaseCard.wide {
grid-column: span 2;
}
.showcaseCard h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.showcaseCard p {
margin: 0;
color: ${cssManager.bdTheme('hsl(215 14% 40%)', 'hsl(215 10% 66%)')};
font-size: 13px;
line-height: 1.55;
}
.progressStack {
display: flex;
flex-direction: column;
gap: 14px;
}
.codeLabel {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 11px;
color: ${cssManager.bdTheme('hsl(215 14% 44%)', 'hsl(215 10% 70%)')};
letter-spacing: 0.03em;
text-transform: uppercase;
}
@media (max-width: 900px) {
.demoBox {
padding: 24px;
}
.showcaseCard.wide {
grid-column: span 1;
}
}
`}
</style>
<div class="demoBox">
<div class="demoIntro">
<code>dees-progressbar</code> can now pair a classic progress bar with a fixed-height status area. Use simple status text for clear user-facing updates or switch to terminal-like lines when you want recent steps to stay visible without causing layout jumps.
</div>
<div class="showcaseGrid">
<section class="showcaseCard">
<div class="codeLabel">Determinate</div>
<h3>Percentage plus current task</h3>
<p>Use a label, a percentage, and one short status line when the work is measurable.</p>
<div class="progressStack">
<dees-progressbar
label="Media upload"
.percentage=${68}
statusText="Uploading thumbnails to edge cache..."
.statusRows=${2}
></dees-progressbar>
<dees-progressbar
label="Asset sync"
.percentage=${100}
statusText="All files are synced and available."
.statusRows=${2}
></dees-progressbar>
</div>
</section>
<section class="showcaseCard">
<div class="codeLabel">Indeterminate</div>
<h3>Spinner-style text indicator</h3>
<p>When there is no trustworthy percentage yet, keep the bar moving and let the text explain what is happening.</p>
<div class="progressStack">
<dees-progressbar
label="Dependency install"
.indeterminate=${true}
statusText="Downloading package metadata..."
.statusRows=${2}
></dees-progressbar>
<dees-progressbar
label="Queued job"
.percentage=${32}
.showPercentage=${false}
statusText="Waiting for a worker slot to become available..."
.statusRows=${2}
></dees-progressbar>
</div>
</section>
<section class="showcaseCard wide">
<div class="codeLabel">Terminal Lines</div>
<h3>Fixed-height terminal-style status output</h3>
<p>The panel stays the same height while the latest step stays visible. This is useful for update flows, downloads, and staged background work.</p>
<dees-progressbar
id="terminal-progress"
label="Release bundle"
.percentage=${20}
.indeterminate=${true}
.statusRows=${4}
.terminalLines=${terminalSnapshots[0]}
></dees-progressbar>
</section>
<section class="showcaseCard">
<div class="codeLabel">Live Demo</div>
<h3>Updating percentage and text together</h3>
<p>A single component can express both how far the job is and which phase is currently active.</p>
<dees-progressbar
id="live-progress"
label="Customer export"
.percentage=${12}
statusText="Preparing archive and dependency graph..."
.statusRows=${2}
></dees-progressbar>
</section>
<section class="showcaseCard">
<div class="codeLabel">Compatibility</div>
<h3>Legacy <code>value</code> and <code>progress</code> inputs</h3>
<p>Existing usages can keep passing percentages directly or normalized progress values from 0 to 1.</p>
<div class="progressStack">
<dees-progressbar
label="From value"
.value=${75}
statusText="Migrating existing readme-style usage..."
.statusRows=${2}
></dees-progressbar>
<dees-progressbar
label="From progress"
.progress=${0.42}
.showPercentage=${false}
statusText="Rendering normalized progress input..."
.statusRows=${2}
></dees-progressbar>
</div>
</section>
</div>
</div>
</dees-demowrapper>
`;
}
};

View File

@@ -1,4 +1,3 @@
import * as plugins from '../../00plugins.js';
import * as colors from '../../00colors.js';
import { demoFunc } from './dees-progressbar.demo.js';
import {
@@ -6,94 +5,342 @@ import {
html,
DeesElement,
property,
type TemplateResult,
cssManager,
css,
type CSSResult,
unsafeCSS,
unsafeHTML,
state,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
import { themeDefaultStyles } from '../../00theme.js';
declare global {
interface HTMLElementTagNameMap {
'dees-progressbar': DeesProgressbar;
}
}
@customElement('dees-progressbar')
export class DeesProgressbar extends DeesElement {
// STATIC
public static demo = demoFunc;
public static demoGroups = ['Feedback'];
// INSTANCE
@property({
type: Number,
})
accessor percentage = 0;
// `value` and `progress` keep existing readme/internal usages working.
@property({
type: Number,
})
accessor value: number | null = null;
@property({
type: Number,
})
accessor progress: number | null = null;
@property({
type: String,
})
accessor label = '';
@property({
type: String,
})
accessor statusText = '';
@property({
type: Array,
})
accessor terminalLines: string[] = [];
@property({
type: Number,
})
accessor statusRows = 3;
@property({
type: Boolean,
})
accessor indeterminate = false;
@property({
type: Boolean,
})
accessor showPercentage = true;
@state()
accessor activeSpinnerFrame = 0;
private spinnerIntervalId: number | null = null;
private readonly spinnerFrames = ['|', '/', '-', '\\'];
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
css`
/* TODO: Migrate hardcoded values to --dees-* CSS variables */
:host {
display: block;
color: ${cssManager.bdTheme(colors.bright.text, colors.dark.text)};
}
.progressBarContainer {
padding: 8px;
min-width: 200px;
padding: 8px;
box-sizing: border-box;
}
.progressHeader {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}
.progressLabel {
font-size: 14px;
font-weight: 500;
line-height: 1.3;
}
.progressValue {
font-size: 12px;
line-height: 1.3;
color: ${cssManager.bdTheme('hsl(215 15% 40%)', 'hsl(215 15% 70%)')};
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.progressBar {
background: ${cssManager.bdTheme('#eeeeeb', '#444')};
height: 8px;
position: relative;
overflow: hidden;
width: 100%;
border-radius: 4px;
border-top: 0.5px solid ${cssManager.bdTheme('none', '#555')};
height: 8px;
border-radius: 999px;
background: ${cssManager.bdTheme('#eeeeeb', '#444')};
border-top: 0.5px solid ${cssManager.bdTheme('transparent', '#555')};
}
.progressBarFill {
height: 100%;
border-radius: inherit;
background: ${cssManager.bdTheme(colors.dark.blueActive, colors.bright.blueActive)};
height: 8px;
margin-top: -0.5px;
transition: 0.2s width;
border-radius: 4px;
width: 0px;
border-top: 0.5 solid ${cssManager.bdTheme('none', '#398fff')};
transition: width 0.2s ease;
}
.progressText {
padding: 8px;
.progressBarFill.indeterminate {
width: 34%;
transition: none;
animation: indeterminateSlide 1.2s ease-in-out infinite;
}
.statusPanel {
margin-top: 10px;
height: calc(var(--status-rows, 3) * 1.35em + 16px);
min-height: calc(var(--status-rows, 3) * 1.35em + 16px);
padding: 8px 10px;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('hsl(210 20% 86%)', 'hsl(210 10% 26%)')};
background: ${cssManager.bdTheme('hsl(210 33% 98%)', 'hsl(220 20% 10%)')};
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.35;
overflow: hidden;
}
.statusTextRow,
.terminalLine {
display: flex;
align-items: flex-start;
gap: 8px;
min-height: 1.35em;
}
.terminalScroller {
height: 100%;
overflow: auto;
}
.terminalScroller::-webkit-scrollbar {
width: 6px;
}
.terminalScroller::-webkit-scrollbar-thumb {
background: ${cssManager.bdTheme('hsl(215 18% 78%)', 'hsl(215 10% 34%)')};
border-radius: 999px;
}
.terminalScroller::-webkit-scrollbar-track {
background: transparent;
}
.linePrefix {
width: 1ch;
flex: 0 0 1ch;
color: ${cssManager.bdTheme(colors.dark.blueActive, colors.bright.blueActive)};
text-align: center;
}
`
.lineText {
flex: 1;
min-width: 0;
color: ${cssManager.bdTheme('hsl(220 15% 25%)', 'hsl(210 15% 86%)')};
white-space: pre-wrap;
word-break: break-word;
}
.terminalLine:not(.current) .lineText {
color: ${cssManager.bdTheme('hsl(215 12% 42%)', 'hsl(215 12% 63%)')};
}
@keyframes indeterminateSlide {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
`,
];
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.syncSpinnerState();
}
public async disconnectedCallback(): Promise<void> {
this.stopSpinner();
await super.disconnectedCallback();
}
public updated(changedProperties: Map<string | number | symbol, unknown>): void {
super.updated(changedProperties);
this.syncSpinnerState();
if (changedProperties.has('terminalLines') && this.terminalLines.length > 0) {
this.scrollTerminalToBottom();
}
}
public render() {
const effectivePercentage = this.getEffectivePercentage();
const showHeader = Boolean(this.label) || (this.showPercentage && !this.indeterminate);
const hasTerminalLines = this.terminalLines.length > 0;
const hasStatusContent = hasTerminalLines || this.statusText.trim().length > 0;
const renderedRows = this.getRenderedStatusRows();
const spinnerFrame = this.spinnerFrames[this.activeSpinnerFrame] ?? this.spinnerFrames[0];
return html`
<div class="progressBarContainer">
${showHeader ? html`
<div class="progressHeader">
<div class="progressLabel">${this.label}</div>
${this.showPercentage && !this.indeterminate ? html`
<div class="progressValue">${this.formatPercentage(effectivePercentage)}%</div>
` : ''}
</div>
` : ''}
<div class="progressBar">
<div class="progressBarFill"></div>
<div class="progressText">
${this.percentage}%
<div>
<div
class="progressBarFill ${this.indeterminate ? 'indeterminate' : ''}"
style="${this.indeterminate ? '' : `width: ${effectivePercentage}%;`}"
></div>
</div>
${hasStatusContent ? html`
<div
class="statusPanel"
style="--status-rows: ${renderedRows};"
aria-live="polite"
aria-atomic="true"
>
${hasTerminalLines ? html`
<div class="terminalScroller">
${this.terminalLines.map((line, index) => {
const isCurrentLine = index === this.terminalLines.length - 1;
const prefix = this.indeterminate && isCurrentLine ? spinnerFrame : '>';
return html`
<div class="terminalLine ${isCurrentLine ? 'current' : ''}">
<span class="linePrefix">${prefix}</span>
<span class="lineText">${line}</span>
</div>
`;
})}
</div>
` : html`
<div class="statusTextRow">
<span class="linePrefix">${this.indeterminate ? spinnerFrame : '>'}</span>
<span class="lineText">${this.statusText}</span>
</div>
`}
</div>
` : ''}
</div>
`
`;
}
firstUpdated (_changedProperties: Map<string | number | symbol, unknown>): void {
super.firstUpdated(_changedProperties);
this.updateComplete.then(() => {
this.updatePercentage();
private getEffectivePercentage(): number {
if (typeof this.value === 'number' && Number.isFinite(this.value)) {
return this.clampPercentage(this.value);
}
if (typeof this.progress === 'number' && Number.isFinite(this.progress)) {
const normalizedProgress = this.progress >= 0 && this.progress <= 1
? this.progress * 100
: this.progress;
return this.clampPercentage(normalizedProgress);
}
return this.clampPercentage(this.percentage);
}
private getRenderedStatusRows(): number {
const rows = Number.isFinite(this.statusRows) ? Math.floor(this.statusRows) : 3;
return Math.max(1, rows);
}
private clampPercentage(input: number): number {
return Math.max(0, Math.min(100, input));
}
private formatPercentage(input: number): string {
return Number.isInteger(input) ? `${input}` : input.toFixed(1).replace(/\.0$/, '');
}
private syncSpinnerState(): void {
const shouldAnimate = this.indeterminate && (this.statusText.trim().length > 0 || this.terminalLines.length > 0);
if (shouldAnimate && this.spinnerIntervalId === null) {
this.spinnerIntervalId = window.setInterval(() => {
this.activeSpinnerFrame = (this.activeSpinnerFrame + 1) % this.spinnerFrames.length;
}, 120);
return;
}
if (!shouldAnimate) {
this.stopSpinner();
}
}
private stopSpinner(): void {
if (this.spinnerIntervalId !== null) {
window.clearInterval(this.spinnerIntervalId);
this.spinnerIntervalId = null;
}
this.activeSpinnerFrame = 0;
}
private scrollTerminalToBottom(): void {
const terminalScroller = this.shadowRoot?.querySelector('.terminalScroller') as HTMLElement | null;
if (!terminalScroller) {
return;
}
window.requestAnimationFrame(() => {
terminalScroller.scrollTop = terminalScroller.scrollHeight;
});
}
public async updatePercentage() {
const progressBarFill = this.shadowRoot!.querySelector('.progressBarFill') as HTMLElement;
progressBarFill.style.width = `${this.percentage}%`;
}
updated(){
this.updatePercentage();
}
}
}

View File

@@ -1,7 +1,45 @@
import { html } from '@design.estate/dees-element';
import { DeesStepper, type IStep } from './dees-stepper.js';
const demoSteps: IStep[] = [
const waitForProgressTick = async (timeoutArg: number, signal?: AbortSignal): Promise<boolean> => {
return new Promise((resolve) => {
let completed = false;
const finish = (result: boolean) => {
if (completed) {
return;
}
completed = true;
if (signal) {
signal.removeEventListener('abort', handleAbort);
}
resolve(result);
};
const handleAbort = () => {
window.clearTimeout(timeoutId);
finish(false);
};
const timeoutId = window.setTimeout(() => {
finish(true);
}, timeoutArg);
if (signal) {
signal.addEventListener('abort', handleAbort, { once: true });
}
});
};
const createContinueMenuOptions = (labelArg = 'Continue') => [
{
name: labelArg,
action: async (stepper?: DeesStepper) => stepper?.goNext(),
},
];
const createDemoSteps = (): IStep[] => [
{
title: 'Account Setup',
content: html`
@@ -10,9 +48,7 @@ const demoSteps: IStep[] = [
<dees-input-text key="password" label="Create Password" type="password" required></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
menuOptions: createContinueMenuOptions(),
},
{
title: 'Profile Details',
@@ -22,9 +58,7 @@ const demoSteps: IStep[] = [
<dees-input-text key="lastName" label="Last Name" required></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
menuOptions: createContinueMenuOptions(),
},
{
title: 'Contact Information',
@@ -34,9 +68,74 @@ const demoSteps: IStep[] = [
<dees-input-text key="company" label="Company"></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
menuOptions: createContinueMenuOptions(),
},
{
title: 'Provision Workspace',
content: html`
<dees-panel>
<p>
We are creating your starter workspace, applying your onboarding choices,
and preparing a live preview. This step moves forward automatically when
the environment is ready.
</p>
</dees-panel>
`,
progressStep: {
label: 'Workspace setup',
percentage: 8,
indeterminate: true,
statusRows: 4,
statusText: 'Allocating a clean workspace...',
terminalLines: ['Allocating a clean workspace'],
},
validationFunc: async (stepper, _htmlElement, signal) => {
const progressFrames = [
{ line: 'Allocating a clean workspace', percentage: 8, delay: 500 },
{ line: 'Syncing account preferences', percentage: 24, delay: 650 },
{ line: 'Installing selected integrations', percentage: 47, delay: 700 },
{ line: 'Generating starter project files', percentage: 71, delay: 650 },
{ line: 'Booting the live preview environment', percentage: 92, delay: 700 },
];
stepper.resetProgressStep();
for (const [index, progressFrame] of progressFrames.entries()) {
if (signal?.aborted) {
return;
}
if (index === 0) {
stepper.updateProgressStep({
percentage: progressFrame.percentage,
indeterminate: true,
statusText: `${progressFrame.line}...`,
terminalLines: [progressFrame.line],
});
} else {
stepper.appendProgressStepLine(progressFrame.line);
stepper.updateProgressStep({
percentage: progressFrame.percentage,
indeterminate: true,
statusText: `${progressFrame.line}...`,
});
}
const completed = await waitForProgressTick(progressFrame.delay, signal);
if (!completed) {
return;
}
}
stepper.appendProgressStepLine('Workspace ready');
stepper.updateProgressStep({
percentage: 100,
indeterminate: false,
statusText: 'Workspace ready.',
});
await waitForProgressTick(350, signal);
},
},
{
title: 'Team Size',
@@ -55,9 +154,7 @@ const demoSteps: IStep[] = [
></dees-input-dropdown>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
menuOptions: createContinueMenuOptions(),
},
{
title: 'Goals',
@@ -75,52 +172,31 @@ const demoSteps: IStep[] = [
></dees-input-multitoggle>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
},
{
title: 'Brand Preferences',
content: html`
<dees-form>
<dees-input-text key="brandColor" label="Primary brand color"></dees-input-text>
<dees-input-text key="tone" label="Preferred tone (e.g. friendly, formal)"></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
},
{
title: 'Integrations',
content: html`
<dees-form>
<dees-input-list
key="integrations"
label="Integrations in use"
placeholder="Add integration"
></dees-input-list>
</dees-form>
`,
menuOptions: [
{ name: 'Continue', action: async (stepper) => stepper!.goNext() },
],
menuOptions: createContinueMenuOptions(),
},
{
title: 'Review & Launch',
content: html`
<dees-panel>
<p>Almost there! Review your selections and launch whenever you're ready.</p>
<p>
Your workspace is ready. Review the collected details and launch when
you are ready to start.
</p>
</dees-panel>
`,
menuOptions: [
{ name: 'Launch', action: async (stepper) => stepper!.goNext() },
{
name: 'Launch',
action: async (stepper?: DeesStepper) => {
if (stepper?.overlay) {
await stepper.destroy();
}
},
},
],
},
];
const cloneSteps = (): IStep[] => demoSteps.map((step) => ({ ...step }));
export const stepperDemo = () => html`
<div style="position: absolute; inset: 0;">
<div
@@ -128,10 +204,10 @@ export const stepperDemo = () => html`
>
<dees-button
@click=${async () => {
await DeesStepper.createAndShow({ steps: cloneSteps() });
await DeesStepper.createAndShow({ steps: createDemoSteps() });
}}
>Open stepper as overlay</dees-button>
</div>
<dees-stepper .steps=${cloneSteps()}></dees-stepper>
<dees-stepper .steps=${createDemoSteps()}></dees-stepper>
</div>
`;

View File

@@ -5,8 +5,6 @@ import {
customElement,
html,
css,
unsafeCSS,
type CSSResult,
cssManager,
property,
type TemplateResult,
@@ -20,16 +18,33 @@ import { zIndexRegistry } from '../../00zindex.js';
import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
import type { DeesForm } from '../../00group-form/dees-form/dees-form.js';
import '../../00group-feedback/dees-progressbar/dees-progressbar.js';
import '../dees-tile/dees-tile.js';
export interface IStepProgressState {
label?: string;
percentage?: number;
indeterminate?: boolean;
showPercentage?: boolean;
statusText?: string;
terminalLines?: string[];
statusRows?: number;
}
export interface IStepProgress extends IStepProgressState {
autoAdvance?: boolean;
}
export interface IStep {
title: string;
content: TemplateResult;
progressStep?: IStepProgress;
menuOptions?: plugins.tsclass.website.IMenuItem<DeesStepper>[];
validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise<any>;
onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise<any>;
validationFuncCalled?: boolean;
abortController?: AbortController;
progressStepState?: IStepProgressState;
}
declare global {
@@ -276,6 +291,14 @@ export class DeesStepper extends DeesElement {
padding: 32px;
}
.step-body .content.withProgressStep {
padding-top: 20px;
}
.progressStep {
padding: 0 24px;
}
/* --- Footer: modal-style bottom buttons --- */
.bottomButtons {
display: flex;
@@ -375,6 +398,7 @@ export class DeesStepper extends DeesElement {
const isHidden =
this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep);
const isFirst = stepIndex === 0;
const progressStepState = stepArg.progressStep ? this.getProgressStepState(stepArg) : null;
return html`<dees-tile
class="step ${isSelected ? 'selected' : ''} ${isHidden ? 'hiddenStep' : ''} ${isFirst ? 'entrance' : ''}"
>
@@ -390,7 +414,20 @@ export class DeesStepper extends DeesElement {
</div>
<div class="step-body">
<div class="title">${stepArg.title}</div>
<div class="content">${stepArg.content}</div>
${stepArg.progressStep && progressStepState ? html`
<div class="progressStep">
<dees-progressbar
.label=${progressStepState.label ?? stepArg.title}
.percentage=${progressStepState.percentage ?? 0}
.indeterminate=${progressStepState.indeterminate ?? false}
.showPercentage=${progressStepState.showPercentage ?? true}
.statusText=${progressStepState.statusText ?? ''}
.terminalLines=${progressStepState.terminalLines ?? []}
.statusRows=${progressStepState.statusRows ?? 3}
></dees-progressbar>
</div>
` : ''}
<div class="content ${stepArg.progressStep ? 'withProgressStep' : ''}">${stepArg.content}</div>
</div>
<div slot="footer" class="bottomButtons">
${isSelected && this.activeForm !== null && !this.activeFormValid
@@ -426,22 +463,30 @@ export class DeesStepper extends DeesElement {
public async firstUpdated() {
await this.domtoolsPromise;
await this.domtools.convenience.smartdelay.delayFor(0);
if (!this.steps.length) {
return;
}
this.prepareStepForActivation(this.steps[0]);
this.selectedStep = this.steps[0];
this.setScrollStatus();
await this.updateComplete;
await this.setScrollStatus();
// Remove entrance class after initial animation completes
await this.domtools.convenience.smartdelay.delayFor(350);
this.shadowRoot!.querySelector('.step.entrance')?.classList.remove('entrance');
}
public async updated() {
this.setScrollStatus();
public async updated(changedProperties: Map<string | number | symbol, unknown>) {
if (!changedProperties.has('selectedStep') && !changedProperties.has('steps')) {
return;
}
await this.setScrollStatus();
}
public scroller!: typeof domtools.plugins.SweetScroll.prototype;
public async setScrollStatus() {
const stepperContainer = this.shadowRoot!.querySelector('.stepperContainer') as HTMLElement;
const firstStepElement = this.shadowRoot!.querySelector('.step') as HTMLElement;
const selectedStepElement = this.shadowRoot!.querySelector('.selected') as HTMLElement;
if (!selectedStepElement) {
return;
@@ -452,14 +497,11 @@ export class DeesStepper extends DeesElement {
stepperContainer.offsetHeight / 2 - selectedStepElement.offsetHeight / 2
}px`;
}
console.log('Setting scroll status');
console.log(selectedStepElement);
const scrollPosition =
selectedStepElement.offsetTop -
stepperContainer.offsetHeight / 2 +
selectedStepElement.offsetHeight / 2;
console.log(scrollPosition);
const domtoolsInstance = await domtools.DomTools.setupDomTools();
await domtools.DomTools.setupDomTools();
if (!this.scroller) {
this.scroller = new domtools.plugins.SweetScroll(
{
@@ -474,7 +516,11 @@ export class DeesStepper extends DeesElement {
if (!this.selectedStep.validationFuncCalled && this.selectedStep.validationFunc) {
this.selectedStep.abortController = new AbortController();
this.selectedStep.validationFuncCalled = true;
await this.selectedStep.validationFunc(this, selectedStepElement, this.selectedStep.abortController.signal);
void this.runStepValidation(
this.selectedStep,
selectedStepElement,
this.selectedStep.abortController.signal,
);
}
this.scroller.to(scrollPosition);
}
@@ -492,6 +538,7 @@ export class DeesStepper extends DeesElement {
currentStep.validationFuncCalled = false;
const previousStep = this.steps[currentIndex - 1];
previousStep.validationFuncCalled = false;
this.prepareStepForActivation(previousStep);
this.selectedStep = previousStep;
await this.domtoolsPromise;
await this.domtools.convenience.smartdelay.delayFor(100);
@@ -511,9 +558,52 @@ export class DeesStepper extends DeesElement {
currentStep.validationFuncCalled = false;
const nextStep = this.steps[currentIndex + 1];
nextStep.validationFuncCalled = false;
this.prepareStepForActivation(nextStep);
this.selectedStep = nextStep;
}
public resetProgressStep(stepArg: IStep = this.selectedStep) {
if (!stepArg?.progressStep) {
return;
}
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
this.requestUpdate();
}
public updateProgressStep(
progressStateArg: Partial<IStepProgressState>,
stepArg: IStep = this.selectedStep,
) {
if (!stepArg?.progressStep) {
return;
}
const currentProgressState = this.getProgressStepState(stepArg);
stepArg.progressStepState = {
...currentProgressState,
...progressStateArg,
terminalLines: progressStateArg.terminalLines
? [...progressStateArg.terminalLines]
: [...(currentProgressState.terminalLines ?? [])],
};
this.requestUpdate();
}
public appendProgressStepLine(lineArg: string, stepArg: IStep = this.selectedStep) {
if (!stepArg?.progressStep) {
return;
}
const currentProgressState = this.getProgressStepState(stepArg);
this.updateProgressStep(
{
terminalLines: [...(currentProgressState.terminalLines ?? []), lineArg],
},
stepArg,
);
}
/**
* Scans the currently selected step for a <dees-form> in its content. When
* found, subscribes to the form's RxJS changeSubject so the primary
@@ -582,6 +672,74 @@ export class DeesStepper extends DeesElement {
await optionArg.action(this);
}
private getProgressStepState(stepArg: IStep): IStepProgressState {
if (!stepArg.progressStep) {
return {};
}
if (!stepArg.progressStepState) {
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
}
return stepArg.progressStepState;
}
private createInitialProgressStepState(stepArg: IStep): IStepProgressState {
return {
label: stepArg.progressStep?.label ?? stepArg.title,
percentage: stepArg.progressStep?.percentage ?? 0,
indeterminate: stepArg.progressStep?.indeterminate ?? false,
showPercentage: stepArg.progressStep?.showPercentage ?? true,
statusText: stepArg.progressStep?.statusText ?? '',
terminalLines: [...(stepArg.progressStep?.terminalLines ?? [])],
statusRows: stepArg.progressStep?.statusRows ?? 3,
};
}
private prepareStepForActivation(stepArg?: IStep) {
if (!stepArg?.progressStep) {
return;
}
stepArg.progressStepState = this.createInitialProgressStepState(stepArg);
}
private async runStepValidation(
stepArg: IStep,
selectedStepElement: HTMLElement,
signal: AbortSignal,
): Promise<void> {
try {
await stepArg.validationFunc?.(this, selectedStepElement, signal);
if (signal.aborted) {
return;
}
if (stepArg.progressStep && stepArg.progressStep.autoAdvance !== false && this.selectedStep === stepArg) {
this.goNext();
}
} catch (error) {
if (signal.aborted) {
return;
}
if (stepArg.progressStep) {
const errorText = error instanceof Error ? error.message : 'Unexpected error';
this.appendProgressStepLine(`Error: ${errorText}`, stepArg);
this.updateProgressStep(
{
indeterminate: false,
statusText: errorText,
},
stepArg,
);
}
console.error(error);
}
}
/**
* Currently-open confirmation modal (if any). Prevents double-stacking when
* the user clicks the backdrop or the Cancel button while a confirm modal
@@ -657,6 +815,9 @@ export class DeesStepper extends DeesElement {
public async destroy() {
const domtools = await this.domtoolsPromise;
const container = this.shadowRoot!.querySelector('.stepperContainer');
if (this.selectedStep?.abortController) {
this.selectedStep.abortController.abort();
}
container?.classList.add('predestroy');
await domtools.convenience.smartdelay.delayFor(250);
if (this.parentElement) {

View File

@@ -2,9 +2,73 @@ import { html } from '@design.estate/dees-element';
import { DeesUpdater } from '../dees-updater/dees-updater.js';
export const demoFunc = async () => {
const updater = await DeesUpdater.createAndShow();
setTimeout(async () => {
await updater.destroy();
}, 10000);
}
const waitForDemoStep = async (timeoutArg: number): Promise<void> => {
await new Promise<void>((resolve) => {
window.setTimeout(() => resolve(), timeoutArg);
});
};
export const demoFunc = () => {
let updaterRunning = false;
return html`
<div style="display: grid; gap: 16px; place-items: center; padding: 32px; text-align: center;">
<p style="margin: 0; max-width: 540px; line-height: 1.6; color: var(--dees-color-text-secondary);">
Launches the updater as a stepper flow. The first step streams terminal-style
progress updates and then moves automatically to the ready step.
</p>
<dees-button
@click=${async () => {
if (updaterRunning) {
return;
}
updaterRunning = true;
try {
const updater = await DeesUpdater.createAndShow({
currentVersion: '3.79.0',
updatedVersion: '3.80.0',
moreInfoUrl: 'https://code.foss.global/design.estate/dees-catalog',
changelogUrl: 'https://code.foss.global/design.estate/dees-catalog/-/blob/main/changelog.md',
successAction: 'close',
successDelayMs: 10000,
});
const progressFrames = [
{ line: 'Checking release manifest', percentage: 12, delay: 550 },
{ line: 'Downloading signed bundle', percentage: 33, delay: 700 },
{ line: 'Verifying checksum', percentage: 51, delay: 650 },
{ line: 'Applying update files', percentage: 74, delay: 800 },
{ line: 'Cleaning up previous release', percentage: 91, delay: 600 },
];
updater.updateProgress({
statusText: 'Checking release manifest...',
terminalLines: ['Checking release manifest'],
percentage: 12,
indeterminate: true,
});
for (const [index, progressFrame] of progressFrames.entries()) {
if (index > 0) {
updater.appendProgressLine(progressFrame.line);
updater.updateProgress({
percentage: progressFrame.percentage,
statusText: `${progressFrame.line}...`,
});
}
await waitForDemoStep(progressFrame.delay);
}
await updater.markUpdateReady();
await waitForDemoStep(10500);
} finally {
updaterRunning = false;
}
}}
>Show updater flow</dees-button>
</div>
`;
};

View File

@@ -4,14 +4,27 @@ import {
type TemplateResult,
html,
property,
type CSSResult,
domtools,
css,
} from '@design.estate/dees-element';
import { demoFunc } from './dees-updater.demo.js';
import {
DeesStepper,
type IStep,
type IStepProgressState,
} from '../../00group-layout/dees-stepper/dees-stepper.js';
import '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
import { css, cssManager } from '@design.estate/dees-element';
import { themeDefaultStyles } from '../../00theme.js';
export type TDeesUpdaterSuccessAction = 'close' | 'reload';
export interface IDeesUpdaterOptions {
currentVersion?: string;
updatedVersion?: string;
moreInfoUrl?: string;
changelogUrl?: string;
successAction?: TDeesUpdaterSuccessAction;
successDelayMs?: number;
successActionLabel?: string;
onSuccessAction?: () => Promise<void> | void;
}
declare global {
interface HTMLElementTagNameMap {
@@ -24,91 +37,393 @@ export class DeesUpdater extends DeesElement {
public static demo = demoFunc;
public static demoGroups = ['Utility'];
public static async createAndShow() {
public static async createAndShow(optionsArg: IDeesUpdaterOptions = {}) {
const updater = new DeesUpdater();
updater.currentVersion = optionsArg.currentVersion ?? '';
updater.updatedVersion = optionsArg.updatedVersion ?? '';
updater.moreInfoUrl = optionsArg.moreInfoUrl ?? '';
updater.changelogUrl = optionsArg.changelogUrl ?? '';
updater.successAction = optionsArg.successAction ?? 'close';
updater.successDelayMs = optionsArg.successDelayMs ?? 10000;
updater.successActionLabel = optionsArg.successActionLabel ?? '';
updater.onSuccessAction = optionsArg.onSuccessAction ?? null;
document.body.appendChild(updater);
await updater.show();
return updater;
}
@property({
type: String,
})
accessor currentVersion!: string;
accessor currentVersion = '';
@property({
type: String,
})
accessor updatedVersion!: string;
accessor updatedVersion = '';
constructor() {
super();
domtools.elementBasic.setup();
}
@property({
type: String,
})
accessor moreInfoUrl = '';
@property({
type: String,
})
accessor changelogUrl = '';
@property({
type: String,
})
accessor successAction: TDeesUpdaterSuccessAction = 'close';
@property({
type: Number,
})
accessor successDelayMs = 10000;
@property({
type: String,
})
accessor successActionLabel = '';
private stepper: DeesStepper | null = null;
private progressStep: IStep | null = null;
private showPromise: Promise<void> | null = null;
private onSuccessAction: (() => Promise<void> | void) | null = null;
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
css`
/* TODO: Migrate hardcoded values to --dees-* CSS variables */
.modalContainer {
will-change: transform;
position: relative;
background: ${cssManager.bdTheme('#eeeeeb', '#222')};
max-width: 800px;
border-radius: 8px;
border-top: 1px solid ${cssManager.bdTheme('#eeeeeb', '#333')};
}
.headingContainer {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 40px;
}
h1 {
margin: none;
font-size: 20px;
color: ${cssManager.bdTheme('#333', '#fff')};
margin-left: 20px;
font-weight: normal;
}
.buttonContainer {
display: grid;
grid-template-columns: 50% 50%;
:host {
display: none;
}
`,
];
public render(): TemplateResult {
return html`
<dees-windowlayer
@clicked="${this.windowLayerClicked}"
.options=${{
blur: true,
}}
>
<div class="modalContainer">
<div class="headingContainer">
<dees-spinner .size=${60}></dees-spinner>
<h1>Updating the application...</h1>
</div>
<div class="progress">
<dees-progressbar .progress=${0.5}></dees-progressbar>
</div>
<div class="buttonContainer">
<dees-button>More info</dees-button>
<dees-button>Changelog</dees-button>
</div>
</div> </dees-windowlayer
>>
`;
return html``;
}
public async connectedCallback(): Promise<void> {
await super.connectedCallback();
void this.show();
}
public async show(): Promise<void> {
if (this.stepper?.isConnected) {
return;
}
if (this.showPromise) {
return this.showPromise;
}
this.showPromise = this.openStepperFlow();
try {
await this.showPromise;
} finally {
this.showPromise = null;
}
}
public updateProgress(progressStateArg: Partial<IStepProgressState>) {
if (!this.stepper || !this.progressStep) {
return;
}
this.stepper.updateProgressStep(progressStateArg, this.progressStep);
}
public appendProgressLine(lineArg: string) {
if (!this.stepper || !this.progressStep) {
return;
}
this.stepper.appendProgressStepLine(lineArg, this.progressStep);
}
public markUpdateError(messageArg: string) {
this.appendProgressLine(`Error: ${messageArg}`);
this.updateProgress({
indeterminate: false,
statusText: messageArg,
});
}
public async markUpdateReady() {
if (!this.stepper || !this.progressStep) {
return;
}
this.stepper.updateProgressStep(
{
percentage: 100,
indeterminate: false,
statusText: 'Update ready.',
},
this.progressStep,
);
this.stepper.appendProgressStepLine('Update ready', this.progressStep);
if (this.stepper.selectedStep === this.progressStep) {
this.stepper.goNext();
}
}
public async destroy() {
this.parentElement!.removeChild(this);
const stepper = this.stepper;
this.stepper = null;
this.progressStep = null;
if (stepper?.isConnected) {
await stepper.destroy();
}
if (this.parentElement) {
this.parentElement.removeChild(this);
}
}
private windowLayerClicked() {}
private async openStepperFlow() {
const { steps, progressStep } = this.createUpdaterSteps();
this.progressStep = progressStep;
this.stepper = await DeesStepper.createAndShow({
steps,
cancelable: false,
});
}
private createUpdaterSteps(): { steps: IStep[]; progressStep: IStep } {
const infoMenuOptions = this.getLinkMenuOptions();
const progressStep: IStep = {
title: 'Updating the application',
content: this.renderProgressContent(),
progressStep: {
label: this.getProgressLabel(),
percentage: 5,
indeterminate: true,
statusRows: 4,
statusText: 'Preparing update...',
terminalLines: ['Preparing update'],
},
menuOptions: infoMenuOptions.length > 0 ? infoMenuOptions : undefined,
};
const readyStep: IStep = {
title: this.updatedVersion ? `Version ${this.updatedVersion} ready` : 'Update ready',
content: this.renderReadyContent(),
progressStep: {
label: this.getSuccessCountdownLabel(this.getSuccessDelaySeconds()),
percentage: 0,
indeterminate: false,
showPercentage: false,
statusRows: 2,
statusText: this.getSuccessCountdownStatus(this.getSuccessDelaySeconds()),
},
validationFunc: async (stepper, _htmlElement, signal) => {
await this.runSuccessCountdown(stepper, readyStep, signal);
},
};
return {
steps: [progressStep, readyStep],
progressStep,
};
}
private getProgressLabel(): string {
if (this.currentVersion && this.updatedVersion) {
return `${this.currentVersion} -> ${this.updatedVersion}`;
}
if (this.updatedVersion) {
return `Preparing ${this.updatedVersion}`;
}
return 'Application update';
}
private getSuccessDelaySeconds(): number {
return Math.max(1, Math.ceil(this.successDelayMs / 1000));
}
private getSuccessActionDisplayLabel(): string {
if (this.successActionLabel) {
return this.successActionLabel;
}
if (this.onSuccessAction) {
return 'Continuing automatically';
}
if (this.successAction === 'reload') {
return 'Reloading application';
}
return 'Closing updater';
}
private getSuccessCountdownLabel(secondsArg: number): string {
return `${this.getSuccessActionDisplayLabel()} in ${secondsArg}s`;
}
private getSuccessCountdownStatus(secondsArg: number): string {
const secondLabel = secondsArg === 1 ? 'second' : 'seconds';
return `${this.getSuccessActionDisplayLabel()} in ${secondsArg} ${secondLabel}.`;
}
private getSuccessActionNowLabel(): string {
return `${this.getSuccessActionDisplayLabel()} now...`;
}
private getLinkMenuOptions() {
const menuOptions: Array<{ name: string; action: () => Promise<void> }> = [];
if (this.moreInfoUrl) {
menuOptions.push({
name: 'More info',
action: async () => {
this.openExternalUrl(this.moreInfoUrl);
},
});
}
if (this.changelogUrl) {
menuOptions.push({
name: 'Changelog',
action: async () => {
this.openExternalUrl(this.changelogUrl);
},
});
}
return menuOptions;
}
private renderProgressContent(): TemplateResult {
return html`
<div style="display: grid; gap: 12px; color: var(--dees-color-text-secondary); line-height: 1.6;">
<p style="margin: 0; text-align: center;">
Downloading and applying the latest application release.
${this.currentVersion && this.updatedVersion
? html`Moving from <strong>${this.currentVersion}</strong> to <strong>${this.updatedVersion}</strong>.`
: this.updatedVersion
? html`Preparing <strong>${this.updatedVersion}</strong>.`
: ''}
</p>
<p style="margin: 0; text-align: center; font-size: 13px; color: var(--dees-color-text-muted);">
The updater advances automatically once the new build is installed and verified.
</p>
</div>
`;
}
private renderReadyContent(): TemplateResult {
const successDelaySeconds = this.getSuccessDelaySeconds();
return html`
<div style="display: grid; gap: 12px; color: var(--dees-color-text-secondary); line-height: 1.6;">
<p style="margin: 0; text-align: center;">
${this.updatedVersion
? html`Version <strong>${this.updatedVersion}</strong> is ready to use.`
: 'The new version is ready to use.'}
</p>
<p style="margin: 0; text-align: center; font-size: 13px; color: var(--dees-color-text-muted);">
Configured next action: ${this.getSuccessActionDisplayLabel()}. It runs automatically in ${successDelaySeconds} seconds.
</p>
</div>
`;
}
private async runSuccessCountdown(
stepperArg: DeesStepper,
stepArg: IStep,
signal?: AbortSignal,
): Promise<void> {
const totalDuration = Math.max(1000, this.successDelayMs);
const startTime = Date.now();
while (!signal?.aborted) {
const elapsed = Math.min(totalDuration, Date.now() - startTime);
const remainingMilliseconds = Math.max(0, totalDuration - elapsed);
const remainingSeconds = Math.max(0, Math.ceil(remainingMilliseconds / 1000));
stepperArg.updateProgressStep(
{
label: remainingMilliseconds > 0
? this.getSuccessCountdownLabel(remainingSeconds)
: this.getSuccessActionNowLabel(),
percentage: (elapsed / totalDuration) * 100,
indeterminate: false,
showPercentage: false,
statusText: remainingMilliseconds > 0
? this.getSuccessCountdownStatus(remainingSeconds)
: this.getSuccessActionNowLabel(),
},
stepArg,
);
if (remainingMilliseconds <= 0) {
break;
}
const completed = await this.waitForCountdownTick(100, signal);
if (!completed) {
return;
}
}
await this.runConfiguredSuccessAction();
}
private async waitForCountdownTick(timeoutArg: number, signal?: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
let completed = false;
const finish = (result: boolean) => {
if (completed) {
return;
}
completed = true;
if (signal) {
signal.removeEventListener('abort', handleAbort);
}
resolve(result);
};
const handleAbort = () => {
window.clearTimeout(timeoutId);
finish(false);
};
const timeoutId = window.setTimeout(() => {
finish(true);
}, timeoutArg);
if (signal) {
signal.addEventListener('abort', handleAbort, { once: true });
}
});
}
private async runConfiguredSuccessAction(): Promise<void> {
if (this.onSuccessAction) {
await this.onSuccessAction();
return;
}
if (this.successAction === 'reload') {
await this.destroy();
window.location.reload();
return;
}
await this.destroy();
}
private openExternalUrl(urlArg: string) {
window.open(urlArg, '_blank', 'noopener,noreferrer');
}
}