Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f5cb4570b | |||
| 428d2741d1 | |||
| 2f4c47f0d2 | |||
| 2be1ce6908 | |||
| c0375508f0 | |||
| 3e86ba034b | |||
| 05e74cbe2e | |||
| 8fbdbf9f64 | |||
| bfbc0f108e | |||
| bab7528f0b | |||
| 6047705e7d | |||
| ab19b561c4 | |||
| 7ef3613e91 | |||
| 940eebe29f | |||
| 8ecaffe165 |
50
changelog.md
50
changelog.md
@@ -1,5 +1,55 @@
|
||||
# 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
|
||||
|
||||
- Updates the @design.estate/dees-wcctools dependency from ^3.8.4 to ^3.9.0 in package.json.
|
||||
|
||||
## 2026-04-12 - 3.78.1 - fix(dees-simple-login)
|
||||
use dees-tile for the login credentials container
|
||||
|
||||
- replace the custom login card wrapper with a dees-tile component
|
||||
- update styles to target dees-tile and its content part while preserving form layout
|
||||
- add a credentials heading to the login tile
|
||||
|
||||
## 2026-04-12 - 3.78.0 - feat(dees-settings)
|
||||
add dees-settings layout component for displaying read-only settings with footer actions
|
||||
|
||||
- introduces a new dees-settings element with heading, description, settings field grid, and footer action support
|
||||
- exports dees-settings from the 00group-layout module index
|
||||
- adds demo examples covering populated, empty, and multi-action states
|
||||
|
||||
## 2026-04-12 - 3.77.0 - feat(dees-table)
|
||||
add configurable cell flash comparison and border highlight mode
|
||||
|
||||
- adds column-level flashCompare support so update highlighting can detect meaningful changes for custom cell values
|
||||
- adds flashBorder styling for cells with badges, icons, or custom rendered content where text-color flashing is not visible
|
||||
- avoids false-positive flash animations for non-primitive cell values unless a custom comparator is provided
|
||||
|
||||
## 2026-04-12 - 3.76.1 - fix(demo-inputs)
|
||||
wrap input component demos in dees-form containers for consistent form integration
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "3.76.1",
|
||||
"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",
|
||||
@@ -18,7 +18,7 @@
|
||||
"dependencies": {
|
||||
"@design.estate/dees-domtools": "^2.5.4",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@design.estate/dees-wcctools": "^3.8.4",
|
||||
"@design.estate/dees-wcctools": "^3.9.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||
|
||||
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
'@design.estate/dees-wcctools':
|
||||
specifier: ^3.8.4
|
||||
version: 3.8.4
|
||||
specifier: ^3.9.0
|
||||
version: 3.9.0
|
||||
'@fortawesome/fontawesome-svg-core':
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
@@ -323,8 +323,8 @@ packages:
|
||||
'@design.estate/dees-element@2.2.4':
|
||||
resolution: {integrity: sha512-O9cA6flBMMd+pBwMQrZXwAWel9yVxgokolb+Em6gvkXxPJ0P/B5UDn4Vc2d4ts3ta55PTBm+l2dPeDVGx/bl7Q==}
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.4':
|
||||
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
|
||||
|
||||
'@emnapi/core@1.8.1':
|
||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||
@@ -2070,6 +2070,12 @@ packages:
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
|
||||
|
||||
@@ -3136,6 +3142,9 @@ packages:
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
mediabunny@1.40.1:
|
||||
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
|
||||
|
||||
memory-pager@1.5.0:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -4711,7 +4720,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.4
|
||||
'@design.estate/dees-wcctools': 3.9.0
|
||||
'@fortawesome/fontawesome-svg-core': 7.2.0
|
||||
'@fortawesome/free-brands-svg-icons': 7.2.0
|
||||
'@fortawesome/free-regular-svg-icons': 7.2.0
|
||||
@@ -4787,12 +4796,13 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@design.estate/dees-wcctools@3.8.4':
|
||||
'@design.estate/dees-wcctools@3.9.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
lit: 3.3.2
|
||||
mediabunny: 1.40.1
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
@@ -7245,6 +7255,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
@@ -8468,6 +8484,11 @@ snapshots:
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
mediabunny@1.40.1:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
memory-pager@1.5.0: {}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
|
||||
107
readme.md
107
readme.md
@@ -59,8 +59,8 @@ For developers working on this library, please refer to the [UI Components Playb
|
||||
| **Forms** | [`DeesForm`](#deesform), [`DeesInputText`](#deesinputtext), [`DeesInputCheckbox`](#deesinputcheckbox), [`DeesInputDropdown`](#deesinputdropdown), [`DeesInputRadiogroup`](#deesinputradiogroup), [`DeesInputFileupload`](#deesinputfileupload), [`DeesInputIban`](#deesinputiban), [`DeesInputPhone`](#deesinputphone), [`DeesInputQuantitySelector`](#deesinputquantityselector), [`DeesInputMultitoggle`](#deesinputmultitoggle), [`DeesInputToggle`](#deesinputtoggle), [`DeesInputTags`](#deesinputtags), [`DeesInputTypelist`](#deesinputtypelist), [`DeesInputList`](#deesinputlist), [`DeesInputProfilepicture`](#deesinputprofilepicture), [`DeesInputRichtext`](#deesinputrichtext), [`DeesInputWysiwyg`](#deesinputwysiwyg), [`DeesInputDatepicker`](#deesinputdatepicker), [`DeesInputSearchselect`](#deesinputsearchselect), [`DeesInputCode`](#deesinputcode), [`DeesFormSubmit`](#deesformsubmit) |
|
||||
| **App Shell (Layout)** | [`DeesAppui`](#deesappui-️), [`DeesAppuiMainmenu`](#deesappuimainmenu), [`DeesAppuiSecondarymenu`](#deesappuisecondarymenu), [`DeesAppuiMaincontent`](#deesappuimaincontent), [`DeesAppuiAppbar`](#deesappuiappbar), [`DeesAppuiActivitylog`](#deesappuiactivitylog), [`DeesAppuiBottombar`](#deesappuibottombar), [`DeesAppuiProfiledropdown`](#deesappuiprofiledropdown), [`DeesAppuiTabs`](#deesappuitabs), [`DeesMobileNavigation`](#deesmobilenavigation), [`DeesDashboardGrid`](#deesdashboardgrid) |
|
||||
| **Data Display** | [`DeesTable`](#deestable), [`DeesDataviewCodebox`](#deesdataviewcodebox), [`DeesDataviewStatusobject`](#deesdataviewstatusobject), [`DeesPdf`](#deespdf), [`DeesStatsGrid`](#deesstatsgrid), [`DeesPagination`](#deespagination), [`DeesStorageBrowser`](#deesstorgebrowser) |
|
||||
| **Media & Tiles** | [`DeesTilePdf`](#deestilepdf), [`DeesTileImage`](#deestileimage), [`DeesTileAudio`](#deestileaudio), [`DeesTileVideo`](#deestilevideo), [`DeesTileNote`](#deestilenote), [`DeesTileFolder`](#deestilefolder), [`DeesPreview`](#deespreview), [`DeesPdfViewer`](#deespdfviewer), [`DeesPdfPreview`](#deespdfpreview), [`DeesImageViewer`](#deesimageviewer), [`DeesAudioViewer`](#deesaudioviewer), [`DeesVideoViewer`](#deesvideoviewer) |
|
||||
| **Visualization** | [`DeesChartArea`](#deeschartarea), [`DeesChartLog`](#deeschartlog) |
|
||||
| **Media & Thumbnails** | [`DeesThumbnailPdf`](#deesthumbnailpdf), [`DeesThumbnailImage`](#deesthumbnailimage), [`DeesThumbnailAudio`](#deesthumbnailaudio), [`DeesThumbnailVideo`](#deesthumbnalvideo), [`DeesThumbnailNote`](#deesthumbnailnote), [`DeesThumbnailFolder`](#deesthumbnailfolder), [`DeesPreview`](#deespreview), [`DeesPdfViewer`](#deespdfviewer), [`DeesImageViewer`](#deesimageviewer), [`DeesAudioViewer`](#deesaudioviewer), [`DeesVideoViewer`](#deesvideoviewer) |
|
||||
| **Visualization** | [`DeesChartArea`](#deeschartarea), [`DeesChartBar`](#deeschartbar), [`DeesChartDonut`](#deeschartdonut), [`DeesChartGauge`](#deeschartgauge), [`DeesChartRadar`](#deeschartradar), [`DeesChartLog`](#deeschartlog) |
|
||||
| **Dialogs & Overlays** | [`DeesModal`](#deesmodal), [`DeesContextmenu`](#deescontextmenu), [`DeesSpeechbubble`](#deesspeechbubble), [`DeesWindowlayer`](#deeswindowlayer) |
|
||||
| **Navigation** | [`DeesStepper`](#deesstepper), [`DeesProgressbar`](#deesprogressbar) |
|
||||
| **Workspace / IDE** | [`DeesWorkspace`](#deesworkspace), [`DeesWorkspaceMonaco`](#deesworkspacemonaco), [`DeesWorkspaceDiffEditor`](#deesworkspacediffeditor), [`DeesWorkspaceFiletree`](#deesworkspacefiletree), [`DeesWorkspaceTerminal`](#deesworkspaceterminal), [`DeesWorkspaceTerminalPreview`](#deesworkspaceterminalpreview), [`DeesWorkspaceMarkdown`](#deesworkspacemarkdown), [`DeesWorkspaceMarkdownoutlet`](#deesworkspacemarkdownoutlet), [`DeesWorkspaceBottombar`](#deesworkspacebottombar) |
|
||||
@@ -143,14 +143,13 @@ Display icons from FontAwesome and Lucide icon libraries with library prefixes.
|
||||
```
|
||||
|
||||
#### `DeesLabel`
|
||||
Text label component with optional icon and status indicators.
|
||||
Text label component with optional required indicator and info tooltip. Used internally by all input components.
|
||||
|
||||
```typescript
|
||||
<dees-label
|
||||
text="Status" // Label text
|
||||
icon="info-circle" // Optional: icon name
|
||||
type="info" // Options: default, info, success, warning, error
|
||||
size="medium" // Options: small, medium, large
|
||||
.label=${'Email Address'} // Label text
|
||||
.required=${true} // Optional: shows red asterisk
|
||||
.infoText=${'We will never share your email'} // Optional: shows hover info icon with tooltip
|
||||
></dees-label>
|
||||
```
|
||||
|
||||
@@ -321,7 +320,7 @@ Container component for form elements with built-in validation and data handling
|
||||
```
|
||||
|
||||
#### `DeesInputText`
|
||||
Text input field with validation and formatting options.
|
||||
Text input field with validation, info tooltips, description text, and context menu (Cut/Copy/Paste/Select All).
|
||||
|
||||
```typescript
|
||||
<dees-input-text
|
||||
@@ -330,10 +329,20 @@ Text input field with validation and formatting options.
|
||||
value="initial@value.com" // Initial value
|
||||
required // Makes the field required
|
||||
disabled // Disables the input
|
||||
placeholder="Enter your email"
|
||||
.infoText=${'Hover icon tooltip text'} // Shows ⓘ icon on label with hover tooltip
|
||||
.description=${'Permanent help text below the input'} // Small text below the input
|
||||
.validationFunction=${(value) => { // Auto-validates on every keystroke
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (emailRegex.test(value)) {
|
||||
return { valid: true, message: 'Email is valid' };
|
||||
}
|
||||
return { valid: false, message: 'Please enter a valid email' };
|
||||
}}
|
||||
></dees-input-text>
|
||||
```
|
||||
|
||||
> 💡 **All input components** share these common properties from `DeesInputBase`: `key`, `label`, `required`, `disabled`, `infoText`, `description`, `layoutMode`, `labelPosition`.
|
||||
|
||||
#### `DeesInputCheckbox`
|
||||
Checkbox input component for boolean values.
|
||||
|
||||
@@ -1499,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>
|
||||
```
|
||||
|
||||
@@ -1561,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 💻
|
||||
@@ -1780,7 +1841,7 @@ interface ITileFolderItem {
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
@@ -1798,5 +1859,3 @@ Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
167
test/test.dees-table-liveupdates.chromium.ts
Normal file
167
test/test.dees-table-liveupdates.chromium.ts
Normal 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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '3.76.1',
|
||||
version: '3.80.0',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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' : '',
|
||||
isFocused && !isEditing ? 'focused' : '',
|
||||
@@ -702,8 +706,13 @@ export class DeesTable<T> extends DeesElement {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const flashClass = isFlashing
|
||||
? useFlashBorder
|
||||
? 'innerCellContainer flashing-border'
|
||||
: 'innerCellContainer flashing'
|
||||
: 'innerCellContainer';
|
||||
const innerHtml = html`<div
|
||||
class=${isFlashing ? 'innerCellContainer flashing' : 'innerCellContainer'}
|
||||
class=${flashClass}
|
||||
>
|
||||
${isEditing ? this.renderCellEditor(itemArg, col) : content}
|
||||
</div>`;
|
||||
@@ -714,7 +723,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
>
|
||||
${isFlashing
|
||||
? directives.keyed(
|
||||
`${rowId}:${editKey}:${this.__flashTick}`,
|
||||
`${rowId}:${editKey}:${flashToken}`,
|
||||
innerHtml
|
||||
)
|
||||
: innerHtml}
|
||||
@@ -722,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"
|
||||
@@ -774,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>
|
||||
@@ -1154,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;
|
||||
@@ -1362,6 +1364,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
|
||||
const effectiveColumns = this.__getEffectiveColumns();
|
||||
const visibleCols = effectiveColumns.filter((c) => !c.hidden);
|
||||
const colByKey = new Map<string, Column<T>>(visibleCols.map((c) => [String(c.key), c]));
|
||||
const nextSnapshot = new Map<string, Map<string, unknown>>();
|
||||
const newlyFlashing = new Map<string, Set<string>>();
|
||||
|
||||
@@ -1376,7 +1379,26 @@ export class DeesTable<T> extends DeesElement {
|
||||
const prevCells = this.__prevSnapshot?.get(rowId);
|
||||
if (!prevCells) continue; // new row — not an "update"
|
||||
for (const [colKey, nextVal] of cellMap) {
|
||||
if (prevCells.get(colKey) !== nextVal) {
|
||||
const prevVal = prevCells.get(colKey);
|
||||
|
||||
// Look up the column definition for flash options.
|
||||
const colDef = colByKey.get(colKey);
|
||||
|
||||
// Determine whether the cell changed.
|
||||
let changed: boolean;
|
||||
if (colDef?.flashCompare) {
|
||||
// Explicit custom comparator — caller decides.
|
||||
changed = colDef.flashCompare(prevVal, nextVal);
|
||||
} else if (nextVal !== null && nextVal !== undefined && typeof nextVal === 'object') {
|
||||
// Non-primitive (TemplateResult, object, array, etc.) — skip by
|
||||
// default. Custom renderings don't benefit from the text-color
|
||||
// flash and reference inequality causes false positives.
|
||||
changed = false;
|
||||
} else {
|
||||
changed = prevVal !== nextVal;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
// Don't flash the cell the user is actively editing.
|
||||
if (
|
||||
this.__editingCell &&
|
||||
@@ -1400,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();
|
||||
@@ -1423,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.
|
||||
@@ -1430,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;
|
||||
@@ -1476,7 +1505,7 @@ export class DeesTable<T> extends DeesElement {
|
||||
if (
|
||||
!this.fixedHeight &&
|
||||
this.data.length > 0 &&
|
||||
(this.__floatingActive || dataOrColsChanged)
|
||||
(this.__floatingActive || columnLayoutChanged)
|
||||
) {
|
||||
this.__syncFloatingHeader();
|
||||
}
|
||||
@@ -1778,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);
|
||||
}
|
||||
|
||||
@@ -2517,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;
|
||||
|
||||
|
||||
@@ -404,11 +404,44 @@ export const tableStyles: CSSResult[] = [
|
||||
100% { color: var(--dees-color-text-primary); }
|
||||
}
|
||||
|
||||
/* Border/background flash variant for cells with styled content
|
||||
(badges, icons, custom components) where a text-color animation
|
||||
would be invisible. Activated via flashBorder on Column. */
|
||||
.innerCellContainer.flashing-border {
|
||||
animation: dees-table-cell-flash-border
|
||||
var(--dees-table-flash-duration, 900ms)
|
||||
var(--dees-table-flash-easing);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@keyframes dees-table-cell-flash-border {
|
||||
0%,
|
||||
35% {
|
||||
box-shadow: inset 0 0 0 1.5px var(--dees-table-flash-color);
|
||||
background: ${cssManager.bdTheme(
|
||||
'hsl(45 93% 62% / 0.10)',
|
||||
'hsl(45 93% 62% / 0.08)'
|
||||
)};
|
||||
}
|
||||
100% {
|
||||
box-shadow: inset 0 0 0 1.5px transparent;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.innerCellContainer.flashing {
|
||||
animation: none;
|
||||
color: var(--dees-table-flash-color);
|
||||
}
|
||||
.innerCellContainer.flashing-border {
|
||||
animation: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--dees-table-flash-color);
|
||||
background: ${cssManager.bdTheme(
|
||||
'hsl(45 93% 62% / 0.10)',
|
||||
'hsl(45 93% 62% / 0.08)'
|
||||
)};
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev-time warning banner shown when highlight-updates="flash" but
|
||||
|
||||
@@ -65,6 +65,25 @@ export interface Column<T = any> {
|
||||
parse?: (editorValue: any, row: T) => any;
|
||||
/** Validate the parsed value before commit. Return string for error, true/void for ok. */
|
||||
validate?: (value: any, row: T) => true | string | void;
|
||||
|
||||
// ─── Flash highlight options ───
|
||||
|
||||
/**
|
||||
* Custom comparison for flash-on-update diffing.
|
||||
* Return `true` if the cell should flash (i.e. the values differ).
|
||||
* When absent, non-primitive cell values are skipped entirely
|
||||
* (only strings, numbers, booleans, null, and undefined are diffed).
|
||||
*/
|
||||
flashCompare?: (prevVal: any, nextVal: any) => boolean;
|
||||
|
||||
/**
|
||||
* When `true`, flash this cell with a border/background pulse instead of
|
||||
* the default text-color animation. Useful for cells containing styled
|
||||
* badges, icons, or custom web-component renderings where a text-color
|
||||
* change would be invisible.
|
||||
* @default false
|
||||
*/
|
||||
flashBorder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,6 @@ export const demoFunc = () => html`
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
|
||||
@@ -13,12 +13,6 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.payment-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,12 +13,6 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,12 +14,6 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.shopping-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
|
||||
@@ -23,12 +23,6 @@ export const demoFunc = () => html`
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
|
||||
@@ -30,12 +30,6 @@ export const demoFunc = () => html`
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -13,12 +13,6 @@ export const demoFunc = () => html`
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.horizontal-group {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import './dees-settings.js';
|
||||
import type { ISettingsField, ISettingsAction } from './dees-settings.js';
|
||||
|
||||
export const demoFunc = () => {
|
||||
const acmeFields: ISettingsField[] = [
|
||||
{ key: 'email', label: 'Account email', value: 'admin@example.com' },
|
||||
{ key: 'status', label: 'Status', value: 'enabled' },
|
||||
{ key: 'mode', label: 'Mode', value: 'production' },
|
||||
{ key: 'autoRenew', label: 'Auto-renew', value: 'on' },
|
||||
{ key: 'threshold', label: 'Renewal threshold', value: '30 days' },
|
||||
];
|
||||
|
||||
const acmeActions: ISettingsAction[] = [
|
||||
{ name: 'Edit', action: () => console.log('Edit clicked') },
|
||||
];
|
||||
|
||||
const emptyActions: ISettingsAction[] = [
|
||||
{ name: 'Configure', action: () => console.log('Configure clicked') },
|
||||
];
|
||||
|
||||
const multiActions: ISettingsAction[] = [
|
||||
{ name: 'Reset', action: () => console.log('Reset clicked') },
|
||||
{ name: 'Edit', action: () => console.log('Edit clicked') },
|
||||
];
|
||||
|
||||
return html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 9%)')};
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 600px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.settingsFields=${acmeFields}
|
||||
.actions=${acmeActions}
|
||||
></dees-settings>
|
||||
|
||||
<dees-settings
|
||||
.heading=${'ACME Settings'}
|
||||
.description=${'No ACME configuration yet. Click Configure to set up automated TLS certificate issuance.'}
|
||||
.actions=${emptyActions}
|
||||
></dees-settings>
|
||||
|
||||
<dees-settings
|
||||
.heading=${'Server Config'}
|
||||
.settingsFields=${[
|
||||
{ key: 'host', label: 'Hostname', value: 'proxy.example.com' },
|
||||
{ key: 'port', label: 'Port', value: '443' },
|
||||
]}
|
||||
.actions=${multiActions}
|
||||
></dees-settings>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
||||
196
ts_web/elements/00group-layout/dees-settings/dees-settings.ts
Normal file
196
ts_web/elements/00group-layout/dees-settings/dees-settings.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
property,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
import { demoFunc } from './dees-settings.demo.js';
|
||||
import { cssGeistFontFamily } from '../../00fonts.js';
|
||||
import { themeDefaultStyles } from '../../00theme.js';
|
||||
import '../../00group-layout/dees-tile/dees-tile.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-settings': DeesSettings;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ISettingsField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string | TemplateResult;
|
||||
}
|
||||
|
||||
export interface ISettingsAction {
|
||||
name: string;
|
||||
action: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* dees-settings — a read-only settings display tile with modal-style footer actions.
|
||||
*
|
||||
* Renders a dees-tile with a heading, a grid of label/value fields,
|
||||
* and a footer action bar. When an action is clicked the component
|
||||
* dispatches a `settings-action` CustomEvent with the action name.
|
||||
*/
|
||||
@customElement('dees-settings')
|
||||
export class DeesSettings extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
public static demoGroups = ['Layout'];
|
||||
|
||||
@property({ type: String })
|
||||
accessor heading: string = '';
|
||||
|
||||
@property({ type: String })
|
||||
accessor description: string = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor settingsFields: ISettingsField[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor actions: ISettingsAction[] = [];
|
||||
|
||||
public static styles = [
|
||||
themeDefaultStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family: ${cssGeistFontFamily};
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px 24px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settingsField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
font-size: 13px;
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.settingsDescription {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
|
||||
.bottomButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton {
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--dees-color-border-subtle);
|
||||
color: var(--dees-color-text-muted);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton:hover {
|
||||
background: var(--dees-color-hover);
|
||||
color: var(--dees-color-text-primary);
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton:active {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(0 0% 13%)')};
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton.primary {
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton.primary:hover {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.08)', 'hsl(213.1 93.9% 67.8% / 0.08)')};
|
||||
color: ${cssManager.bdTheme('hsl(217.2 91.2% 50%)', 'hsl(213.1 93.9% 75%)')};
|
||||
}
|
||||
|
||||
.bottomButtons .bottomButton.primary:active {
|
||||
background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.15)', 'hsl(213.1 93.9% 67.8% / 0.15)')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const hasFields = this.settingsFields.length > 0;
|
||||
const hasActions = this.actions.length > 0;
|
||||
|
||||
return html`
|
||||
<dees-tile .heading=${this.heading}>
|
||||
${hasFields
|
||||
? html`
|
||||
<div class="settingsGrid">
|
||||
${this.settingsFields.map(
|
||||
(field) => html`
|
||||
<div class="settingsField">
|
||||
<span class="fieldLabel">${field.label}</span>
|
||||
<span class="fieldValue">${field.value}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="settingsDescription">${this.description}</div>
|
||||
`}
|
||||
${hasActions
|
||||
? html`
|
||||
<div slot="footer" class="bottomButtons">
|
||||
${this.actions.map(
|
||||
(actionArg, index) => html`
|
||||
<div
|
||||
class="bottomButton ${index === this.actions.length - 1 ? 'primary' : ''}"
|
||||
@click=${() => actionArg.action()}
|
||||
>
|
||||
${actionArg.name}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</dees-tile>
|
||||
`;
|
||||
}
|
||||
}
|
||||
1
ts_web/elements/00group-layout/dees-settings/index.ts
Normal file
1
ts_web/elements/00group-layout/dees-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-settings.js';
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,5 +5,6 @@ export * from './dees-heading/index.js';
|
||||
export * from './dees-label/index.js';
|
||||
export * from './dees-pagination/index.js';
|
||||
export * from './dees-panel/index.js';
|
||||
export * from './dees-settings/index.js';
|
||||
export * from './dees-stepper/index.js';
|
||||
export * from './dees-tile/index.js';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
import { themeDefaultStyles } from '../../00theme.js';
|
||||
import '../../00group-layout/dees-tile/dees-tile.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -90,24 +91,25 @@ export class DeesSimpleLogin extends DeesElement {
|
||||
color: var(--dees-color-text-muted);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--dees-color-bg-primary);
|
||||
border: 1px solid var(--dees-color-border-default);
|
||||
border-radius: 8px;
|
||||
dees-tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
dees-tile::part(content) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card dees-form {
|
||||
dees-tile dees-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.login-card dees-input-text {
|
||||
dees-tile dees-input-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card dees-form-submit {
|
||||
dees-tile dees-form-submit {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -122,13 +124,13 @@ export class DeesSimpleLogin extends DeesElement {
|
||||
<div class="header">Sign in</div>
|
||||
<div class="subheader">Enter your credentials to access ${this.name}</div>
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<dees-tile .heading=${'Credentials'}>
|
||||
<dees-form>
|
||||
<dees-input-text key="username" label="Username" required></dees-input-text>
|
||||
<dees-input-text key="password" label="Password" isPasswordBool required></dees-input-text>
|
||||
<dees-form-submit>Sign in</dees-form-submit>
|
||||
</dees-form>
|
||||
</div>
|
||||
</dees-tile>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slotContainer">
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user