diff --git a/readme.plan.md b/readme.plan.md new file mode 100644 index 0000000..81b2e18 --- /dev/null +++ b/readme.plan.md @@ -0,0 +1,352 @@ +# Plan: dees-stepper — adopt dees-tile + optional overlay window layer + +> First line (per CLAUDE.md): Please reread `/home/philkunz/.claude/CLAUDE.md` before continuing. + +## Context + +Today `dees-stepper` is an inline-only layout component: it hard-codes each step as a custom `.step` `
` with its own border / background / box-shadow / border-radius, and its `:host` is `position: absolute; width: 100%; height: 100%;` so it can only live inside a bounded parent container. + +The user wants it to behave more like `dees-modal`: + +1. Each step should be wrapped in a `` — the unified "rounded on rounded" frame used by modals and panels — rather than a bespoke `.step` div. +2. A `DeesWindowLayer` should be added behind the stepper, the same way `DeesModal.createAndShow` does, so the stepper can appear as an overlay on top of the page. + +User has confirmed (via AskUserQuestion in this session): +- **API**: keep the current inline usage working AND add a static `createAndShow()` like dees-modal. +- **Layout**: keep the current vertical stack + SweetScroll behavior inside the overlay (don't switch to single-tile swap). +- **Nav placement**: split header/footer — goBack + step counter go into the `dees-tile` header slot; the title stays in the content area; the tile footer is used for optional next/submit buttons supplied per-step. + +No external consumers of `dees-stepper` were found inside this package (`grep dees-stepper|DeesStepper` only matches its own source, demo, index, changelog, readme). External consumers in dependent projects may exist — the refactor is kept backward-compatible for the inline path. + +## Current state (reference) + +**File:** `ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts` (lines 20–299) + +- `IStep` interface — `title`, `content: TemplateResult`, `validationFunc`, `onReturnToStepFunc`, internal flags (lines 20–27). +- `:host { position: absolute; width: 100%; height: 100%; }` (lines 59–63). +- `.stepperContainer` — absolute, 100% w/h, `overflow: hidden`, holds SweetScroll (lines 64–69). +- `.step` — max-width 500, min-height 300, `border-radius: 12px`, theme background, theme border, `box-shadow: 0 8px 32px rgba(0,0,0,0.4)`, `filter: opacity(0.55) saturate(0.85)`, transform transition (lines 71–97). **These frame styles overlap with what `dees-tile` already provides.** +- `.step.selected` — `filter: opacity(1) saturate(1)` (lines 89–93). **Scroll-through visual cue, keep.** +- `.step.hiddenStep` — `filter: opacity(0)` (line 95). **Keep.** +- `.step.entrance` — faster transition variant for first-render (lines 99–105). **Keep.** +- `.step .stepCounter` — `position: absolute; top: 12px; right: 12px;` pill (lines 111–121). **Move into header slot as a flex child.** +- `.step .goBack` — `position: absolute; top: 12px; left: 12px;` pill + icon + hover (lines 123–161). **Move into header slot as a flex child.** +- `.step .title` — centered, 24px, 64px top padding (lines 163–171). **Keep inside the tile's content slot; remove the 64px top padding since goBack/counter no longer overlap it.** +- `.step .content` — 32px padding (lines 173–175). **Keep.** +- `render()` (lines 179–204) — maps `steps` to `.step` divs. +- `setScrollStatus()` (lines 226–263) — SweetScroll container setup + step validation kick-off. **Keep mostly as-is; selectors still target `.step`/`.selected` so rename cautiously.** +- `firstUpdated` (lines 210–218), `updated` (lines 220–222), `goBack` (lines 265–282), `goNext` (lines 284–298) — untouched in behavior, only DOM selectors may need adjusting. + +**Reference files (read, do not modify):** +- `ts_web/elements/00group-overlay/dees-modal/dees-modal.ts` — canonical `createAndShow` + `destroy` + window-layer coordination + z-index registry usage. +- `ts_web/elements/00group-layout/dees-tile/dees-tile.ts` — slot API: `slot="header"`, default slot, `slot="footer"`. Auto-hides footer when slotted nodes are empty. Uses `part="outer"`, `part="header"`, `part="content"`, `part="footer"` for external shadow-part styling. +- `ts_web/elements/00group-overlay/dees-windowlayer/dees-windowlayer.ts` — `createAndShow({ blur })`, `destroy()`, dispatches `clicked` event on backdrop click, uses `zIndexRegistry`. +- `ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts` — existing inline demo. + +## Target state + +### 1. IStep interface — add one optional field + +```ts +export interface IStep { + title: string; + content: TemplateResult; + footerContent?: TemplateResult; // NEW: optional, rendered in dees-tile footer slot + validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise; + onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise; + validationFuncCalled?: boolean; + abortController?: AbortController; +} +``` + +Form-based steps don't need `footerContent` — their `dees-form-submit` stays inside the form in the content slot (as today). `footerContent` is for non-form steps that need an explicit primary action, or for any step that wants buttons in the conventional tile footer location. + +### 2. New overlay-mode state + API on DeesStepper + +```ts +@state() accessor overlay: boolean = false; +@state() accessor stepperZIndex: number = 1000; +private windowLayer?: DeesWindowLayer; + +public static async createAndShow(optionsArg: { + steps: IStep[]; +}): Promise { + const body = document.body; + const stepper = new DeesStepper(); + stepper.steps = optionsArg.steps; + stepper.overlay = true; + stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true }); + stepper.windowLayer.addEventListener('click', async () => { + await stepper.destroy(); + }); + body.append(stepper.windowLayer); // (already appended inside createAndShow, but mirror dees-modal's pattern; see note) + body.append(stepper); + stepper.stepperZIndex = zIndexRegistry.getNextZIndex(); + zIndexRegistry.register(stepper, stepper.stepperZIndex); + return stepper; +} + +public async destroy() { + const domtools = await this.domtoolsPromise; + const container = this.shadowRoot!.querySelector('.stepperContainer'); + container?.classList.add('predestroy'); + await domtools.convenience.smartdelay.delayFor(200); + if (this.parentElement) this.parentElement.removeChild(this); + if (this.windowLayer) await this.windowLayer.destroy(); + zIndexRegistry.unregister(this); +} +``` + +**Note on `body.append(windowLayer)`:** `DeesWindowLayer.createAndShow` already appends the window layer to `document.body` (line 27 of `dees-windowlayer.ts`). `dees-modal.ts:71` still calls `body.append(modal.windowLayer)` — that's either a no-op (already-attached nodes) or a re-parent to keep ordering. I will match dees-modal's exact sequence verbatim to avoid introducing subtle differences; if it's a bug in dees-modal it is out of scope for this task. + +**Minimum new scope for createAndShow:** just `steps` for now. No `onComplete`, no `showCloseButton`, no width options. Future-proofing via additional options is an explicit follow-up — this plan keeps scope razor-sharp (per CLAUDE.md). The caller can already wire completion via the last step's `validationFunc` calling back into their own code. + +### 3. Render template — wrap each step in `` + +```ts +public render() { + return html` +
+ ${this.steps.map((stepArg, i) => { + const isSelected = stepArg === this.selectedStep; + const isHidden = this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep); + const isFirst = i === 0; + const stepNumber = i + 1; + return html` + +
+ ${!isFirst + ? html`
+ go to previous step +
` + : html`
`} +
Step ${stepNumber} of ${this.steps.length}
+
+
+
${stepArg.title}
+
${stepArg.content}
+
+ ${stepArg.footerContent + ? html`` + : ''} +
+ `; + })} +
+ `; +} +``` + +**Key detail:** on the first step, render a `.goBack-spacer` (empty div) in the header instead of nothing — so the `stepCounter` stays right-aligned via `justify-content: space-between`. Without a spacer, flex would left-align the counter on step 1. + +### 4. CSS changes + +**Remove from `.step`:** +- `border-radius: 12px;` +- `background: ${cssManager.bdTheme(...)};` +- `border: 1px solid ${cssManager.bdTheme(...)};` +- `color: ${cssManager.bdTheme(...)};` +- `box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);` +- `overflow: hidden;` + +**Why:** `dees-tile` owns all of these now. The `.step` selector still exists (since `dees-tile` has `class="step ..."` on it), but it only controls the outer animation wrapper: `max-width`, `min-height`, `margin`, `filter`, `transform`, `transition`, `user-select`, `pointer-events`. + +**Keep on `.step`:** +- `position: relative;` +- `pointer-events: none;` + `.step.selected { pointer-events: all; }` +- `max-width: 500px;` / `min-height: 300px;` +- `margin: auto; margin-bottom: 20px;` +- `filter: opacity(0.55) saturate(0.85);` + `.selected { filter: opacity(1) saturate(1); }` +- `.hiddenStep { filter: opacity(0); }` +- All the cubic-bezier transitions (transform/filter/box-shadow — but box-shadow is now a no-op since dees-tile provides the shadow; leave the transition spec in so we don't have to re-check browser parsing; or just drop `box-shadow` from the transition list — I'll drop it for cleanliness). +- `.step.entrance` + `.step.entrance.hiddenStep { transform: translateY(16px); }` +- `.step:last-child { margin-bottom: 100vh; }` + +**Add for dees-tile shadow enhancement:** use `::part(outer)` to apply the modal-style elevated shadow only when in overlay mode (optional polish — inline mode stays flat): +```css +.stepperContainer.overlay dees-tile.step::part(outer) { + box-shadow: + 0 0 0 1px ${cssManager.bdTheme('hsl(0 0% 0% / 0.03)', 'hsl(0 0% 100% / 0.03)')}, + 0 8px 40px ${cssManager.bdTheme('hsl(0 0% 0% / 0.12)', 'hsl(0 0% 0% / 0.5)')}, + 0 2px 8px ${cssManager.bdTheme('hsl(0 0% 0% / 0.06)', 'hsl(0 0% 0% / 0.25)')}; +} +``` +This exactly mirrors the dees-modal::part(outer) shadow stack (dees-modal.ts:157–161) so the overlay stepper reads as "same visual language as modal." + +**Restyle `.step-header` (NEW — the `
`):** +```css +.step-header { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + gap: 12px; +} +``` + +**Restyle `.step .stepCounter` → `.step-header .stepCounter` (move from absolute to flex child):** +- Drop `position: absolute; top: 12px; right: 12px;` +- Keep everything else (padding, font-size, border-radius, background, border). + +**Restyle `.step .goBack` → `.step-header .goBack` (move from absolute to flex child):** +- Drop `position: absolute; top: 12px; left: 12px;` +- Keep everything else (padding, font-size, border-radius, background, border, hover/active states). + +**Add `.goBack-spacer`:** +```css +.goBack-spacer { width: 1px; } /* placeholder so flex space-between works on step 1 */ +``` + +**Restyle `.step .title`:** +- Drop `padding-top: 64px;` — no longer overlaps anything since header is in its own slot. +- Keep `text-align: center; font-family: 'Geist Sans', sans-serif; font-size: 24px; font-weight: 600; letter-spacing: -0.01em; color: inherit;` +- Add `padding-top: 32px;` (or similar) so there's consistent breathing room above the title inside the tile content. + +**Add `.step-footer` (new container for `stepArg.footerContent`):** +```css +.step-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; +} +``` + +**Add overlay-mode positioning:** +```css +.stepperContainer { + position: absolute; + width: 100%; + height: 100%; + overflow: hidden; +} +.stepperContainer.overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; +} +.stepperContainer.predestroy { + opacity: 0; + transition: opacity 0.2s ease-in; +} +``` + +**Adjust `:host` for dual-mode:** +```css +:host { + position: absolute; /* inline default */ + width: 100%; + height: 100%; + font-family: ${cssGeistFontFamily}; + color: var(--dees-color-text-primary); +} +:host([overlay]) { + position: fixed; /* overlay mode */ + top: 0; + left: 0; + width: 100vw; + height: 100vh; +} +``` + +The `overlay` @state needs to reflect to an attribute for the `:host([overlay])` selector to work. Since `@state` doesn't reflect attributes, use `@property({ type: Boolean, reflect: true })` instead — change the decorator accordingly. + +### 5. Imports to add in `dees-stepper.ts` + +```ts +import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js'; +import { zIndexRegistry } from '../../00zindex.js'; +import { cssGeistFontFamily } from '../../00fonts.js'; +import '../../00group-layout/dees-tile/dees-tile.js'; +``` + +`dees-tile` side-effect import registers the custom element. `cssGeistFontFamily` is only needed if I add it to `:host` (which I want, to match modal). + +### 6. SweetScroll selector stability + +`setScrollStatus()` selectors target `.step` and `.selected` (lines 228–229). These continue to match since I'm keeping those class names on the `` elements. **No selector changes needed.** + +One subtlety: `offsetTop` / `offsetHeight` on `` should still work — the tile's `:host` is `display: flex; flex-direction: column;` which participates in layout. I'll verify visually in the demo. + +### 7. Demo update + +**File:** `ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts` + +Current demo renders one inline stepper directly. I'll keep that and add an **overlay launcher button** above it: + +```ts +export const stepperDemo = () => html` +
+ { + const stepper = await DeesStepper.createAndShow({ + steps: [/* same steps as inline demo */], + }); + }}>Open stepper as overlay +
+ +`; +``` + +Extract the step definitions into a `const demoSteps = [...]` above the template so both the inline and overlay paths reuse them (DRY). Import `DeesStepper` at the top of the demo file. + +## Files to modify + +1. **`ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts`** — main refactor (IStep, imports, render, styles, createAndShow, destroy, overlay state). +2. **`ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts`** — add overlay launcher button, extract shared `demoSteps` const, import `DeesStepper`. + +**Files explicitly NOT modified:** +- `dees-tile.ts` — used as-is via its slot API. +- `dees-windowlayer.ts` — used as-is via `createAndShow` / `destroy` / `click` event. +- `dees-modal.ts` — reference only. +- `00zindex.ts` — reference only. + +## Verification + +1. **Build**: `pnpm run build` — must pass with no TS errors. Pure refactor, no new dependencies, no lib-check regressions expected. + +2. **Inline demo (backward compat)**: + - Start the demo server (port 8080 is already running) and navigate to the dees-stepper demo page. + - Confirm the stepper renders inline exactly like before: first step centered, subsequent steps dimmed below, scroll-through animation on goNext / goBack. + - Fill out the first form, submit → stepper scrolls to step 2. Click goBack → scrolls back. + - Confirm the `dees-tile` frame is visible on each step (rounded, bordered, themed) and that the title + form are inside the tile's content area. + - Confirm goBack button + step counter sit in the tile's header row, space-between, left/right respectively. + +3. **Overlay demo (new path)**: + - Click the "Open stepper as overlay" button. + - Confirm a `dees-windowlayer` with blur appears behind the stepper. + - Confirm the stepper fills the viewport (fixed, 100vw×100vh). + - Confirm z-index stacking: stepper above window layer above page content. + - Click the window layer (outside the tile) → stepper animates out, then destroys along with the window layer. + - Re-open and step through forward & back — behavior identical to inline mode. + +4. **Playwright visual check** (per CLAUDE.md: screenshots MUST go in `.playwright-mcp/`): + - `.playwright-mcp/dees-stepper-inline.png` — inline mode, step 1 with form. + - `.playwright-mcp/dees-stepper-overlay.png` — overlay mode, same step. + - `.playwright-mcp/dees-stepper-overlay-step3.png` — overlay mode mid-flow, to verify scroll-stack visual. + - Both light and dark themes if the demo has a theme toggle. + +5. **Grep sanity**: + - Confirm `dees-stepper` has no new unexpected match locations: `grep dees-stepper ts_web/` should still only match stepper's own files. + - Confirm no `.step` class collisions elsewhere (unlikely — `.step` is a plain class name; all usages should be shadow-scoped to `dees-stepper`). + +## Open assumptions & deferred scope + +These are explicit defaults in this plan. If the user wants different behavior for any of them, they should flag it on review — each is a simple follow-up but not in scope right now (CLAUDE.md: stay focused, no "while we're at it"): + +- **No close button on overlay stepper.** Clicking the window layer backdrop is the only way to dismiss. Matches how dees-modal with `showCloseButton: false` behaves. Can add a close button in a follow-up. +- **No `onComplete` callback in `createAndShow`.** The last step doesn't auto-destroy the overlay — the app controls it via the step's `validationFunc`. Can add a callback option in a follow-up. +- **No width/size options in `createAndShow`.** The step tile continues to use the stepper's existing `max-width: 500px`. Can parameterize in a follow-up. +- **Box-shadow in the `.step` transition list** is dropped from the transition for cleanliness — the box-shadow is now on `dees-tile::part(outer)` and doesn't change between selected/hiddenStep, so transitioning it was already a no-op. +- **`pnpm start` / dev server path**: I'll reuse the existing server on port 8080 that was already listening when this session began; if that server doesn't serve the stepper demo, I'll start wcctools manually. + +## Risk + +- **Low-medium.** The change is localized to one component and its demo. No API removal, only an additive `createAndShow` + an optional `footerContent` field. External consumers of the inline API continue to work if they only set `steps` + `selectedStep`. +- **Biggest risk:** SweetScroll's `offsetTop` / `offsetHeight` measurements on `` may compute differently than on the former `
` because `dees-tile` has an internal `display: flex; flex-direction: column;` host and a `.tile-outer { flex: 1; min-height: 0; }` inner frame. If the scroll math drifts, the mitigation is to keep the `.step` wrapper as an outer `
` that **contains** a ``, rather than putting the class directly on ``. That preserves the exact box model SweetScroll was measuring. I'll try the direct-class approach first (simpler) and fall back to the wrapper approach if the scroll target looks off in the demo. +- **Second risk:** The `:host([overlay])` attribute selector requires `overlay` to be a reflected `@property`, not `@state`. I've already accounted for this in the plan (decorator change). diff --git a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts index c8eae72..8e85fdf 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.demo.ts @@ -1,134 +1,148 @@ import { html } from '@design.estate/dees-element'; +import { DeesStepper, type IStep } from './dees-stepper.js'; + +const demoSteps: IStep[] = [ + { + title: 'Account Setup', + content: html` + + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Profile Details', + content: html` + + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Contact Information', + content: html` + + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Team Size', + content: html` + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Goals', + content: html` + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Brand Preferences', + content: html` + + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Integrations', + content: html` + + + Continue + + `, + validationFunc: async (stepperArg, elementArg) => { + const deesForm = elementArg.querySelector('dees-form'); + deesForm!.addEventListener('formData', () => stepperArg.goNext(), { once: true }); + }, + }, + { + title: 'Review & Launch', + content: html` + +

Almost there! Review your selections and launch whenever you're ready.

+
+ `, + }, +]; + +const cloneSteps = (): IStep[] => demoSteps.map((step) => ({ ...step })); export const stepperDemo = () => html` - - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Profile Details', - content: html` - - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Contact Information', - content: html` - - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Team Size', - content: html` - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Goals', - content: html` - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Brand Preferences', - content: html` - - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Integrations', - content: html` - - - Continue - - `, - validationFunc: async (stepperArg, elementArg) => { - const deesForm = elementArg.querySelector('dees-form'); - deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true }); - }, - }, - { - title: 'Review & Launch', - content: html` - -

Almost there! Review your selections and launch whenever you're ready.

-
- `, - }, - ] as const} - >
+
+
+ { + await DeesStepper.createAndShow({ steps: cloneSteps() }); + }} + >Open stepper as overlay +
+ +
`; diff --git a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts index 23275c4..50e2627 100644 --- a/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts +++ b/ts_web/elements/00group-layout/dees-stepper/dees-stepper.ts @@ -1,5 +1,4 @@ import * as plugins from '../../00plugins.js'; -import * as colors from '../../00colors.js'; import { DeesElement, @@ -16,10 +15,15 @@ import { import * as domtools from '@design.estate/dees-domtools'; import { stepperDemo } from './dees-stepper.demo.js'; import { themeDefaultStyles } from '../../00theme.js'; +import { cssGeistFontFamily } from '../../00fonts.js'; +import { zIndexRegistry } from '../../00zindex.js'; +import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js'; +import '../dees-tile/dees-tile.js'; export interface IStep { title: string; content: TemplateResult; + footerContent?: TemplateResult; validationFunc?: (stepper: DeesStepper, htmlElement: HTMLElement, signal?: AbortSignal) => Promise; onReturnToStepFunc?: (stepper: DeesStepper, htmlElement: HTMLElement) => Promise; validationFuncCalled?: boolean; @@ -34,9 +38,32 @@ declare global { @customElement('dees-stepper') export class DeesStepper extends DeesElement { + // STATIC public static demo = stepperDemo; public static demoGroups = ['Layout', 'Form']; + public static async createAndShow(optionsArg: { + steps: IStep[]; + }): Promise { + const body = document.body; + const stepper = new DeesStepper(); + stepper.steps = optionsArg.steps; + stepper.overlay = true; + stepper.windowLayer = await DeesWindowLayer.createAndShow({ blur: true }); + stepper.windowLayer.addEventListener('click', async () => { + await stepper.destroy(); + }); + body.append(stepper.windowLayer); + body.append(stepper); + + // Get z-index for stepper (should be above window layer) + stepper.stepperZIndex = zIndexRegistry.getNextZIndex(); + zIndexRegistry.register(stepper, stepper.stepperZIndex); + + return stepper; + } + + // INSTANCE @property({ type: Array, }) @@ -47,6 +74,17 @@ export class DeesStepper extends DeesElement { }) accessor selectedStep!: IStep; + @property({ + type: Boolean, + reflect: true, + }) + accessor overlay: boolean = false; + + @property({ type: Number, attribute: false }) + accessor stepperZIndex: number = 1000; + + private windowLayer?: DeesWindowLayer; + constructor() { super(); } @@ -60,7 +98,18 @@ export class DeesStepper extends DeesElement { position: absolute; width: 100%; height: 100%; + font-family: ${cssGeistFontFamily}; + color: var(--dees-color-text-primary); } + + :host([overlay]) { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + .stepperContainer { position: absolute; width: 100%; @@ -68,101 +117,120 @@ export class DeesStepper extends DeesElement { overflow: hidden; } - .step { + .stepperContainer.overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + .stepperContainer.predestroy { + opacity: 0; + transition: opacity 0.2s ease-in; + } + + dees-tile.step { position: relative; pointer-events: none; - overflow: hidden; - transition: transform 0.7s cubic-bezier(0.87, 0, 0.13, 1), box-shadow 0.7s cubic-bezier(0.87, 0, 0.13, 1), filter 0.7s cubic-bezier(0.87, 0, 0.13, 1), border 0.7s cubic-bezier(0.87, 0, 0.13, 1); max-width: 500px; min-height: 300px; - border-radius: 12px; - background: ${cssManager.bdTheme('#ffffff', '#0f0f11')}; - border: 1px solid ${cssManager.bdTheme('#e2e8f0', '#272729')}; - color: ${cssManager.bdTheme('#0f172a', '#f5f5f5')}; margin: auto; margin-bottom: 20px; filter: opacity(0.55) saturate(0.85); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + transition: transform 0.7s cubic-bezier(0.87, 0, 0.13, 1), filter 0.7s cubic-bezier(0.87, 0, 0.13, 1); user-select: none; } - .step.selected { + dees-tile.step.selected { pointer-events: all; filter: opacity(1) saturate(1); user-select: auto; } - .step.hiddenStep { + dees-tile.step.hiddenStep { filter: opacity(0); } - .step.entrance { - transition: transform 0.35s ease, box-shadow 0.35s ease, filter 0.35s ease, border 0.35s ease; + dees-tile.step.entrance { + transition: transform 0.35s ease, filter 0.35s ease; } - .step.entrance.hiddenStep { + dees-tile.step.entrance.hiddenStep { transform: translateY(16px); } - .step:last-child { + dees-tile.step:last-child { margin-bottom: 100vh; } - .step .stepCounter { - color: ${cssManager.bdTheme('#64748b', '#a1a1aa')}; - position: absolute; - top: 12px; - right: 12px; - padding: 6px 14px; - font-size: 12px; - border-radius: 999px; - background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.5)', 'rgba(63, 63, 70, 0.45)')}; - border: 1px solid ${cssManager.bdTheme('rgba(226, 232, 240, 0.7)', 'rgba(63, 63, 70, 0.6)')}; + .stepperContainer.overlay dees-tile.step::part(outer) { + box-shadow: + 0 0 0 1px ${cssManager.bdTheme('hsl(0 0% 0% / 0.03)', 'hsl(0 0% 100% / 0.03)')}, + 0 8px 40px ${cssManager.bdTheme('hsl(0 0% 0% / 0.12)', 'hsl(0 0% 0% / 0.5)')}, + 0 2px 8px ${cssManager.bdTheme('hsl(0 0% 0% / 0.06)', 'hsl(0 0% 0% / 0.25)')}; } - .step .goBack { - position: absolute; - top: 12px; - left: 12px; + .step-header { + height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 12px; + gap: 12px; + } + + .goBack-spacer { + width: 1px; + } + + .step-header .goBack { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 12px; + height: 24px; + padding: 0 8px; font-size: 12px; font-weight: 500; - border-radius: 999px; - border: 1px solid ${cssManager.bdTheme('rgba(226, 232, 240, 0.9)', 'rgba(63, 63, 70, 0.85)')}; - background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.9)', 'rgba(39, 39, 42, 0.85)')}; - color: ${cssManager.bdTheme('#475569', '#d4d4d8')}; + line-height: 1; + border: none; + background: transparent; + color: var(--dees-color-text-muted); + border-radius: 4px; cursor: pointer; - transition: border 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease; + transition: background 0.15s ease, color 0.15s ease, transform 0.2s ease; } - .step .goBack:hover { - color: ${cssManager.bdTheme('#0f172a', '#fafafa')}; - border-color: ${cssManager.bdTheme(colors.dark.blue, colors.dark.blue)}; - background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.95)', 'rgba(63, 63, 70, 0.7)')}; + .step-header .goBack:hover { + background: var(--dees-color-hover); + color: var(--dees-color-text-secondary); transform: translateX(-2px); } - .step .goBack:active { - color: ${cssManager.bdTheme('#0f172a', '#fafafa')}; - border-color: ${cssManager.bdTheme(colors.dark.blueActive, colors.dark.blueActive)}; - background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.85)', 'rgba(63, 63, 70, 0.6)')}; + .step-header .goBack:active { + background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(0 0% 15%)')}; } - .step .goBack span { + .step-header .goBack span { transition: transform 0.2s ease; display: inline-block; } - .step .goBack:hover span { + .step-header .goBack:hover span { transform: translateX(-2px); } - .step .title { + .step-header .stepCounter { + color: var(--dees-color-text-muted); + font-size: 12px; + font-weight: 500; + letter-spacing: -0.01em; + padding: 0 8px; + } + + .step-body .title { text-align: center; - padding-top: 64px; + padding-top: 32px; font-family: 'Geist Sans', sans-serif; font-size: 24px; font-weight: 600; @@ -170,35 +238,53 @@ export class DeesStepper extends DeesElement { color: inherit; } - .step .content { + .step-body .content { padding: 32px; } + + .step-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + } `, ]; public render() { return html` -
- ${this.steps.map( - (stepArg) => - html`
- ${this.getIndexOfStep(stepArg) > 0 - ? html`
<- go to previous step
` - : ``} +
+ ${this.steps.map((stepArg, stepIndex) => { + const isSelected = stepArg === this.selectedStep; + const isHidden = + this.getIndexOfStep(stepArg) > this.getIndexOfStep(this.selectedStep); + const isFirst = stepIndex === 0; + return html` +
+ ${!isFirst + ? html`
+ <- go to previous step +
` + : html`
`}
- Step ${this.steps.findIndex((elementArg) => elementArg === stepArg) + 1} of - ${this.steps.length} + Step ${stepIndex + 1} of ${this.steps.length}
+
+
${stepArg.title}
${stepArg.content}
-
` - )} +
+ ${stepArg.footerContent + ? html`` + : ''} + `; + })}
`; } @@ -296,4 +382,20 @@ export class DeesStepper extends DeesElement { nextStep.validationFuncCalled = false; this.selectedStep = nextStep; } + + public async destroy() { + const domtools = await this.domtoolsPromise; + const container = this.shadowRoot!.querySelector('.stepperContainer'); + container?.classList.add('predestroy'); + await domtools.convenience.smartdelay.delayFor(200); + if (this.parentElement) { + this.parentElement.removeChild(this); + } + if (this.windowLayer) { + await this.windowLayer.destroy(); + } + + // Unregister from z-index registry + zIndexRegistry.unregister(this); + } }