From 735fdadeafcbe17f31bfaa3a93ab6d14bf6475b7 Mon Sep 17 00:00:00 2001 From: Hong Date: Thu, 25 Jun 2026 21:44:12 +0200 Subject: [PATCH 01/48] init: create the library --- .editorconfig | 17 + .gitignore | 62 +- .prettierrc | 12 + .vscode/extensions.json | 4 + .vscode/launch.json | 20 + .vscode/mcp.json | 9 + .vscode/tasks.json | 42 + README.md | 59 + ROADMAP.md | 143 + angular.json | 102 + package-lock.json | 9653 +++++++++++++++++ package.json | 36 + projects/angular-inline-select/README.md | 64 + .../angular-inline-select/ng-package.json | 7 + projects/angular-inline-select/package.json | 15 + .../angular-inline-text.html | 53 + .../angular-inline-text.scss | 29 + .../angular-inline-text.spec.ts | 26 + .../angular-inline-text.ts | 253 + .../directives/editable-overlay-control.ts | 121 + .../directives/overlay-width-sync.ts | 294 + .../restrict-characters.ts | 106 + .../directives/restrict-characters/tokens.ts | 36 + .../directives/textarea-autosize.ts | 35 + .../editable-action-buttons.html | 36 + .../editable-action-buttons.scss | 52 + .../editable-action-buttons.ts | 23 + .../editable-wrapper/editable-wrapper.html | 65 + .../editable-wrapper/editable-wrapper.scss | 3 + .../editable-wrapper/editable-wrapper.ts | 257 + .../src/lib/styles/inline-text.scss | 547 + .../angular-inline-select/src/public-api.ts | 12 + .../angular-inline-select/tsconfig.lib.json | 21 + .../tsconfig.lib.prod.json | 17 + .../angular-inline-select/tsconfig.spec.json | 18 + projects/app/public/favicon.ico | Bin 0 -> 15086 bytes projects/app/src/app/app.config.ts | 8 + projects/app/src/app/app.html | 113 + projects/app/src/app/app.routes.ts | 3 + projects/app/src/app/app.scss | 216 + projects/app/src/app/app.spec.ts | 16 + projects/app/src/app/app.ts | 130 + projects/app/src/app/login/login.html | 19 + projects/app/src/app/login/login.scss | 7 + projects/app/src/app/login/login.spec.ts | 22 + projects/app/src/app/login/login.ts | 28 + projects/app/src/index.html | 24 + projects/app/src/main.ts | 5 + projects/app/src/styles.scss | 45 + projects/app/tsconfig.app.json | 19 + projects/app/tsconfig.spec.json | 10 + tsconfig.json | 42 + 52 files changed, 12933 insertions(+), 23 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/mcp.json create mode 100644 .vscode/tasks.json create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 angular.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 projects/angular-inline-select/README.md create mode 100644 projects/angular-inline-select/ng-package.json create mode 100644 projects/angular-inline-select/package.json create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts create mode 100644 projects/angular-inline-select/src/lib/styles/inline-text.scss create mode 100644 projects/angular-inline-select/src/public-api.ts create mode 100644 projects/angular-inline-select/tsconfig.lib.json create mode 100644 projects/angular-inline-select/tsconfig.lib.prod.json create mode 100644 projects/angular-inline-select/tsconfig.spec.json create mode 100644 projects/app/public/favicon.ico create mode 100644 projects/app/src/app/app.config.ts create mode 100644 projects/app/src/app/app.html create mode 100644 projects/app/src/app/app.routes.ts create mode 100644 projects/app/src/app/app.scss create mode 100644 projects/app/src/app/app.spec.ts create mode 100644 projects/app/src/app/app.ts create mode 100644 projects/app/src/app/login/login.html create mode 100644 projects/app/src/app/login/login.scss create mode 100644 projects/app/src/app/login/login.spec.ts create mode 100644 projects/app/src/app/login/login.ts create mode 100644 projects/app/src/index.html create mode 100644 projects/app/src/main.ts create mode 100644 projects/app/src/styles.scss create mode 100644 projects/app/tsconfig.app.json create mode 100644 projects/app/tsconfig.spec.json create mode 100644 tsconfig.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f166060 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 0383c3a..854acd5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,44 @@ -# Angular specific -/dist/ -/out-tsc/ -/tmp/ -/coverage/ -/e2e/test-output/ -/.angular/ -.angular/ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. -# Node modules and dependency files -/node_modules/ -/package-lock.json -/yarn.lock +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out -# Environment files -/.env +# Node +/node_modules +npm-debug.log +yarn-error.log -# Angular CLI and build artefacts -/.angular-cli.json -/.ng/ +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace -# TypeScript cache -*.tsbuildinfo +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/mcp.json +.history/* -# Logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings +__screenshots__/ + +# System files +.DS_Store +Thumbs.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d6c16d7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "singleQuote": true, + "overrides": [ + { + "files": "*.html", + "options": { + "parser": "angular" + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..77b3745 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..925af83 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..956af8c --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + // For more information, visit: https://angular.dev/ai/mcp + "servers": { + "angular-cli": { + "command": "npx", + "args": ["-y", "@angular/cli", "mcp"] + } + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..244306f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "Changes detected" + }, + "endsPattern": { + "regexp": "bundle generation (complete|failed)" + } + } + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d081059 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# AngularInlineSelect + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.11. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d0d42d7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,143 @@ +# ROADMAP — angular-inline-text + +Goal: an inline edit component that emits the changed value on save (`savedModelChange`), auto-reverts non-accepted values, and notifies the parent via a `reverted` output. Angular 22, zoneless, signal-based, standalone. Styled with `--mat-sys-*` tokens (with fallbacks), built primarily on `@angular/aria` / CDK, Material only where unavoidable. + +Contract decisions (agreed): +- Live value propagation stays (dual mode with injected `FormField` is kept). +- New `reverted` output fires whenever a draft is discarded (Escape, decline, outside-click decline, detach revert). +- Pre-1.0: breaking API changes are allowed. + +--- + +## What is already good (preserve) + +- Pure signal architecture: `computed`, `linkedSignal`, `model`, host bindings — no zone reliance, no RxJS in the hot path. +- `FormValueControl` + signal-forms integration; standalone fallback via local `form()`. +- CDK `cdkConnectedOverlay` with paired top/bottom positions, `cdkTrapFocus`, appearance variants (`fill`/`outline`). +- Theming via `--mat-sys-*` tokens with fallbacks; `prefers-reduced-motion` handled; `field-sizing: content` with graceful fallback. +- `RestrictCharacters` strategy pattern: IME-safe (`compositionstart/end`), delegates `beforeinput`/`paste`/`keydown` — good extension point. +- `OverlayWidthSyncDirective`: ResizeObserver + rAF-throttled reposition, context-over-input resolution. + +--- + +## Phase 0 — Bug fixes (no visual change) + +- [ ] **Blur guard selector mismatch**: `EditableOverlayControl.onBlur` checks `closest('.editable-panel')`, but the panel renders `.editable-panel__inner` / `.iusta-editable-panel`. The guard never matches → overlay can close while focus moves into it. Fix selector (or better: compare against the overlay element ref instead of a class string). +- [ ] **Dead code**: `provideAutosize()` is never called; `scrollStrategy` computed in `OverlayWidthSyncDirective` is never used; `copyValue` signal is never written; `warningMessage` linkedSignal always computes the same constant. Remove or wire up. +- [ ] **Duplicate autosize effect**: `resize` effect and `provideAutosize()` are copies. Keep exactly one code path (see Phase 2 — likely neither). +- [ ] **`accepted` mutable boolean + `autoResetAccepted` effect**: ordering-fragile (accept → detach race). Replace with a signal or reset it synchronously where state transitions happen; delete the effect. +- [ ] **`errorMessage`** in wrapper returns static `'Invalid input'` while the template already renders real error messages — deduplicate (panelMessage vs. template `@for` over errors render competing messages). + +## Phase 1 — Contract: FormValueControl + save / revert semantics + +First-class signal-forms citizenship — the component must behave identically in all three modes: bound via `[formField]` (signal forms), plain `[(value)]` model binding, and fully standalone. + +- [ ] **Implement the FormValueControl contract natively** instead of injecting `FormField` and mirroring its state: declare `disabled = input(false)`, `readonly = input(false)`, `required = input(false)`, `errors = input([])` — the form binds these automatically. Remove the `inject(FormField)` workaround. +- [ ] **Accept must write the value model**: today `accept()` only emits `savedModelChange`; `value.set(normalized)` is the actual channel signal forms and `[(value)]` consumers listen to. Order: `value.set()` → `savedModelChange.emit()` → close. +- [ ] **Add `touched = model(false)`**: set on first edit-session end (blur/close) so the form's touched state is real. +- [ ] **Drop the nested mirror `form()`** whose `validate()` replays parent errors (double validation). Keep a local `form()` only for the *draft* (draft-local validation like restrict/normalize checks); bound-field errors come in via the `errors` input. +- [ ] Live propagation stays: keystrokes update `value` while editing; revert sets `value` back to `previous` and emits `reverted`. +- [ ] Add `reverted = output()` (payload: the discarded draft value). Emit on: Escape decline, Discard button, outside-click decline, detach-revert in `handleDetach`. +- [ ] Single choke point for "discard": today revert logic is spread across `(declined)="localForm().reset(previous)"` in the template, `handleDetach`, and wrapper decline handling. Route all paths through one `revert()` method in `AngularInlineText`. +- [ ] Enter accepts on single-line input (currently only Ctrl+Enter). Keep Ctrl+Enter for textarea. +- [ ] `previous` linkedSignal reads `dirty()` inside its computation — document or refactor; latching behavior is correct today but non-obvious and easy to break. Consider explicit `previous = signal()` set at edit-session start (`showForm` → true) instead. +- [ ] Review `handleOutsideClick` half-viewport Euclidean distance gate: replace magic threshold with an input (`declineDistance`) or a simpler rule (click on another `.iusta-editable-wrapper` → decline; otherwise refocus). Document whichever stays. +- [ ] Tests (vitest): accept sets `value` and emits once with normalized value; decline/detach reverts and emits `reverted`; invalid blocks accept; `normalizeValue` trims before compare (no false-dirty); all three binding modes covered (signal form / `[(value)]` / standalone); `touched`/`disabled`/`readonly` round-trip with a real `form()`. + +## Phase 2 — Performance + +- [ ] **Autosize** *(superseded by Phase 4 contenteditable — height becomes text flow; skip if Phase 4 lands first)*: per keystroke today = input-handler resize + effect rAF resize, each doing `height='auto'` + `scrollHeight` read → multiple forced reflows. Interim: `field-sizing: content` primary, `TextareaAutosize` as `@supports not` fallback, delete the component-level effects; reposition overlay via `afterRenderEffect`. +- [ ] **ResizeObserver lifecycle**: observer runs from `ngAfterViewInit` forever, on every instance (a page of 50 inline fields = 50 live observers). Interim fix: observe only while the overlay is open. Superseded by Phase 4's view/edit split, which deletes the observer entirely — if Phase 4 lands first, skip this. +- [ ] **Template object identity**: `[mEditableOverlayControl]="{ showSignal: showForm }"` allocates a fresh object every template execution. Bind the signal directly (split into two inputs) or build the object once in the component. +- [ ] **Overlay config churn**: `overlayConfig` recomputes on every width change while open. Verify CdkConnectedOverlay diffing; if it rebuilds, pass width via `cdkConnectedOverlayWidth` only. +- [ ] Measure before/after: Chrome performance trace of typing in a multiline field; assert single layout pass per keystroke. + +## Phase 3 — Accessibility (@angular/aria first) + +- [ ] `@angular/aria` is a dependency but unused — adopt it for the combobox-like pattern (input + owned panel) where it fits; fall back to CDK a11y, Material last. +- [ ] Wire input ↔ panel: `aria-expanded`, `aria-controls`, panel `role` (likely `dialog` for the confirm card), `aria-describedby` for error/warning messages. +- [ ] Error/warning messages in a live region (`aria-live="polite"`), so screen readers hear validation without focus moves. +- [ ] Replace hand-rolled `handleTab` querySelector-focusable-walk with CDK `FocusTrap.focusFirstTabbableElement()` / `InteractivityChecker` — the panel already has `cdkTrapFocus`. +- [ ] Panel `
` review: focusable container without a role is noise; give it a role or drop the tabindex. +- [ ] Clear button: confirm hover-reveal doesn't hide it from keyboard/AT (it's `visibility: hidden` until `:focus-within` — verify tab order reaches it and add `aria-label` audit). +- [ ] Keyboard spec written down: Enter (single-line save), Ctrl+Enter (textarea save), Escape (revert), Tab-while-dirty (into panel). + +## Phase 4 — Appearance & motion (Framer-grade) + +### Non-negotiables (agreed) + +1. **Per-line dashed underline in multiline** — the underline hugs each text line and stops where the text stops (never covers empty input space). +2. Opening/edit transition must feel smooth and designed, not a restyle-snap. +3. Action/clear buttons float — they reserve **no** layout space (must also work inside `mat-dialog`). +4. Spring-based, interruptible motion (Cheng Lou / react-motion philosophy: no fixed-duration feel, velocity-preserving). + +### Editing surface: contenteditable (the structural fix — decided) + +A textarea is a rectangular box — no border, `::after`, or background can ever hug wrapped text lines, and it can never wrap inline within a paragraph. The ResizeObserver + width-sync + autosize machinery exists only to fight these symptoms. The Notion answer: the rendered text IS the editor. + +- [ ] **Single surface**: the inline span carries `contenteditable="plaintext-only"`. Same element at rest and while editing — true inline flow in paragraphs in both states, wraps mid-line, never pushes surrounding text, zero layout shift on click, native caret-at-click-point. +- [ ] **Underline**: `text-decoration: underline dashed` + `text-underline-offset` (native text paint — most performant). Fallback to `repeating-linear-gradient` + `box-decoration-break: clone` only if dash geometry needs exact control. Per-line, text-hugging, in view *and* edit state. +- [ ] **Delete the machinery**: `TextareaAutosize`, `field-sizing` juggling, `OverlayWidthSyncDirective` ResizeObserver, span↔textarea metric parity — all removed. Height/width are just text flow. +- [ ] **Value sync**: `textContent` ↔ draft signal on `input` events; `FormValueControl` contract (Phase 1) is untouched — it lives on the component, not the element. +- [ ] **plaintext-only support**: requires Firefox 136+ (2025). Fallback path: plain `contenteditable` + `beforeinput` filtering of `insertFromPaste`/formatting — `RestrictCharacters` already hooks exactly these events; extend it to double as the fallback sanitizer. +- [ ] **Single-line variant**: block `insertParagraph`/`insertLineBreak` in `beforeinput` (Enter = accept instead); `white-space: nowrap` + fade-out mask at overflow. +- [ ] **A11y wiring (moves from nice-to-have to required)**: `role="textbox"`, `aria-multiline`, `aria-invalid`, `aria-readonly`; label association; verify SR announcement of edit mode. IME test matrix (CJK composition on contenteditable). +- [ ] **Disabled/readonly**: `contenteditable=false` + cursor/appearance states; ensure copy still works. +- [ ] **Floating actions**: clear button (and future affordances) in an absolutely positioned rail pinned to the wrapper edge — zero layout shift, fade+scale entrance. Verify inside `mat-dialog` (stacking context, overflow clipping); CDK overlay panel already escapes the dialog. + +### Motion system + +- [ ] **Spring easings, web-native**: CSS `linear()` easings generated from spring curves for enters/exits (no JS, no lib); a tiny WAAPI spring helper only where mid-flight interruption matters (expansion morph). Duration tokens become spring presets (`--iusta-spring-snappy`, `--iusta-spring-gentle`). +- [ ] **Overlay exit animation**: only `animate.enter` exists — the panel pops out. Add `animate.leave` (fade + 4px translate + slight scale-down, accelerate). Exit never blocks interaction. +- [ ] **Panel internal height choreography**: error/warning/action rows cause hard jumps. `interpolate-size: allow-keywords` + height transition, grid-rows `0fr → 1fr` fallback; messages fade/slide staggered ~30ms after the container settles. +- [ ] **Micro-interactions**: Save morphs to checkmark on success (~300ms, then close); invalid accept = ±3px x-shake (200ms); pressed-state scale 0.97 with spring-back; floating buttons fade+scale rather than visibility-flip. +- [ ] **Performance budget**: compositor-only (`transform`/`opacity`) except the sanctioned height choreography. 60fps trace while typing with panel open. +- [ ] **Reduced motion parity**: every animation gets a `prefers-reduced-motion` branch (opacity-only or none). + +## Phase 5 — Theming & styles (preserve the look) + +- [ ] Replace hard-coded `#428bca` default with token chain: `var(--iusta-editable-color, var(--mat-sys-primary, #428bca))`. +- [ ] Overlay pixel offsets (`VISUAL_Y_OFFSET = 7.5` "eyeballed at 13px font") break at other root font sizes. Derive from the same CSS custom properties the SCSS uses (read once per attach via `getComputedStyle`) or express insets in rem on both sides. +- [ ] Remove inline styles from templates (`style="margin-left: 8px"`, `[style.marginLeft]="'-1rem'"`) → SCSS classes. +- [ ] Document the public CSS API: every `--iusta-*` variable, in the README, with defaults. +- [ ] Visual regression: before/after screenshots of fill/outline × empty/filled × idle/editing/invalid, light + dark. + +## Phase 6 — API & DX (pre-1.0 cleanup) + +- [ ] Consistent selector prefix: `angular-inline-text` vs `m-editable-wrapper` vs `mEditableOverlayControl` vs `iusta-*` CSS. Pick one prefix (suggest `iusta`) for selectors, directives, and CSS. +- [ ] Trim public API: `public-api.ts` exports everything, including internals (`OverlayWidthSyncDirective` context plumbing). Export only what consumers compose. +- [ ] Package naming: library is `angular-inline-select` but ships an inline *text* component — align name before publishing, or document the select roadmap. +- [ ] `EditableOverlayControl.state()` throws inside a `computed` when no form is provided — fail at construction time with a clear message instead. +- [ ] README: usage with signal forms, standalone usage, normalization behavior, keyboard map, theming variables. + +--- + +## House style (apply to all new/touched code) + +Conventions distilled from the existing codebase — every phase's code follows these. + +**Imports** — grouped with banner comments, in fixed order: Angular core (with a `// Signal` sub-group for signal primitives), Material & CDK, third-party, core infrastructure (services/models/enums/pipes), shared UI components, domain-specific components. The `@Component.imports` array gets the same grouping comments (`// Material`, `// Pipes`, `// Components`). + +**Dependency injection** — `inject()` only, never constructor injection. Injected services are native private fields: `#document = inject(Document)`. + +**Signals first** — `input()` / `model()` / `output()` / `computed()` / `signal()` / `linkedSignal()` / `viewChild()`; no decorators. Derive, don't store: state that can be computed from other signals is a `computed()` (e.g. selections derived from view children), never a synced copy. Compose small named computeds into larger ones (clause → and → where pattern) instead of one monolithic computation. + +**Host over template wrappers** — bindings and listeners in `host` metadata (`'[attr.id]': '_id()'`, `'[class.x]': 'cond()'`), not `@HostBinding`/`@HostListener`, not wrapper divs. + +**Class body organization** — section separators (`/// Getters`, `/// Lifecycle`, or `// ---` banners) grouping: DI, inputs/models, derived signals, handlers, lifecycle. JSDoc on every public API member (inputs, models, outputs, public methods) explaining intent, not mechanics. + +**Control flow** — guard clauses and early returns over nesting. Action dispatch via discriminated unions + `switch` (`DocumentTableRowAction` pattern) rather than boolean flag parameters. + +**Lazy boundaries** — heavy or rarely-used UI (dialogs) loaded via dynamic `import().then(({ X }) => dialog.open(X, ...))` at the call site; keeps the eager bundle lean. + +**Async hygiene** — cancellable requests take an `AbortSignal`; resolvers receive the signal explicitly. + +**Naming** — verb-first handlers (`handleX`, `openX`, `clearX`), `bulkX` for multi-row operations, `isX`/`hasX` for boolean signals and helpers, `X = model(...)` / `X = input(...)` names describe the datum not the mechanism. + +--- + +## Verification (every phase) + +1. `ng test` (vitest) green; new behavior covered in Phase 1/2 specs. +2. `ng build` library + app, zoneless app boots without change-detection warnings. +3. Manual pass in the demo app: single-line, multiline, outline, required, restricted-input fields — look must be pixel-identical except where a phase says otherwise. diff --git a/angular.json b/angular.json new file mode 100644 index 0000000..7c4f85a --- /dev/null +++ b/angular.json @@ -0,0 +1,102 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "npm" + }, + "newProjectRoot": "projects", + "projects": { + "app": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "root": "projects/app", + "sourceRoot": "projects/app/src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "projects/app/src/main.ts", + "tsConfig": "projects/app/tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + { + "glob": "**/*", + "input": "projects/app/public" + } + ], + "styles": ["projects/app/src/styles.scss"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "configurations": { + "production": { + "buildTarget": "app:build:production" + }, + "development": { + "buildTarget": "app:build:development" + } + }, + "defaultConfiguration": "development" + }, + "test": { + "builder": "@angular/build:unit-test" + } + } + }, + "angular-inline-select": { + "projectType": "library", + "root": "projects/angular-inline-select", + "sourceRoot": "projects/angular-inline-select/src", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "configurations": { + "production": { + "tsConfig": "projects/angular-inline-select/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/angular-inline-select/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "projects/angular-inline-select/tsconfig.spec.json" + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..766fa55 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9653 @@ +{ + "name": "angular-inline-select", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "angular-inline-select", + "version": "0.0.0", + "dependencies": { + "@angular/aria": "^22.0.2", + "@angular/cdk": "^22.0.2", + "@angular/common": "^22.0.3", + "@angular/compiler": "^22.0.3", + "@angular/core": "^22.0.3", + "@angular/forms": "^22.0.3", + "@angular/material": "^22.0.2", + "@angular/platform-browser": "^22.0.3", + "@angular/router": "^22.0.3", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.0.4", + "@angular/cli": "^22.0.4", + "@angular/compiler-cli": "^22.0.3", + "jsdom": "^28.0.0", + "ng-packagr": "^22.0.0", + "prettier": "^3.8.1", + "typescript": "~6.0.3", + "vitest": "^4.0.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@algolia/abtesting": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz", + "integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz", + "integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz", + "integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz", + "integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz", + "integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz", + "integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz", + "integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz", + "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz", + "integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz", + "integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz", + "integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz", + "integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz", + "integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz", + "integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.2200.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2200.4.tgz", + "integrity": "sha512-X/iEQiZ0pRmpjUt11jCM+mtOLRl4XxI9hnM0IC9aAcsm5AzRBb9WY6QIEqOSficjxC/+MI7MGwrerrcP6QN8UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.4", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.0.4.tgz", + "integrity": "sha512-zA2UJSMAU3su5uJTOn5ul/gCLRcw6/uIQ6EC5v/Ju/ePjgDIw9R3y3MAvWQ4Ibi/fXiq0FVxpF8hE7RUclYmJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.0.4.tgz", + "integrity": "sha512-VRxL1hD/Q3TQglM6EfQ0ksAW4OIvtKyZgtaUpyGsJRfD6tGmLFn7MDnmyyq1ceLX/Clq+3tzH/wN0tF6rcE0jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.4", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.4.0", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/aria": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/aria/-/aria-22.0.2.tgz", + "integrity": "sha512-K0eSN64ywJ0GG162PUV6eu0vXNkBhB6QAtt7oXW5V4OsjqgDo+kxCAZpeNGCNpwmjC7xa5DOHXfGOiRNrzUpWw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "22.0.2", + "@angular/core": "^22.0.0 || ^23.0.0" + } + }, + "node_modules/@angular/build": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.0.4.tgz", + "integrity": "sha512-hst/KhP5mMPajY32l7qmyW/5fuqrMHCjHEeNhMMNqP82+j8jPBFfMEL26AH9w2kIIdKGpC3WKN5JgLotyFD7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.2200.4", + "@babel/core": "7.29.0", + "@babel/helper-annotate-as-pure": "7.27.3", + "@babel/helper-split-export-declaration": "7.24.7", + "@inquirer/confirm": "6.0.12", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.4.2", + "browserslist": "^4.26.0", + "esbuild": "0.28.1", + "https-proxy-agent": "9.0.0", + "jsonc-parser": "3.3.1", + "listr2": "10.2.1", + "magic-string": "0.30.21", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.4", + "piscina": "5.2.0", + "rollup": "4.60.2", + "sass": "1.99.0", + "semver": "7.7.4", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.16", + "vite": "7.3.5", + "watchpack": "2.5.1" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.5.4" + }, + "peerDependencies": { + "@angular/compiler": "^22.0.0", + "@angular/compiler-cli": "^22.0.0", + "@angular/core": "^22.0.0", + "@angular/localize": "^22.0.0", + "@angular/platform-browser": "^22.0.0", + "@angular/platform-server": "^22.0.0", + "@angular/service-worker": "^22.0.0", + "@angular/ssr": "^22.0.4", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^22.0.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular/build/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@angular/build/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/build/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@angular/build/node_modules/https-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@angular/build/node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/@angular/build/node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@angular/cdk": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.0.2.tgz", + "integrity": "sha512-3AOyLNIpvXkxbiCeUc4R5ubwCBpY83ZPe2I6Q/cTUW53SnFapEBNYZ2spSY+jPVY4IVPnQN1Tvjlzq6R9K4M3w==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/cli": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.0.4.tgz", + "integrity": "sha512-3eJy6VoNlgskKFzvqy3AJsYXFRhSBLLGObF2iTpJymsukuxWUen7hlVVVWrO5++bW1LEgd6PTCCD5fdT2UuRiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2200.4", + "@angular-devkit/core": "22.0.4", + "@angular-devkit/schematics": "22.0.4", + "@inquirer/prompts": "8.4.2", + "@listr2/prompt-adapter-inquirer": "4.2.3", + "@modelcontextprotocol/sdk": "1.29.0", + "@schematics/angular": "22.0.4", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.52.0", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "10.2.1", + "npm-package-arg": "13.0.2", + "pacote": "21.5.1", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.4.2" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.0.3.tgz", + "integrity": "sha512-LRglsR4Xerw/vrqoEMd489fF5PMJZ3kqGAPwO1332S42lN5KXlYITCB8WJw1iIqvoBqZvlR9R8u85cLUsfDzbg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.0.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.0.3.tgz", + "integrity": "sha512-tZYq4RYYGCT6enI3wlOZsG81VJkR9wGhf5kexhvffCiMHfcA9IACHu7gjvtZPZlibeKs1PY5TMoOG3/rUqJH6Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/compiler-cli": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.0.3.tgz", + "integrity": "sha512-TMpKn2KefhGXM1FzcLYeyTMsBbxtFJU83bYnlI0pTw7k3jBAmN6H+ydRcNhdEYr3R2Ja3ncOXGV98RWLm9ElcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.29.7", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.0.3", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@angular/core": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.0.3.tgz", + "integrity": "sha512-u6bYkPBB9PfYyyQ29JiytYbTqHO0lhigkkSVFoAT0WXu3R0ohycjYJooxJbHNBivWkQsASwv/k125Wb3SQoL2g==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.0.3", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/forms": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.0.3.tgz", + "integrity": "sha512-AhZEKvOw+5cqS3j1hjM0jbrWEg4xUg/l0h7yDpWAsgenWFutATGzeeRvl9dJ3HPh2Ga6leIraDi0Q/+YLCDE/A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.10" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.0.3", + "@angular/core": "22.0.3", + "@angular/platform-browser": "22.0.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/material": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-22.0.2.tgz", + "integrity": "sha512-a2sp9ipozR4THqu5A3ff3VXBpbQHpfTmH+Oqb0+RD47fJ+/kvyBUZQ5JK2Yh6eUXVceAOW4s+sL0ev8tS1EfuQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "22.0.2", + "@angular/common": "^22.0.0 || ^23.0.0", + "@angular/core": "^22.0.0 || ^23.0.0", + "@angular/forms": "^22.0.0 || ^23.0.0", + "@angular/platform-browser": "^22.0.0 || ^23.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.0.3.tgz", + "integrity": "sha512-MAOGciS6zrw4CzEH6n/Cry0tl5MtgWWYEs/IH2McMtixpun1EOr5TVn84xIELloOtNBNT4679g5rpAVm6onLAw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/animations": "22.0.3", + "@angular/common": "22.0.3", + "@angular/core": "22.0.3" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/router": { + "version": "22.0.3", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.0.3.tgz", + "integrity": "sha512-wkD7axjX43fIbhJKLy83O/XnJI1LO/C6aXdVFE+D6dVWCzwI8wL+cqHk9ovW9TsLOtxWvp6DE7emcc+54vlshw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/common": "22.0.3", + "@angular/core": "22.0.3", + "@angular/platform-browser": "22.0.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@harperfast/extended-iterable": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", + "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.3.tgz", + "integrity": "sha512-Co9U3AJ3LW0J8XBHjVoNKA79dMAyFt8EZH3OaKTMcDTj8r+6kG3vSUPq/eGLHT7P0iK3uLaFfhdFYd3033P24g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "10.2.1" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.4.tgz", + "integrity": "sha512-Kk4Kz3iyu1QiLsLZBS9Af1eSKUC8VR2T+/jyE2iAyuGw2VwK08pp5iTbZnXn6sWu0LogO/RFktMxOjiDA2sS3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.4.tgz", + "integrity": "sha512-BEe5Rp3trn26oxoXOVL5HVDoiYmjUDwr8NRPkBOdUdCSBEorKI+7JrZLRKAdxO+G6cGQLgseXk0gR7qIQa7aGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.4.tgz", + "integrity": "sha512-SGbFR7816uBcTHc2ZY4S6WyOkl9bICnzqTQd2Mv4V/j24cfds88xx2nC6cm/y8zGQL7Ds31YF/5NGxjgcdM5Hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.4.tgz", + "integrity": "sha512-cUXEengO8o60v1SWerJTH4/RH4U3+9jC0/4njp2Z9NdmvaGzhKsbRM2wpXuRYrN8tytsoJCg0SvWEWwHAwLbCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.4.tgz", + "integrity": "sha512-Gxq8jpgOWXwd0PUR+c9R2Ik1/uBnGd5GMIIzRRDqABCkvmjtC3KWcyhesV9jSPCz759isl0NlbsstZ2oyvk8lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-arm64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.4.tgz", + "integrity": "sha512-pKv1DJ1bPZAaHkdFsSz5IDfUG8x9vntgquXF9/Dm2xuupcIe/EkLzylpoBxppFVK5vzbV561Dq26jNY2fIMA7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.4.tgz", + "integrity": "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", + "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", + "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", + "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", + "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-openharmony-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", + "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/wasm-node": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.62.2.tgz", + "integrity": "sha512-LseVv64SSO6S7eyc+LFGUnH36NMMFbtKN28vTUHFinRVzFKH4cVQ/BB22JfXM9Ei5l7x46AIQp+n2QzzJ9kxHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/@schematics/angular": { + "version": "22.0.4", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.0.4.tgz", + "integrity": "sha512-P3V3tkqIR+n0GJSv0ibf34/zMtKbFp6kaTjBe5cm/RyXuHbmdaPYjk8PNphkGMypDtWCof1RtNnW/hl832Wnew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.0.4", + "@angular-devkit/schematics": "22.0.4", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", + "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/algoliasearch": { + "version": "5.52.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz", + "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.18.0", + "@algolia/client-abtesting": "5.52.0", + "@algolia/client-analytics": "5.52.0", + "@algolia/client-common": "5.52.0", + "@algolia/client-insights": "5.52.0", + "@algolia/client-personalization": "5.52.0", + "@algolia/client-query-suggestions": "5.52.0", + "@algolia/client-search": "5.52.0", + "@algolia/ingestion": "1.52.0", + "@algolia/monitoring": "1.52.0", + "@algolia/recommend": "5.52.0", + "@algolia/requester-browser-xhr": "5.52.0", + "@algolia/requester-fetch": "5.52.0", + "@algolia/requester-node-http": "5.52.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/beasties": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.2.tgz", + "integrity": "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^6.0.0", + "css-what": "^7.0.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^10.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", + "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^7.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "nth-check": "^2.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", + "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-cache-directory": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/find-cache-directory/-/find-cache-directory-6.0.0.tgz", + "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immutable": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.7.tgz", + "integrity": "sha512-47Xb+LFbZ/ZIjQMj6Q5J3IfK7PJFuqRdFOC9FpGgRTK6U2dAEVmkR9hp58qU4FpYux5YXpneDwkj2EP6lppzFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/injection-js": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/injection-js/-/injection-js-2.6.1.tgz", + "integrity": "sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/less": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/less/-/less-4.6.7.tgz", + "integrity": "sha512-o3UxHBPPVY1HtCXx15/z1NlknQiWyafRNbtLEv+6xFaDRI2g2xPKIH43do9dSwt8bGLTsjNSaifa48N3d6odsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "copy-anything": "^3.0.5", + "parse-node-version": "^1.0.1" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^5.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/lmdb": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.4.tgz", + "integrity": "sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@harperfast/extended-iterable": "^1.0.3", + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.5.4", + "@lmdb/lmdb-darwin-x64": "3.5.4", + "@lmdb/lmdb-linux-arm": "3.5.4", + "@lmdb/lmdb-linux-arm64": "3.5.4", + "@lmdb/lmdb-linux-x64": "3.5.4", + "@lmdb/lmdb-win32-arm64": "3.5.4", + "@lmdb/lmdb-win32-x64": "3.5.4" + } + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", + "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", + "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/needle": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", + "integrity": "sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ng-packagr": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/ng-packagr/-/ng-packagr-22.0.0.tgz", + "integrity": "sha512-2mXzUdprkDHk4j0NVDcpkVztVwdb1b3o63vLK8YQVCJqCMvCv8BBkFjBo9f1KJmuPf+CE/xuvylhyqfzXoTTqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/wasm-node": "^4.24.0", + "ajv": "^8.17.1", + "browserslist": "^4.26.0", + "chokidar": "^5.0.0", + "commander": "^14.0.0", + "dependency-graph": "^1.0.0", + "esbuild": "^0.28.0", + "find-cache-directory": "^6.0.0", + "injection-js": "^2.4.0", + "jsonc-parser": "^3.3.1", + "less": "^4.2.0", + "ora": "^9.0.0", + "piscina": "^5.0.0", + "postcss": "^8.4.47", + "rollup-plugin-dts": "^6.4.0", + "rxjs": "^7.8.1", + "sass": "^1.81.0", + "tinyglobby": "^0.2.12" + }, + "bin": { + "ng-packagr": "src/cli/main.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "optionalDependencies": { + "rollup": "^4.24.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^22.0.0 || ^22.1.0-next.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", + "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.2", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ordered-binary": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", + "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", + "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0", + "parse5": "^8.0.0", + "parse5-sax-parser": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", + "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.x" + }, + "optionalDependencies": { + "@napi-rs/nice": "^1.0.4" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-dir": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up-simple": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup-plugin-dts": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-dts/-/rollup-plugin-dts-6.4.1.tgz", + "integrity": "sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "convert-source-map": "^2.0.0", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/Swatinem" + }, + "optionalDependencies": { + "@babel/code-frame": "^7.29.0" + }, + "peerDependencies": { + "rollup": "^3.29.4 || ^4", + "typescript": "^4.5 || ^5.0 || ^6.0" + } + }, + "node_modules/rollup-plugin-dts/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tar": { + "version": "7.5.17", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz", + "integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..013410d --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "angular-inline-select", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "packageManager": "npm@11.13.0", + "dependencies": { + "@angular/aria": "^22.0.2", + "@angular/cdk": "^22.0.2", + "@angular/common": "^22.0.3", + "@angular/compiler": "^22.0.3", + "@angular/core": "^22.0.3", + "@angular/forms": "^22.0.3", + "@angular/material": "^22.0.2", + "@angular/platform-browser": "^22.0.3", + "@angular/router": "^22.0.3", + "rxjs": "~7.8.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@angular/build": "^22.0.4", + "@angular/cli": "^22.0.4", + "@angular/compiler-cli": "^22.0.3", + "jsdom": "^28.0.0", + "ng-packagr": "^22.0.0", + "prettier": "^3.8.1", + "typescript": "~6.0.3", + "vitest": "^4.0.8" + } +} diff --git a/projects/angular-inline-select/README.md b/projects/angular-inline-select/README.md new file mode 100644 index 0000000..bc71a25 --- /dev/null +++ b/projects/angular-inline-select/README.md @@ -0,0 +1,64 @@ +# AngularInlineSelect + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the library, run: + +```bash +ng build angular-inline-select +``` + +This command will compile your project, and the build artifacts will be placed in the `dist/` directory. + +### Publishing the Library + +Once the project is built, you can publish your library by following these steps: + +1. Navigate to the `dist` directory: + + ```bash + cd dist/angular-inline-select + ``` + +2. Run the `npm publish` command to publish your library to the npm registry: + ```bash + npm publish + ``` + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/projects/angular-inline-select/ng-package.json b/projects/angular-inline-select/ng-package.json new file mode 100644 index 0000000..052d40e --- /dev/null +++ b/projects/angular-inline-select/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/angular-inline-select", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/angular-inline-select/package.json b/projects/angular-inline-select/package.json new file mode 100644 index 0000000..80a903a --- /dev/null +++ b/projects/angular-inline-select/package.json @@ -0,0 +1,15 @@ +{ + "name": "angular-inline-select", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^22.0.3", + "@angular/core": "^22.0.3", + "@angular/forms": "^22.0.3", + "@angular/cdk": "^22.0.2", + "@angular/material": "^22.0.2" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html new file mode 100644 index 0000000..4bd7be4 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -0,0 +1,53 @@ +@let previous = this.previous(); + + + @if (isSingleLine()) { + + } @else { + + } + + @if (!localForm().required()) { + + } + diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss new file mode 100644 index 0000000..09eca16 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss @@ -0,0 +1,29 @@ +.iusta-editable-text-area { + width: 100%; + max-width: 100%; + + /* wrapping */ + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + + /* let autosize control height */ + height: auto; + + &--no-manual-resize { + resize: none; /* remove the corner handle */ + overflow: hidden; /* no scrollbars while autosizing */ + + /* hide scrollbar visuals just in case */ + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + + /* optional: make it feel "input-ish" when focused (tighter vertical feel) */ + .iusta-editable-wrapper:focus-within & { + padding-top: 0; + padding-bottom: 0; + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts new file mode 100644 index 0000000..330e71c --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AngularInlineText, normalizeString } from './angular-inline-text'; + +describe('AngularInlineText', () => { + let component: AngularInlineText; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AngularInlineText], + }).compileComponents(); + + fixture = TestBed.createComponent(AngularInlineText); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should normalize surplus whitespace and newlines', () => { + expect(normalizeString(' hello \n world ')).toBe('hello world'); + }); +}); diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts new file mode 100644 index 0000000..82e24ae --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -0,0 +1,253 @@ +import { + Component, + inject, + ElementRef, + + // Signals + computed, + output, + model, + viewChild, + input, + effect, + untracked, + linkedSignal, +} from '@angular/core'; +import { FormValueControl, FormField, form, disabled, readonly, validate } from '@angular/forms/signals'; + +// Material +import { MatInputModule } from '@angular/material/input'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; + +// CDK +import { OverlayModule } from '@angular/cdk/overlay'; + +// Directives +import { RestrictCharacters } from './directives/restrict-characters/restrict-characters'; +import { NOOP_STRATEGY, RestrictStrategy } from './directives/restrict-characters/tokens'; +import { EditableOverlayControl } from './directives/editable-overlay-control'; +import { TextareaAutosize } from './directives/textarea-autosize'; + +// Components +import { EditableWrapper } from './editable-wrapper/editable-wrapper'; + +interface ValueNormalizationDetails { + value: string; + changed: boolean; +} + +export function normalizeString(value: string): string { + const trim = value.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); + return trim; +} + +@Component({ + selector: 'angular-inline-text', + imports: [ + // CDK + OverlayModule, + + // Material + MatInputModule, + MatIconModule, + MatButtonModule, + + // Forms + FormField, + EditableOverlayControl, + EditableWrapper, + TextareaAutosize, + + // Directives + RestrictCharacters, + ], + templateUrl: './angular-inline-text.html', + styleUrl: './angular-inline-text.scss', +}) +export class AngularInlineText implements FormValueControl { + signalForm = inject(FormField, { optional: true }); + + protected autosize = viewChild('autosize'); + protected overlayControl = viewChild(EditableOverlayControl); + protected wrapper = viewChild(EditableWrapper); + + protected singleLineInput = viewChild>('singleLineInput'); + protected multiLineInput = viewChild>('multiLineInput'); + + // Signal Form Control + // --------------------------------------------------------------------------- + value = model(''); + + /** + * The local model is the model that is used to store the value of the control. + */ + localModel = linkedSignal(() => this.value() ?? ''); + + localForm = + this.signalForm?.state ?? + form(this.localModel, (path) => { + disabled(path, () => this.signalForm?.state()?.disabled() ?? false); + readonly(path, () => this.signalForm?.state()?.readonly() ?? false); + validate(path, () => { + const valid = this.signalForm?.state()?.valid() ?? true; + if (valid) return null; + + const errorSummary = this.signalForm?.state()?.errors() ?? []; + return { + kind: 'invalid', + errors: errorSummary, + message: errorSummary[0]?.message ?? 'Invalid value', + }; + }); + }); + + /** + * This is the previous value of the control. + * It is used to revert the value to the previous value if the control is reverted. + */ + previous = linkedSignal({ + source: () => this.value(), + computation: (source, previous): string => { + const dirty = this.localForm().dirty(); + if (!dirty) return source; + + return previous ? previous.value : ''; + }, + }); + + isEmpty = computed(() => { + const control = this.overlayControl(); + + if (!control) return false; + return control.isEmpty() ?? false; + }); + + // --------------------------------------------------------------------------- + // Editable Core + // --------------------------------------------------------------------------- + appearance = input<'outline' | 'fill'>('fill'); + savedModelChange = output(); + showForm = model(false); + + // Directives + // --------------------------------------------------------------------------- + restrictionStrategy = input(NOOP_STRATEGY); + + // Class Owned + // --------------------------------------------------------------------------- + isSingleLine = input(false); + placeholder = input('N/A'); + + // Inputs + // --------------------------------------------------------------------------- + + /** + * This will trim all surplus characters + * - before emitting the value + * - and after accepting setting the value to this normalized value + */ + normalizeValue = input(false); + + /** + * Normalization includes (a growing list of things to normalize): + * - removed all surplus spaces + * - removed all newlines + */ + normalization = computed((): ValueNormalizationDetails => { + const value = this.localForm()?.value() ?? ''; + const previous = this.previous(); + + if (!this.normalizeValue()) { + return { + value, + changed: value !== previous, + }; + } + + const normalized = normalizeString(value); + return { + value: normalized, + changed: normalized !== previous, + }; + }); + + // Handlers + // ------------------------------------------------------------------------- + + accepted = false; + protected accept() { + const { value, changed } = this.normalization(); + + if (!changed) { + this.showForm.set(false); + this.localForm().reset(); + return; + } + + // Validation check uses the activeForm state + if (this.localForm().invalid()) return; + + this.accepted = true; + + // Fire the hard commit! + this.savedModelChange.emit(value); + this.showForm.set(false); + + if (this.isSingleLine()) { + this.singleLineInput()?.nativeElement.blur(); + } else { + this.multiLineInput()?.nativeElement.blur(); + } + + this.localForm().reset(); + } + + protected handleDetach() { + if (this.accepted) return; + + const previous = this.previous(); + const current = this.localForm().value(); + + // If they click away and it's different than the latched value, revert + if (previous !== current) { + this.localForm().reset(previous); + } + } + + protected handleCopied() { + this.overlayControl()?.copyCurrent(); + } + + protected clearValue(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + this.value.set(''); + this.savedModelChange.emit(''); + this.localForm().reset(); + } + + provideAutosize() { + if (this.isSingleLine()) return; + + return effect(() => { + this.localForm().value(); + untracked(() => requestAnimationFrame(() => this.autosize()?.resize())); + }); + } + + autoResetAccepted = effect(() => { + if (this.showForm()) { + untracked(() => (this.accepted = false)); + } + }); + + resize = effect(() => { + // Move the check inside the effect + if (this.isSingleLine()) return; + + this.localForm().value(); + untracked(() => requestAnimationFrame(() => this.autosize()?.resize())); + }); +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts new file mode 100644 index 0000000..bb4a120 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts @@ -0,0 +1,121 @@ +import { + Directive, + inject, + ElementRef, + + // Signals + input, + computed, + signal, + linkedSignal, + type WritableSignal, +} from '@angular/core'; +import { FieldTree, FormField } from '@angular/forms/signals'; + +/** + * Directive to control the overlay of the editable component. This is used to + * - open and close the overlay of the editable component. + * - set the class of the editable component based on the state of the control. + */ +@Directive({ + selector: '[mEditableOverlayControl]', + exportAs: 'editableOverlayControl', + host: { + class: 'iusta-editable', + '[class.iusta-editable--empty]': 'isOpen() === false && isEmpty()', + '[class.iusta-editable--filled]': 'isOpen() === false && !isEmpty()', + '(focus)': 'showSignal().set(true)', + '(blur)': 'onBlur($event)', + }, +}) +export class EditableOverlayControl { + host = inject>(ElementRef); + #control = inject(FormField, { optional: true }); + + mEditableOverlayControl = input.required<{ + showSignal: WritableSignal; + localForm?: FieldTree; + }>(); + + /** + * This control expects the distinction between local form and injected form. + * The injected form involves what user expects - why the local form is the actual + * control. Both can coexist but fulfill different purposes. + */ + state = computed(() => { + const config = this.mEditableOverlayControl(); + const state = config?.localForm?.() ?? this.#control?.state(); + + if (!state) { + throw new Error('Please Provide FormField to properly control editable wrapper'); + } + + return state; + }); + + /** Writable show signal (the actual signal instance) */ + showSignal = computed(() => this.mEditableOverlayControl().showSignal); + currentValue = computed(() => this.state().value()); + + /** Boolean open value */ + isOpen = computed(() => this.showSignal()()); + + /** + * Whether the control is empty + */ + isEmpty = computed(() => { + const state = this.state(); + if (!state) return false; // ← safe default + + if (typeof this.state().value() === 'string') { + return this.state().value() === ''; + } + + return this.state().value() === null || this.state().value() === undefined; + }); + + /* + * Whether the value is required + */ + required = computed(() => !!this.state().required()); + + /** + * The value to copy + */ + copyValue = signal(undefined); + + /** + * The warning message to display + */ + warningMessage = linkedSignal({ + source: () => this.#control?.state().value() ?? undefined, + computation: () => 'You have unsaved changes', + }); + + resetWarningMessage() { + this.warningMessage.set('You have unsaved changes'); + } + + /** + * Handler for the blur event. + * @param event - The focus event + */ + onBlur(event: FocusEvent) { + if (this.state().dirty()) return; + + // 2. Check where the focus is going (relatedTarget) + const nextTarget = event.relatedTarget as HTMLElement | null; + + // If focus is moving into the editable panel (e.g., the Save button), + if (nextTarget?.closest('.editable-panel')) return; + + // 3. Otherwise, safe to close + this.showSignal().set(false); + } + + copyCurrent() { + const value = this.copyValue() ?? this.currentValue(); + if (!value) return; + navigator.clipboard.writeText(String(value)); + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts new file mode 100644 index 0000000..691b2c3 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts @@ -0,0 +1,294 @@ +import { + Directive, + ElementRef, + OnDestroy, + AfterViewInit, + inject, + InjectionToken, + Injector, + Signal, + + // Signals + computed, + input, + model, +} from '@angular/core'; +import { CdkConnectedOverlay, ConnectedPosition, CdkConnectedOverlayConfig, Overlay } from '@angular/cdk/overlay'; + +/** + * These offsets are in line with the ones appearing in the editable styles. + * There these sizes are relative to rem but here we eye balled them to be 1rem = 13px font size. + */ +export const VISUAL_Y_OFFSET = 7.5; +export const VISUAL_X_OFFSET = 9; + +export const VISUAL_X_OFFSET_OUTLINE = 9.75; +export const VISUAL_Y_OFFSET_OUTLINE = 9.5; + +export const OVERLAY_POSITIONS: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: VISUAL_Y_OFFSET, + offsetX: -VISUAL_X_OFFSET, + panelClass: ['__bottom'], + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -VISUAL_Y_OFFSET, + offsetX: -VISUAL_X_OFFSET, + panelClass: ['__top'], + }, +]; + +export const OVERLAY_POSITIONS_OUTLINE: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: VISUAL_Y_OFFSET_OUTLINE, + offsetX: -VISUAL_X_OFFSET_OUTLINE, + panelClass: ['__bottom'], + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -VISUAL_Y_OFFSET_OUTLINE, + offsetX: -VISUAL_X_OFFSET_OUTLINE, + panelClass: ['__top'], + }, +]; + +/** + * Appearance type for editable components. + */ +export type EditableAppearance = 'outline' | 'fill'; + +/** + * Default appearance value. Components using this directive should use this + * as their input default to maintain consistency. + */ +export const DEFAULT_EDITABLE_APPEARANCE: EditableAppearance = 'fill'; + +/** + * Configuration interface for host directive usage. + * Components can provide this to configure the directive via DI + * instead of template inputs. + */ +export interface OverlayWidthSyncContext { + /** Signal indicating whether the overlay is open */ + isOpen: Signal; + /** Signal for the appearance style */ + appearance: Signal; + /** Signal for the element to measure width from */ + originElement: Signal; + /** Optional signal for width offset override */ + widthOffsetOverride?: Signal; + /** Function to manually trigger overlay repositioning */ + connectedOverlay?: Signal; +} + +export const DEFAULT_CDK_CONNECTED_OVERLAY_CONFIG: CdkConnectedOverlayConfig = { + viewportMargin: 8, + push: true, + disposeOnNavigation: true, +} as const; + +/** + * Injection token for host directive configuration. + * Components using the directive as a hostDirective should provide this. + */ +export const OVERLAY_WIDTH_SYNC_CONTEXT = new InjectionToken('OVERLAY_WIDTH_SYNC_CONTEXT'); + +/** + * Directive that automatically syncs an overlay's minimum width with its origin element + * and handles repositioning when the origin resizes. + * + * It also provides computed overlay positions and width offsets based on the appearance. + */ +@Directive({ + selector: '[mOverlayWidthSync]', + exportAs: 'overlayWidthSync', +}) +export class OverlayWidthSyncDirective implements AfterViewInit, OnDestroy { + #overlay = inject(Overlay); + #elementRef = inject(ElementRef); + #injector = inject(Injector); + + scrollStrategy = computed(() => this.#overlay.scrollStrategies.block()); + + /** + * Lazily resolved context for host directive usage. + * Uses Injector.get() to avoid circular dependency during construction. + */ + #contextCache: OverlayWidthSyncContext | null | undefined = undefined; + + #getContext(): OverlayWidthSyncContext | null { + if (this.#contextCache === undefined) { + this.#contextCache = this.#injector.get(OVERLAY_WIDTH_SYNC_CONTEXT, null, { optional: true }); + } + + return this.#contextCache; + } + + defaultConfig = DEFAULT_CDK_CONNECTED_OVERLAY_CONFIG; + + // --------------------------------------------------------------------------- + // Inputs (used when no context is provided) + // --------------------------------------------------------------------------- + + /** + * The visual appearance style. Determines default positions and offsets. + * - 'fill': Default style with standard offsets + * - 'outline': Outline style with slightly larger offsets + */ + appearanceInput = input(DEFAULT_EDITABLE_APPEARANCE, { alias: 'appearance' }); + + /** + * Optional override for the width offset. + * If not provided, automatically calculated based on appearance. + */ + widthOffsetOverrideInput = input(undefined, { alias: 'widthOffsetOverride' }); + + /** + * Whether the overlay is currently open (used to conditionally apply width) + */ + isOpenInput = input(false, { alias: 'isOpen' }); + + minWidth = input(250, { alias: 'widthSyncMin' }); + + /** + * Optional element to measure width from. + * If not provided, measures the host element. + */ + originElementInput = input(undefined, { alias: 'originElement' }); + + /** + * The measured width of the element + */ + measuredWidth = model(0); + + // --------------------------------------------------------------------------- + // Resolved values (prefer context over inputs) + // --------------------------------------------------------------------------- + + /** Resolved isOpen - prefers context over input */ + #isOpen = computed(() => this.#getContext()?.isOpen() ?? this.isOpenInput()); + + /** Resolved appearance - prefers context over input */ + #appearance = computed(() => this.#getContext()?.appearance() ?? this.appearanceInput()); + + /** Resolved originElement - prefers context over input */ + #originElement = computed(() => this.#getContext()?.originElement() ?? this.originElementInput()); + + /** Resolved widthOffsetOverride - prefers context over input */ + #widthOffsetOverride = computed(() => this.#getContext()?.widthOffsetOverride?.() ?? this.widthOffsetOverrideInput()); + + // --------------------------------------------------------------------------- + // Computed values based on appearance + // --------------------------------------------------------------------------- + + /** + * The width offset based on appearance (or explicit override) + */ + widthOffset = computed(() => { + const override = this.#widthOffsetOverride(); + if (override !== undefined) return override; + + const offset = this.#appearance() === 'outline' ? VISUAL_X_OFFSET_OUTLINE : VISUAL_X_OFFSET; + return offset * 2; + }); + + /** + * The computed width including any offsets + */ + overlayWidth = computed(() => Math.max(this.measuredWidth() + this.widthOffset(), this.minWidth())); + + /** + * The overlay positions based on appearance. + */ + overlayPositions = computed((): ConnectedPosition[] => { + if (this.#appearance() === 'outline') { + return OVERLAY_POSITIONS_OUTLINE; + } + + return OVERLAY_POSITIONS; + }); + + // --------------------------------------------------------------------------- + // Resize handling + // --------------------------------------------------------------------------- + + #resizeObserver?: ResizeObserver; + #rafId: number | null = null; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngAfterViewInit() { + const element = this.#originElement() ?? this.#elementRef.nativeElement; + + // Initialize with current width + this.measuredWidth.set(element.getBoundingClientRect().width); + + // Watch for size changes + this.#resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + + // Use contentRect to avoid layout thrashing + this.measuredWidth.set(entry.contentRect.width); + + // Trigger overlay reposition if needed + this.#scheduleOverlayReposition(); + }); + + this.#resizeObserver.observe(element); + } + + ngOnDestroy() { + this.#resizeObserver?.disconnect(); + this.#resizeObserver = undefined; + + if (this.#rafId !== null) { + cancelAnimationFrame(this.#rafId); + this.#rafId = null; + } + } + + private connectedOverlay = computed(() => this.#getContext()?.connectedOverlay?.()); + + /** + * Throttled overlay reposition using requestAnimationFrame + */ + #scheduleOverlayReposition() { + if (!this.#isOpen()) return; + + const overlay = this.connectedOverlay(); + if (!overlay?.overlayRef) return; + + if (this.#rafId !== null) return; + + this.#rafId = requestAnimationFrame(() => { + this.#rafId = null; + + overlay.overlayRef?.updatePosition(); + }); + } + + /** + * Public method to manually trigger overlay repositioning + */ + updateOverlayPosition() { + this.#scheduleOverlayReposition(); + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts new file mode 100644 index 0000000..1f1fbb3 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts @@ -0,0 +1,106 @@ +import { computed, Directive, ElementRef, inject, input, signal } from '@angular/core'; +import { NOOP_STRATEGY, RestrictStrategy } from './tokens'; + +export interface BlockedInputEvent { + kind: 'beforeinput' | 'paste' | 'keydown'; + attempted?: string; + strategy?: string; +} + +/** + * RestrictCharacters Directive + * =========================== + * Attribute directive that restricts / transforms user input based on a provided strategy instance. + * + * - Host element should remain a text input/textarea (no native input type switching). + * - Strategy instance is passed in via `[strategy]`. + * - Delegates input-related DOM events to the strategy. + * - IME-safe: ignores `beforeinput` while composition is active. + * + * Usage + * ----- + * ```html + * + * ``` + */ +@Directive({ + selector: '[mRestrictCharacters]', + exportAs: 'restrictCharacters', + host: { + '(beforeinput)': 'onBeforeInput($event)', + '(paste)': 'onPaste($event)', + '(keydown)': 'onKeydown($event)', + '(compositionstart)': 'onCompositionStart()', + '(compositionend)': 'onCompositionEnd()', + }, +}) +export class RestrictCharacters { + strategy = input(NOOP_STRATEGY); + + #elRef = inject>(ElementRef); + + // Signals for directive state + #composing = signal(false); + + // Computed: active strategy (mostly just for readability) + #activeStrategy = computed(() => this.strategy()); + + get el() { + return this.#elRef.nativeElement; + } + + // ctx as a method: always up-to-date, no capture issues + #ctx() { + const el = this.el; + + return { + el, + setValueAndNotify: (v: string) => { + el.value = v; + el.dispatchEvent(new Event('input', { bubbles: true })); + }, + proposedAfterInsert: (insert: string) => { + const { value, selectionStart, selectionEnd } = el; + const s = selectionStart ?? value.length; + const e = selectionEnd ?? value.length; + return value.slice(0, s) + insert + value.slice(e); + }, + insertTextAtSelection: (text: string) => { + const { value, selectionStart, selectionEnd } = el; + const s = selectionStart ?? value.length; + const e = selectionEnd ?? value.length; + + const next = value.slice(0, s) + text + value.slice(e); + + el.value = next; + el.dispatchEvent(new Event('input', { bubbles: true })); + + const pos = s + text.length; + el.setSelectionRange(pos, pos); + }, + }; + } + + onCompositionStart() { + this.#composing.set(true); + } + + onCompositionEnd() { + this.#composing.set(false); + } + + onBeforeInput(e: InputEvent) { + if (this.#composing()) return; + this.#activeStrategy()?.beforeInput?.(this.#ctx(), e); + } + + onPaste(e: ClipboardEvent) { + this.#activeStrategy()?.paste?.(this.#ctx(), e); + } + + onKeydown(e: KeyboardEvent) { + this.#activeStrategy()?.keydown?.(this.#ctx(), e); + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts new file mode 100644 index 0000000..9030947 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts @@ -0,0 +1,36 @@ +import { InjectionToken } from '@angular/core'; + +export interface RestrictStrategy { + /** + * Optional identifier (for debugging / logging). + * Not required for execution. + */ + readonly strategy?: string; + + beforeInput?(ctx: RestrictContext, e: InputEvent): void; + paste?(ctx: RestrictContext, e: ClipboardEvent): void; + keydown?(ctx: RestrictContext, e: KeyboardEvent): void; +} + +export interface RestrictContext { + el: HTMLInputElement | HTMLTextAreaElement; + setValueAndNotify(value: string): void; + proposedAfterInsert(insert: string): string; + insertTextAtSelection?(text: string): void; +} + +/** + * Explicit "do nothing" strategy. + * + * This is a single frozen object shared by the whole app. + * It is NOT provided via DI and has zero runtime behavior. + */ +export const NOOP_STRATEGY: RestrictStrategy = Object.freeze({ + strategy: 'noop', +}); + +/** + * Optional: only needed if you still want to register strategies via DI. + * Can be removed if you always pass `[strategy]="..."` directly. + */ +export const RESTRICT_STRATEGIES = new InjectionToken('RESTRICT_STRATEGIES'); diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts new file mode 100644 index 0000000..0479282 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts @@ -0,0 +1,35 @@ +import { Directive, ElementRef, OnInit, inject, output } from '@angular/core'; + +/** + * Simple drop-in replacement for the cdkTextareaAutosize directive keeping the resize handler. + * It does not include everything that cdkTextareaAutosize does, but it is a good starting point. + */ +@Directive({ + selector: 'textarea[mTextareaAutosize]', + exportAs: 'mTextareaAutosize', + host: { + '(input)': 'onInput()', + }, +}) +export class TextareaAutosize implements OnInit { + private elementRef = inject(ElementRef); + resized = output(); + + protected onInput() { + this.resize(); + } + + ngOnInit() { + if (this.elementRef.nativeElement.scrollHeight) { + requestAnimationFrame(() => this.resize()); + } + } + + resize() { + const el = this.elementRef.nativeElement as HTMLTextAreaElement; + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; + + this.resized.emit(); + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html new file mode 100644 index 0000000..6aed874 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html @@ -0,0 +1,36 @@ +@if (!hideDiscard()) { + +} + + diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss new file mode 100644 index 0000000..91141f3 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss @@ -0,0 +1,52 @@ +@use '@angular/material' as mat; + +:host { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.action-reset { + @include mat.button-overrides( + ( + // neutral but still clearly clickable + text-label-text-color: var(--iusta-sys-on-surface-variant, #6b7280), + text-state-layer-color: var(--iusta-sys-on-surface, #111827), + text-container-shape: var(--iusta-editable-radius, 0.25rem) + ) + ); +} + +.action-save { + @include mat.button-overrides( + ( + filled-container-shape: var(--iusta-editable-radius, 0.25rem), + ) + ); +} + +/* 1. Define the rotation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* 2. Target the mat-icon only when the parent button has aria-busy="true" */ +button[aria-busy='true'] mat-icon { + animation: spin 1s linear infinite; + + /* Ensures the rotation happens around the center of the icon */ + display: inline-block; + line-height: 1; +} + +/* 3. Optional: visual feedback for the button itself */ +button[aria-busy='true'] { + pointer-events: none; /* Prevent double-clicks while loading */ + opacity: 0.8; + cursor: wait; +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts new file mode 100644 index 0000000..ecd6b54 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts @@ -0,0 +1,23 @@ +import { Component, input, output } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; + +@Component({ + selector: 'm-editable-action-buttons', + imports: [ + // Material + MatIconModule, + MatButtonModule, + ], + templateUrl: './editable-action-buttons.html', + styleUrl: './editable-action-buttons.scss', +}) +export class EditableActionButtons { + accept = output(); + decline = output(); + disableAccept = input(false); + disable = input(false); + isLoading = input(false); + + hideDiscard = input(false); +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html new file mode 100644 index 0000000..677ff5b --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html @@ -0,0 +1,65 @@ + + + +
+ @for (error of control().errors(); track error.kind) { +
+ {{ error.message ?? 'Invalid value' }} +
+ } + + @if (isDirty() && !isInvalid()) { +
+ warning + {{ warningMessage() }} +
+ } + + @if (isDirty()) { +
+
+ @if (currentValue()) { + + } +
+ +
+ +
+
+ } +
+
diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss new file mode 100644 index 0000000..24ed64e --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss @@ -0,0 +1,3 @@ +// Structural styles for the editable wrapper live in the shared stylesheet +// (styles/inline-text.scss) because the overlay panel renders outside of this +// component's encapsulation scope. diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts new file mode 100644 index 0000000..b91b39b --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts @@ -0,0 +1,257 @@ +import { + Component, + ElementRef, + inject, + + // Signals + computed, + output, + contentChild, + viewChild, + input, + Signal, +} from '@angular/core'; +import { CdkConnectedOverlay, CdkConnectedOverlayConfig, OverlayModule } from '@angular/cdk/overlay'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { A11yModule } from '@angular/cdk/a11y'; + +import { EditableOverlayControl } from '../directives/editable-overlay-control'; +import { EditableActionButtons } from '../editable-action-buttons/editable-action-buttons'; +import { + DEFAULT_EDITABLE_APPEARANCE, + EditableAppearance, + OverlayWidthSyncContext, + OverlayWidthSyncDirective, + OVERLAY_WIDTH_SYNC_CONTEXT, +} from '../directives/overlay-width-sync'; + +@Component({ + selector: 'm-editable-wrapper', + imports: [ + // CDK + OverlayModule, + A11yModule, + + // Material + MatButtonModule, + MatIconModule, + + // Components + EditableActionButtons, + ], + hostDirectives: [OverlayWidthSyncDirective], + providers: [ + { + provide: OVERLAY_WIDTH_SYNC_CONTEXT, + useExisting: EditableWrapper, + }, + ], + templateUrl: './editable-wrapper.html', + styleUrl: './editable-wrapper.scss', + host: { + class: 'iusta-editable-wrapper', + '[class]': 'customClass()', + '[class.iusta-editable-wrapper--has-prefix]': 'hasPrefix()', + '[class.iusta-editable-wrapper--outline]': 'appearance() === "outline"', + '[class.invalid]': 'isInvalid()', + '[class.is-editing]': 'isOpen()', + + '(keydown.control.enter)': 'handleAccept()', + '(keydown.escape)': 'handleDecline()', + '(keydown.tab)': 'handleTab($event)', + '(keydown.shift.tab)': 'handleTab($event)', + }, +}) +export class EditableWrapper implements OverlayWidthSyncContext { + widthOffsetOverride?: Signal | undefined; + // --------------------------------------------------------------------------- + // Host directive + // --------------------------------------------------------------------------- + private widthSync = inject(OverlayWidthSyncDirective); + + // --------------------------------------------------------------------------- + // Content + View refs + // --------------------------------------------------------------------------- + wrapperRef = inject(ElementRef); + connectedOverlay = viewChild(CdkConnectedOverlay); + protected overlayControl = contentChild.required(EditableOverlayControl); + protected panelRef = viewChild>('panel'); + + // --------------------------------------------------------------------------- + // Inputs + // --------------------------------------------------------------------------- + appearance = input(DEFAULT_EDITABLE_APPEARANCE); + hasPrefix = input(false); + customClass = input(''); + + // --------------------------------------------------------------------------- + // Derived state (signals) + // --------------------------------------------------------------------------- + protected inputElement = computed(() => this.overlayControl().host); + protected control = computed(() => this.overlayControl().state()); + protected isInvalid = computed(() => this.control().invalid()); + protected isDirty = computed(() => this.control().dirty()); + protected currentValue = computed(() => this.overlayControl().currentValue()); + protected warningMessage = computed(() => this.overlayControl().warningMessage()); + + /** + * Only open if the input is active AND it needs attention (dirty or invalid). + * Part of OverlayWidthSyncContext interface. + */ + isOpen = computed(() => { + const active = this.overlayControl().isOpen(); + const needsAttention = this.isDirty() || this.isInvalid(); + return active && needsAttention; + }); + + /** + * The element to measure width from. + * Part of OverlayWidthSyncContext interface. + */ + originElement = computed(() => this.wrapperRef.nativeElement); + + // --------------------------------------------------------------------------- + // Width measurement + // --------------------------------------------------------------------------- + + protected overlayConfig = computed( + (): CdkConnectedOverlayConfig => ({ + origin: this.wrapperRef, + panelClass: 'iusta-editable-panel', + width: this.width(), + positions: this.widthSync.overlayPositions(), + minWidth: '300px', + usePopover: 'inline', + }), + ); + + width = computed(() => this.widthSync.overlayWidth()); + + /** Expose overlay positions for child components that need it */ + overlayPositions = computed(() => this.widthSync.overlayPositions()); + + // --------------------------------------------------------------------------- + // Messages + // --------------------------------------------------------------------------- + protected errorMessage = computed(() => { + const errors = this.control().errors(); + return errors ? 'Invalid input' : null; + }); + + protected panelMessage = computed((): { text: string; kind: 'error' | 'hint' } | null => { + if (this.isInvalid()) { + return { text: this.errorMessage() ?? 'Invalid input', kind: 'error' }; + } + + if (this.isDirty()) { + return { text: 'You have unsaved changes', kind: 'hint' }; + } + + return null; + }); + + // --------------------------------------------------------------------------- + // Outputs + // --------------------------------------------------------------------------- + accepted = output(); + declined = output(); + detached = output(); + attached = output(); + copied = output(); + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + protected handleAccept() { + if (this.isInvalid()) return; + this.accepted.emit(); + } + + protected handleDecline() { + this.declined.emit(); + } + + protected handleDetach() { + this.detached.emit(); + } + + protected handleAttach() { + this.attached.emit(); + this.widthSync.updateOverlayPosition(); + } + + // --------------------------------------------------------------------------- + // Focus + close helpers + // --------------------------------------------------------------------------- + private focusOrigin() { + this.control().focusBoundControl(); + this.widthSync.updateOverlayPosition(); + } + + // --------------------------------------------------------------------------- + // Keyboard Navigation + // --------------------------------------------------------------------------- + + /** + * Handles outside clicks while the field has unsaved changes. + * Blocks switching to other editables, allows clicks inside the current one, + * declines the edit on clearly distant clicks, and otherwise keeps focus + * on the current field to prevent accidental data loss. + */ + protected handleOutsideClick(event: MouseEvent) { + if (!this.isDirty()) return; + + const originEl = this.wrapperRef.nativeElement; + const targetEl = event.target as HTMLElement | null; + if (!targetEl) return; + + // Dirty → distance gate (squared Euclidean) + const rect = originEl.getBoundingClientRect(); + const x = event.clientX; + const y = event.clientY; + + const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0; + const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0; + + const threshold = Math.ceil(window.innerHeight / 2); + const thresholdSq = threshold * threshold; + + // Far away → explicit decline + if (dx * dx + dy * dy > thresholdSq) { + this.handleDecline(); + return; + } + } + + protected handleBackdropClick() { + if (!this.isDirty()) return; + this.focusOrigin(); + } + + /* + * Handles tab key presses while the field has unsaved changes. + * Prevents leaving the field and instead moves focus into the overlay panel. + */ + protected handleTab(event: Event) { + if (!this.isDirty()) return; + + // Dirty: don't allow leaving; instead move focus into the overlay panel. + event.preventDefault(); + + queueMicrotask(() => { + const panelEl = this.panelRef()?.nativeElement; + if (!panelEl) return; + + const firstFocusable = panelEl.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + + firstFocusable?.focus(); + }); + } + + updateOverlayPosition() { + this.widthSync.updateOverlayPosition(); + } +} diff --git a/projects/angular-inline-select/src/lib/styles/inline-text.scss b/projects/angular-inline-select/src/lib/styles/inline-text.scss new file mode 100644 index 0000000..fbbebed --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/inline-text.scss @@ -0,0 +1,547 @@ +// ============================================================================= +// angular-inline-text — shared (global) styles +// ============================================================================= +// These classes target elements that render outside component encapsulation +// (CDK overlay panels) or projected content, so they must be included globally: +// +// @use 'path/to/angular-inline-select/src/lib/styles/inline-text'; +// +// Requires the Angular Material system variables (--mat-sys-*) from mat.theme(). + +// ----------------------------------------------------------------------------- +// ELEMENT: The Input Field +// ----------------------------------------------------------------------------- +.iusta-editable { + border: none; + padding: 0; + margin: 0; + background-color: transparent; + text-decoration-color: transparent; + + outline: none; + position: relative; + + // content layer sits above ::after (-1) without magic "1" + z-index: 0; + + // Fallback width for browsers without field-sizing support + width: max-content; // best-effort auto width based on content (where supported) + field-sizing: content; // overrides where supported + min-width: 0; // keep it usable even if max-content behaves oddly + max-width: 100%; + + &:disabled { + cursor: default; + } + + // M3 Typography: Body Large + font-weight: var(--mat-sys-body-large-weight, 400); + line-height: var(--mat-sys-body-large-line-height, 1.5); + letter-spacing: var(--mat-sys-body-large-tracking, 0.03125rem); + + font-family: inherit; + font-size: inherit; + color: inherit; + + // --------------------------------------------------------------------------- + // Focus state: hide underline immediately (keep your focus sizing) + // --------------------------------------------------------------------------- + .iusta-editable-wrapper:focus-within &, + .iusta-editable-wrapper.is-editing & { + color: var(--mat-sys-on-surface); + caret-color: var(--mat-sys-primary); + + field-sizing: fixed; + width: 100%; + max-width: 100%; + + // Helps in flex rows so it can actually take available space + flex: 1 1 auto; + + transition: + filter var(--iusta-t-fast, 0.28s) var(--iusta-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)), + opacity var(--iusta-t-fast, 0.28s) var(--iusta-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + } + + // --------------------------------------------------------------------------- + // Static Modifier Logic (Overrides field-sizing) + // --------------------------------------------------------------------------- + .iusta-editable-wrapper--static & { + width: 100%; + max-width: 100%; + field-sizing: fixed; + flex: 1 1 auto; + } + + // MODIFIER: Truncation + &--truncate { + display: block; + width: 100%; + field-sizing: fixed; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + // MODIFIER: Persistent (marker class for wrapper's :has() selector) + &--persistent { + // marker + } + + &--filled { + color: var(--iusta-editable-color, #428bca); + } + + &--empty { + --_iusta-editable-empty-opacity: 0.3875; + + color: inherit; + + &:disabled { + opacity: 1; + -webkit-text-fill-color: currentColor; + } + } + + &::placeholder { + font-style: italic; + opacity: var(--_iusta-editable-empty-opacity); + } +} + +@media (prefers-reduced-motion: reduce) { + .iusta-editable { + transition: none; + } +} + +// ----------------------------------------------------------------------------- +// BLOCK: Editable Wrapper (The Container) +// ----------------------------------------------------------------------------- +.iusta-editable-wrapper { + // Shared animation tokens (M3-ish) + --iusta-editable-editing-background: color-mix(in srgb, var(--iusta-editable-color, #428bca) 5%, transparent); + --iusta-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --iusta-ease-emphasized: cubic-bezier(0, 0, 0.2, 1); + --iusta-t-fast: 0.2s; + --iusta-t-slow: 0.32s; + + --iusta-editable-outline-inset-top-bottom: -0.5rem; + --iusta-editable-outline-inset-left-right: -0.75rem; + + --iusta-editable-focus-min: var(--iusta-editable-focus-min-value, 310px); + --iusta-editable-shortcut-optical-offset: -1px; + + position: relative; + display: inline-flex; + align-items: center; + border-radius: var(--iusta-editable-radius, 0.25rem); + + // Create local stacking context so ::after can sit behind content safely + isolation: isolate; + + transition: + width var(--iusta-t-slow) var(--iusta-ease-standard), + flex-grow var(--iusta-t-slow) var(--iusta-ease-standard); + + // --------------------------------------------------------------------------- + // SIZING LOGIC + // --------------------------------------------------------------------------- + + // 1. Dynamic Behavior (Default): content-sized unless static + &:not(&--static) { + width: auto; + min-width: 4ch; + max-width: 100%; + } + + // 2. Static Behavior (Modifier): always full width + &--static { + width: 100%; + flex: 1 1 auto; + } + + // 3. Active/Editing Behavior (Overrides everything) + &:focus-within, + &.is-editing { + // Always expand to the parent's available width (shell controls max) + width: 100%; + + // Lift wrapper cap while editing + max-inline-size: 100%; + + // Prefer at least 310px, but never overflow small parents + min-inline-size: min(var(--iusta-editable-focus-min), 100%); + + // Helps in flex rows so it can actually take available space + flex: 1 1 auto; + + // Focus/active transition: quicker + transition: + width var(--iusta-t-fast) var(--iusta-ease-emphasized), + flex-grow var(--iusta-t-fast) var(--iusta-ease-emphasized); + + z-index: 1001; // Match CDK overlay z-index + } + + /** + * BEM MODIFIER: Standard grow + */ + &--standard-grow { + &:focus-within { + width: 100%; + max-inline-size: 100%; + min-inline-size: min(var(--iusta-editable-focus-min), 100%); + flex: 1 1 auto; + + transition: + width var(--iusta-t-fast) var(--iusta-ease-emphasized), + flex-grow var(--iusta-t-fast) var(--iusta-ease-emphasized); + } + } + + // --------------------------------------------------------------------------- + // SHAPE: visual border/background (pseudo-element) + // --------------------------------------------------------------------------- + &::after { + content: ''; + position: absolute; + inset: var(--iusta-editable-outline-inset-top-bottom) var(--iusta-editable-outline-inset-left-right); + pointer-events: none; + + z-index: -1; + + border-bottom: 0.125rem solid var(--iusta-editable-outline-color, var(--iusta-editable-color, #428bca)); + border-radius: calc(var(--iusta-editable-radius, 0.25rem)) calc(var(--iusta-editable-radius, 0.25rem)) 0 0; + + background: var(--iusta-editable-editing-background); + } + + // --------------------------------------------------------------------------- + // DEFAULT BEHAVIOR: Fade in on focus (unless persistent) + // --------------------------------------------------------------------------- + &:not(:has(.iusta-editable--persistent)) { + &::after { + opacity: 0; + transition: none; + } + + &:focus-within::after, + &.is-editing::after { + opacity: 1; + transition: + opacity 0.18s ease, + border-color var(--iusta-t-fast) var(--iusta-ease-emphasized); + } + } + + // --------------------------------------------------------------------------- + // PERSISTENT BEHAVIOR: Always visible, color-only changes + // --------------------------------------------------------------------------- + &:has(.iusta-editable--persistent) { + &::after { + opacity: 1; + + transition: + border-color var(--iusta-t-fast) var(--iusta-ease-standard), + border-width var(--iusta-t-fast) var(--iusta-ease-standard), + background-color var(--iusta-t-fast) var(--iusta-ease-standard); + } + + &:focus-within::after, + &.is-editing::after { + transition: + border-color var(--iusta-t-fast) var(--iusta-ease-emphasized), + border-width var(--iusta-t-fast) var(--iusta-ease-emphasized), + background-color var(--iusta-t-fast) var(--iusta-ease-emphasized); + } + } + + // Shared focus/edit styles (apply to ALL variants) + &:focus-within::after, + &.is-editing::after { + border-bottom-color: var(--mat-sys-primary); + } + + // --------------------------------------------------------------------------- + // MODIFIER: Outline variant + // --------------------------------------------------------------------------- + &--outline { + &::after { + inset: -0.5rem -0.75rem; + border: 1px solid var(--iusta-editable-outline-color, var(--iusta-editable-color, #428bca)); + border-radius: 4px; + background: transparent; + } + + // Default outline: fade behavior (unless persistent) + &:not(:has(.iusta-editable--persistent)) { + &::after { + opacity: 0; + transition: none; + } + + &:focus-within::after, + &.is-editing::after { + opacity: 1; + + transition: + opacity var(--iusta-t-fast) var(--iusta-ease-emphasized), + border-color var(--iusta-t-fast) var(--iusta-ease-emphasized), + border-width var(--iusta-t-fast) var(--iusta-ease-emphasized); + } + } + + // Persistent outline: always visible, subtle default color + &:has(.iusta-editable--persistent) { + &::after { + opacity: 1; + border-color: var(--iusta-editable-outline-color, var(--mat-sys-outline-variant, #c4c7c5)); + + transition: + border-color var(--iusta-t-fast) var(--iusta-ease-standard), + border-width var(--iusta-t-fast) var(--iusta-ease-standard), + background-color var(--iusta-t-fast) var(--iusta-ease-standard); + } + } + + // Shared outline focus/edit styles + &:focus-within::after, + &.is-editing::after { + border-color: var(--mat-sys-primary); + background: var(--mat-sys-surface); + } + } + + &.invalid:focus-within::after { + background: var(--mat-sys-surface); + border-color: var(--mat-sys-error); + } + + // --------------------------------------------------------------------------- + // Shortcut action (clear value) + // --------------------------------------------------------------------------- + + &__shortcut { + align-self: center; + + // Default: hidden on desktop + opacity: 0; + visibility: hidden; + pointer-events: none; + + transition: + opacity 0.15s var(--iusta-ease-standard), + visibility 0.15s var(--iusta-ease-standard); + } + + // Reveal on intent (desktop + keyboard) + &:hover &__shortcut, + &:focus-within &__shortcut { + opacity: 1; + visibility: visible; + pointer-events: auto; + } + + // Touch / coarse pointer: always visible + @media (hover: none), (pointer: coarse) { + &__shortcut { + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: none; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .iusta-editable-wrapper { + transition: none; + } + + .iusta-editable-wrapper::after { + transition: none; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: Clear button (replacement for the aria-grid icon button) +// ----------------------------------------------------------------------------- +.iusta-editable-clear { + appearance: none; + -webkit-appearance: none; + box-sizing: border-box; + + display: inline-grid; + place-items: center; + + width: 28px; + height: 28px; + padding: 0; + margin: 0; + + border: none; + border-radius: 6px; + background: transparent; + color: var(--mat-sys-error, #dc3545); + + cursor: pointer; + font: inherit; + line-height: 1; + -webkit-tap-highlight-color: transparent; + + &:hover, + &:focus-visible { + background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 10%, transparent); + } + + &:focus-visible { + outline: 1.5px solid var(--mat-sys-error, #dc3545); + outline-offset: 1px; + } +} + +// ----------------------------------------------------------------------------- +// BLOCK: The Main Overlay Panel content +// ----------------------------------------------------------------------------- +.editable-panel__inner { + display: flex; + flex-direction: column; + gap: 1rem; + + padding: 8px 12px; +} + +// ----------------------------------------------------------------------------- +// ELEMENT: Actions Row +// ----------------------------------------------------------------------------- +.editable-panel__inner-actions { + display: flex; + align-items: center; + justify-content: space-between; + + &--revert { + display: flex; + align-items: flex-start; + } + + &--accept { + display: flex; + align-items: center; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: Shared Message (Error / Hint) +// ----------------------------------------------------------------------------- +.editable-panel__inner-message { + width: 100%; + word-break: break-word; + + font-family: var(--mat-sys-body-small-font, inherit); + font-size: var(--mat-sys-body-small-size, 0.75rem); + font-weight: var(--mat-sys-body-small-weight, 400); + line-height: var(--mat-sys-body-small-line-height, 1rem); + letter-spacing: var(--mat-sys-body-small-tracking, 0.025rem); + + margin-left: -0.15rem; + padding-left: 0; + + text-align: left; + + animation: message-enter 0.2s var(--iusta-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); + + &--error { + color: var(--mat-sys-error, #dc3545); + } + + &--warning { + color: var(--mat-sys-outline, #6b7280); // neutral warning tone + } +} + +@keyframes message-enter { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ----------------------------------------------------------------------------- +// BLOCK: Overlay panel + card (rendered in the CDK overlay container) +// ----------------------------------------------------------------------------- +.iusta-editable-panel { + background: transparent; + + // 1. Shared Colors + --_surface-color: var( + --iusta-sys-dropdown-background-color, + var(--mat-autocomplete-background-color, var(--mat-sys-surface-container, #fff)) + ); + --_border-color: color-mix(in srgb, var(--iusta-sys-on-surface, #000) 20%, var(--iusta-sys-surface-container, #fff)); + + // 2. Shadows + --_shadow-def: + 0.5px 0.5px 1px hsl(0deg 0% 0% / 0.05), 1px 1px 2px hsl(0deg 0% 0% / 0.05), 2px 2px 4px hsl(0deg 0% 0% / 0.05), + 4px 4px 6px hsl(0deg 0% 0% / 0.04); + + // 3. Animation Defaults (Downwards) + --anim-origin: top center; + --anim-start-y: -4px; + + // 4. Shape Defaults (Bottom Rounded) + --_radius: 0 0 var(--mat-sys-corner-large, 16px) var(--mat-sys-corner-large, 16px); + --_border-width: 0 1px 1px 1px; // Top Right Bottom Left + + /* TOP variant: Opens upwards */ + &.__top { + --anim-origin: bottom center; + --anim-start-y: 4px; + + // Rounded Top + --_radius: var(--mat-sys-corner-large, 16px) var(--mat-sys-corner-large, 16px) 0 0; + --_border-width: 1px 1px 0 1px; + } +} + +.iusta-editable-card { + width: 100%; + box-sizing: border-box; + + // Background + background-color: var(--_surface-color, var(--mat-sys-surface-container, #fff)); + + // Shadow + box-shadow: var(--_shadow-def); + + // Borders + border-style: solid; + border-color: var(--_border-color, var(--mat-sys-outline-variant, #c4c7c5)); + border-width: var(--_border-width, 1px); + border-radius: var(--_radius, 0.5rem); +} + +// ----------------------------------------------------------------------------- +// ANIMATION: overlay panel enter +// ----------------------------------------------------------------------------- +@keyframes iusta-dynamic-enter { + 0% { + opacity: 0; + transform: translate3d(0, var(--anim-start-y, 0), 0); + } + 100% { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +.dropdown-animation-enter { + transform-origin: var(--anim-origin, top center); + + animation: iusta-dynamic-enter 0.15s cubic-bezier(0.86, 0, 0.14, 1); + will-change: opacity, transform; +} diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts new file mode 100644 index 0000000..1abbe6a --- /dev/null +++ b/projects/angular-inline-select/src/public-api.ts @@ -0,0 +1,12 @@ +/* + * Public API Surface of angular-inline-select + */ + +export * from './lib/angular-inline-text/angular-inline-text'; +export * from './lib/angular-inline-text/editable-wrapper/editable-wrapper'; +export * from './lib/angular-inline-text/editable-action-buttons/editable-action-buttons'; +export * from './lib/angular-inline-text/directives/editable-overlay-control'; +export * from './lib/angular-inline-text/directives/overlay-width-sync'; +export * from './lib/angular-inline-text/directives/textarea-autosize'; +export * from './lib/angular-inline-text/directives/restrict-characters/restrict-characters'; +export * from './lib/angular-inline-text/directives/restrict-characters/tokens'; diff --git a/projects/angular-inline-select/tsconfig.lib.json b/projects/angular-inline-select/tsconfig.lib.json new file mode 100644 index 0000000..ffc453e --- /dev/null +++ b/projects/angular-inline-select/tsconfig.lib.json @@ -0,0 +1,21 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declaration": true, + "declarationMap": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/angular-inline-select/tsconfig.lib.prod.json b/projects/angular-inline-select/tsconfig.lib.prod.json new file mode 100644 index 0000000..86f6d18 --- /dev/null +++ b/projects/angular-inline-select/tsconfig.lib.prod.json @@ -0,0 +1,17 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial", + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/angular-inline-select/tsconfig.spec.json b/projects/angular-inline-select/tsconfig.spec.json new file mode 100644 index 0000000..fa2e8be --- /dev/null +++ b/projects/angular-inline-select/tsconfig.spec.json @@ -0,0 +1,18 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/app/public/favicon.ico b/projects/app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/projects/app/src/app/app.config.ts b/projects/app/src/app/app.config.ts new file mode 100644 index 0000000..31fe709 --- /dev/null +++ b/projects/app/src/app/app.config.ts @@ -0,0 +1,8 @@ +import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter, withViewTransitions } from '@angular/router'; + +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes, withViewTransitions())], +}; diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html new file mode 100644 index 0000000..694309e --- /dev/null +++ b/projects/app/src/app/app.html @@ -0,0 +1,113 @@ + + + +
+ +
+ + +
+
+ + + +
+ Push left + + + + +
+ +
+
+
+

Inline text in a paragraph

+

Single-line and multi-line editables embedded in running copy.

+
+ +
+

+ The + + project ships editable text right inside running copy — no form fields, no mode switches. The value below is a + multi-line editable that autosizes while you type: +

+ + +
+
+ +
+
+

Inline text in a table

+

100 rows, every name and note editable in place. The table scrolls inside the viewport.

+
+ +
+ + + + + + + + + + + + + + + + + + +
#{{ row.position }}Name + + Notes + +
+
+
+
diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts new file mode 100644 index 0000000..dc39edb --- /dev/null +++ b/projects/app/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/projects/app/src/app/app.scss b/projects/app/src/app/app.scss new file mode 100644 index 0000000..5a7935c --- /dev/null +++ b/projects/app/src/app/app.scss @@ -0,0 +1,216 @@ +@use '@angular/material' as mat; + +// The toolbar is ~64px high; the examples subtract it so each one +// fills the remaining viewport exactly. +$toolbar-height: 64px; + +.toolbar { + @include mat.toolbar-overrides( + ( + container-background-color: transparent, + ) + ); + + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + position: sticky; + top: 0; + z-index: 10; + gap: 8px; +} + +.app-title { + font: var(--mat-sys-title-large); + max-width: min(50ch, 50vw); +} + +.spacer { + flex: 1 1 auto; +} + +.toolbar-actions { + display: flex; + gap: 8px; +} + +/* --- Right floating nav --- */ + +.floating-nav { + position: fixed; + right: 16px; + top: 50%; + transform: translateY(-50%); + z-index: 20; + + display: flex; + flex-direction: column; + gap: 8px; + + a { + display: block; + padding: 8px 14px; + border-radius: 999px; + + background: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface); + border: 1px solid var(--mat-sys-outline-variant); + + font: var(--mat-sys-label-large); + text-decoration: none; + + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover, + &:focus-visible { + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + } + } +} + +/* --- Examples: each fills the viewport --- */ + +main { + display: flex; + flex-direction: column; + + // Animate the manual push so the shift is gradual — ResizeObserver fires + // on every frame of the transition, not just once. + transition: margin-left 0.3s ease; + + // Continuous stress test: keeps the layout shifting while an editable is + // open, forcing constant re-measure + overlay reposition. + &.oscillate { + animation: push-left 2s ease-in-out infinite alternate; + } +} + +@keyframes push-left { + from { + margin-left: 0; + } + to { + margin-left: 320px; + } +} + +/* --- Layout shift tester (bottom left) --- */ + +.shift-controls { + position: fixed; + left: 16px; + bottom: 16px; + z-index: 20; + + display: flex; + align-items: center; + gap: 8px; + + padding: 8px 12px; + border-radius: 999px; + background: var(--mat-sys-surface-container-high); + border: 1px solid var(--mat-sys-outline-variant); + + &__label { + font: var(--mat-sys-label-large); + color: var(--mat-sys-on-surface-variant); + padding-right: 4px; + } +} + +.example { + // Fill exactly the viewport below the sticky toolbar + min-height: calc(100svh - #{$toolbar-height}); + box-sizing: border-box; + + // When jumping via # anchors, land below the sticky toolbar + scroll-margin-top: $toolbar-height; + + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 24px 88px 24px 24px; // extra right padding clears the floating nav + + display: flex; + flex-direction: column; + gap: 16px; +} + +.example__header { + h2 { + font: var(--mat-sys-headline-medium); + margin: 0 0 4px; + } + + p { + color: var(--mat-sys-on-surface-variant); + margin: 0; + } +} + +// The body stretches so the example really fills the viewport +.example__body { + flex: 1 1 auto; + min-height: 0; +} + +/* --- Paragraph example --- */ + +.example-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.5rem; + + padding: clamp(16px, 4vw, 48px); + border-radius: 0.75rem; + border: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface-bright); +} + +.prose { + font: var(--mat-sys-body-large); + max-width: 70ch; + margin: 0; +} + +.prose-block { + display: block; + max-width: 70ch; +} + +/* --- Table example: 100 rows, scrolls inside the viewport --- */ + +.table-scroll { + overflow: auto; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 0.75rem; + background: var(--mat-sys-surface-bright); +} + +.demo-table { + width: 100%; + + // Room for the editable's focus ring inside cells + td { + padding-top: 4px; + padding-bottom: 4px; + } +} + +/* --- Small screens: tuck the nav to the bottom right --- */ + +@media (max-width: 720px) { + .floating-nav { + top: auto; + bottom: 16px; + transform: none; + flex-direction: row; + } + + .example { + padding: 16px; + } +} diff --git a/projects/app/src/app/app.spec.ts b/projects/app/src/app/app.spec.ts new file mode 100644 index 0000000..75753d6 --- /dev/null +++ b/projects/app/src/app/app.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; +import { App } from './app'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/app.ts b/projects/app/src/app/app.ts new file mode 100644 index 0000000..81385cd --- /dev/null +++ b/projects/app/src/app/app.ts @@ -0,0 +1,130 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + + // Signals + signal, + computed, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +// Material +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatDialog } from '@angular/material/dialog'; + +// Components +import { AngularInlineText } from '../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; +import { Login } from './login/login'; + +export interface DemoRow { + position: number; + name: string; + notes: string; +} + +const SAMPLE_NAMES = [ + 'Aurora', + 'Borealis', + 'Cascade', + 'Drift', + 'Ember', + 'Flux', + 'Gossamer', + 'Halo', + 'Iris', + 'Junction', +]; + +@Component({ + selector: '[app-root]', + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.Eager, + styleUrl: './app.scss', + imports: [ + // Material + MatToolbarModule, + MatButtonModule, + MatIconModule, + MatTableModule, + + // Components + AngularInlineText, + ], + host: { + '[class]': 'themeClass()', + }, +}) +export class App { + #document = inject(DOCUMENT); + #dialog = inject(MatDialog); + + /** + * The toolbar title. Editable in place — and set by the login dialog. + */ + protected readonly title = signal('Inline Text Playground'); + + // --------------------------------------------------------------------------- + // Paragraph example + // --------------------------------------------------------------------------- + protected projectName = signal('Aurora'); + protected summary = signal( + 'Click any highlighted text on this page and start typing. ' + + 'Save with Ctrl+Enter or the Save button, discard with Escape — ' + + 'the overlay only appears once you actually change something.', + ); + + // --------------------------------------------------------------------------- + // Table example (100 rows) + // --------------------------------------------------------------------------- + protected displayedColumns = ['position', 'name', 'notes']; + + protected rows: DemoRow[] = Array.from({ length: 100 }, (_, i) => ({ + position: i + 1, + name: `${SAMPLE_NAMES[i % SAMPLE_NAMES.length]} ${i + 1}`, + notes: `Editable notes for row ${i + 1}`, + })); + + // --------------------------------------------------------------------------- + // Layout shift tester + // --------------------------------------------------------------------------- + // Pushes the whole content area aside with a left margin to stress-test the + // ResizeObserver in OverlayWidthSyncDirective: the editable wrapper resizes, + // the overlay has to re-measure and reposition while open. + protected pushMargin = signal(0); + protected oscillate = signal(false); + + // --------------------------------------------------------------------------- + // Login + // --------------------------------------------------------------------------- + protected openLoginDialog() { + const ref = this.#dialog.open(Login, { + width: 'min(60ch, 100dvw)', + }); + + ref.afterClosed().subscribe((displayName?: string) => { + if (displayName) this.title.set(displayName); + }); + } + + // --------------------------------------------------------------------------- + // Theme + // --------------------------------------------------------------------------- + theme = signal<'light' | 'dark'>('light'); + themeClass = computed(() => `${this.theme()}-mode`); + + toggleTheme() { + if (this.#document.startViewTransition) { + this.#document.startViewTransition(() => { + this.theme.update((theme) => (theme === 'light' ? 'dark' : 'light')); + }); + + return; + } + + this.theme.update((theme) => (theme === 'light' ? 'dark' : 'light')); + } +} diff --git a/projects/app/src/app/login/login.html b/projects/app/src/app/login/login.html new file mode 100644 index 0000000..29e5bd7 --- /dev/null +++ b/projects/app/src/app/login/login.html @@ -0,0 +1,19 @@ +

Sign In

+ +

Pick a display name — it becomes the toolbar title.

+
+ + +
+ + diff --git a/projects/app/src/app/login/login.scss b/projects/app/src/app/login/login.scss new file mode 100644 index 0000000..8e905d1 --- /dev/null +++ b/projects/app/src/app/login/login.scss @@ -0,0 +1,7 @@ +.login-button { + width: 100%; +} + +.login-actions { + background-color: var(--mat-sys-surface-container-high); +} diff --git a/projects/app/src/app/login/login.spec.ts b/projects/app/src/app/login/login.spec.ts new file mode 100644 index 0000000..1ee4152 --- /dev/null +++ b/projects/app/src/app/login/login.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Login } from './login'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/login/login.ts b/projects/app/src/app/login/login.ts new file mode 100644 index 0000000..115947f --- /dev/null +++ b/projects/app/src/app/login/login.ts @@ -0,0 +1,28 @@ +import { Component, signal } from '@angular/core'; + +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; + +// Components +import { AngularInlineText } from '../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; + +@Component({ + selector: 'app-login', + imports: [ + // Material + MatDialogModule, + MatButtonModule, + + // Components + AngularInlineText, + ], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + /** + * The display name entered by the user. On "Sign In" this is returned + * as the dialog result and becomes the app's toolbar title. + */ + displayName = signal(''); +} diff --git a/projects/app/src/index.html b/projects/app/src/index.html new file mode 100644 index 0000000..907ed93 --- /dev/null +++ b/projects/app/src/index.html @@ -0,0 +1,24 @@ + + + + + + Angular Inline Text + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/projects/app/src/main.ts b/projects/app/src/main.ts new file mode 100644 index 0000000..190f341 --- /dev/null +++ b/projects/app/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/projects/app/src/styles.scss b/projects/app/src/styles.scss new file mode 100644 index 0000000..8a3b669 --- /dev/null +++ b/projects/app/src/styles.scss @@ -0,0 +1,45 @@ +// Include theming for Angular Material with `mat.theme()`. +// This Sass mixin will define CSS variables that are used for styling Angular Material +// components according to the Material 3 design spec. +@use '@angular/material' as mat; + +// Shared (global) styles for angular-inline-text: editable field, wrapper and +// overlay panel classes that render outside component encapsulation. +@use '../../angular-inline-select/src/lib/styles/inline-text'; + +html { + height: 100%; + scroll-behavior: smooth; + + @include mat.theme( + ( + color: ( + primary: mat.$cyan-palette, + tertiary: mat.$orange-palette, + ), + typography: Roboto, + density: 0, + ) + ); +} + +body.dark-mode { + color-scheme: dark; +} + +body { + // Default the application to a light color theme. This can be changed to + // `dark` to enable the dark color theme, or to `light dark` to defer to the + // user's system settings. + color-scheme: light; + + // Set a default background, font and text colors for the application using + // Angular Material's system-level CSS variables. + background-color: var(--mat-sys-surface); + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-medium); + + // Reset the user agent margin. + margin: 0; + height: 100%; +} diff --git a/projects/app/tsconfig.app.json b/projects/app/tsconfig.app.json new file mode 100644 index 0000000..ec1c26f --- /dev/null +++ b/projects/app/tsconfig.app.json @@ -0,0 +1,19 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/app/tsconfig.spec.json b/projects/app/tsconfig.spec.json new file mode 100644 index 0000000..48fcc2f --- /dev/null +++ b/projects/app/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": ["vitest/globals"] + }, + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3579499 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,42 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "paths": { + "angular-inline-select": ["./dist/angular-inline-select"] + }, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "references": [ + { + "path": "./projects/app/tsconfig.app.json" + }, + { + "path": "./projects/app/tsconfig.spec.json" + }, + { + "path": "./projects/angular-inline-select/tsconfig.lib.json" + }, + { + "path": "./projects/angular-inline-select/tsconfig.spec.json" + } + ] +} From 626afb28ec2be0b3e280c1299f9635d749e91a60 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Sun, 5 Jul 2026 18:11:56 +0200 Subject: [PATCH 02/48] feat(core): springbooted --- .claude/launch.json | 11 + .vscode/settings.json | 37 + .vscode/tasks.json | 8 +- ROADMAP.md | 335 +++-- eslint.config.mjs | 63 + package-lock.json | 1295 ++++++++++++++++- package.json | 5 +- prettier.config.mjs | 18 + .../angular-inline-number.html | 25 + .../angular-inline-number.spec.ts | 244 ++++ .../angular-inline-number.ts | 200 +++ .../angular-inline-text.html | 192 ++- .../angular-inline-text.scss | 29 - .../angular-inline-text.spec.ts | 484 +++++- .../angular-inline-text.ts | 789 ++++++++-- .../src/lib/angular-inline-text/caret.ts | 124 ++ .../directives/editable-overlay-control.ts | 121 -- .../directives/overlay-width-sync.ts | 294 ---- .../restrict-characters.ts | 106 -- .../directives/restrict-characters/tokens.ts | 36 - .../directives/textarea-autosize.ts | 35 - .../editable-action-buttons.html | 36 - .../editable-action-buttons.scss | 52 - .../editable-action-buttons.ts | 23 - .../lib/angular-inline-text/editable-affix.ts | 36 + .../lib/angular-inline-text/editable-error.ts | 25 + .../editable-wrapper/editable-wrapper.html | 65 - .../editable-wrapper/editable-wrapper.scss | 3 - .../editable-wrapper/editable-wrapper.ts | 257 ---- .../src/lib/styles/_editable-text.scss | 171 +++ .../src/lib/styles/_editable.scss | 200 +++ .../src/lib/styles/_index.scss | 13 + .../src/lib/styles/inline-text.scss | 547 ------- .../angular-inline-select/src/public-api.ts | 11 +- projects/app/src/app/app.html | 95 +- projects/app/src/app/app.routes.ts | 14 +- projects/app/src/app/app.scss | 193 +-- projects/app/src/app/app.spec.ts | 2 + projects/app/src/app/app.ts | 62 +- projects/app/src/app/login/login.html | 9 +- projects/app/src/app/pages/_demo.scss | 122 ++ .../number-playground/number-playground.html | 99 ++ .../number-playground/number-playground.scss | 6 + .../number-playground/number-playground.ts | 98 ++ .../text-playground/text-playground.html | 162 +++ .../text-playground/text-playground.scss | 130 ++ .../pages/text-playground/text-playground.ts | 151 ++ projects/app/src/styles.scss | 7 +- 48 files changed, 4723 insertions(+), 2317 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .vscode/settings.json create mode 100644 eslint.config.mjs create mode 100644 prettier.config.mjs create mode 100644 projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html create mode 100644 projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/caret.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-affix.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss delete mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts create mode 100644 projects/angular-inline-select/src/lib/styles/_editable-text.scss create mode 100644 projects/angular-inline-select/src/lib/styles/_editable.scss create mode 100644 projects/angular-inline-select/src/lib/styles/_index.scss delete mode 100644 projects/angular-inline-select/src/lib/styles/inline-text.scss create mode 100644 projects/app/src/app/pages/_demo.scss create mode 100644 projects/app/src/app/pages/number-playground/number-playground.html create mode 100644 projects/app/src/app/pages/number-playground/number-playground.scss create mode 100644 projects/app/src/app/pages/number-playground/number-playground.ts create mode 100644 projects/app/src/app/pages/text-playground/text-playground.html create mode 100644 projects/app/src/app/pages/text-playground/text-playground.scss create mode 100644 projects/app/src/app/pages/text-playground/text-playground.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ae17907 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "app", + "runtimeExecutable": "/Users/hongknop/.nvm/versions/node/v24.15.0/bin/node", + "runtimeArgs": ["node_modules/.bin/ng", "serve", "app", "--port", "4300"], + "port": 4300 + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bc20a91 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,37 @@ +{ + // Enable format on save for all files + "editor.formatOnSave": true, + // Use ESLint to fix TypeScript and JavaScript files on save + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + // Set Prettier as the default formatter for HTML files + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + // Set Prettier as the default formatter for TypeScript files + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Set Prettier as the default formatter for JavaScript files + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Set Prettier as the default formatter for JSON files + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Set Prettier as the default formatter for SCSS files + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + // Angular Language Service settings + "angular.enable-strict-mode-prompt": false, + "cSpell.words": [ + "bpmnlint", + "editables", + "flaticon", + "Preselection" + ] + } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 244306f..a298b5b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -12,10 +12,10 @@ "background": { "activeOnStart": true, "beginsPattern": { - "regexp": "Changes detected" + "regexp": "(.*?)" }, "endsPattern": { - "regexp": "bundle generation (complete|failed)" + "regexp": "bundle generation complete" } } } @@ -30,10 +30,10 @@ "background": { "activeOnStart": true, "beginsPattern": { - "regexp": "Changes detected" + "regexp": "(.*?)" }, "endsPattern": { - "regexp": "bundle generation (complete|failed)" + "regexp": "bundle generation complete" } } } diff --git a/ROADMAP.md b/ROADMAP.md index d0d42d7..9f17d07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,143 +1,208 @@ -# ROADMAP — angular-inline-text +# Roadmap — angular-inline-text -Goal: an inline edit component that emits the changed value on save (`savedModelChange`), auto-reverts non-accepted values, and notifies the parent via a `reverted` output. Angular 22, zoneless, signal-based, standalone. Styled with `--mat-sys-*` tokens (with fallbacks), built primarily on `@angular/aria` / CDK, Material only where unavoidable. +## North star -Contract decisions (agreed): -- Live value propagation stays (dual mode with injected `FormField` is kept). -- New `reverted` output fires whenever a draft is discarded (Escape, decline, outside-click decline, detach revert). -- Pre-1.0: breaking API changes are allowed. +**The field dictates, the component renders.** `angular-inline-text` is a +`FormValueControl` — it should own no state the `FormUiControl` contract has a +word for. Its only private state is the *session* concepts signal forms does +not model: the draft (living in the `value` channel), the session baseline, +the open panel, and `saveAttempted`. ---- - -## What is already good (preserve) - -- Pure signal architecture: `computed`, `linkedSignal`, `model`, host bindings — no zone reliance, no RxJS in the hot path. -- `FormValueControl` + signal-forms integration; standalone fallback via local `form()`. -- CDK `cdkConnectedOverlay` with paired top/bottom positions, `cdkTrapFocus`, appearance variants (`fill`/`outline`). -- Theming via `--mat-sys-*` tokens with fallbacks; `prefers-reduced-motion` handled; `field-sizing: content` with graceful fallback. -- `RestrictCharacters` strategy pattern: IME-safe (`compositionstart/end`), delegates `beforeinput`/`paste`/`keydown` — good extension point. -- `OverlayWidthSyncDirective`: ResizeObserver + rAF-throttled reposition, context-over-input resolution. - ---- - -## Phase 0 — Bug fixes (no visual change) - -- [ ] **Blur guard selector mismatch**: `EditableOverlayControl.onBlur` checks `closest('.editable-panel')`, but the panel renders `.editable-panel__inner` / `.iusta-editable-panel`. The guard never matches → overlay can close while focus moves into it. Fix selector (or better: compare against the overlay element ref instead of a class string). -- [ ] **Dead code**: `provideAutosize()` is never called; `scrollStrategy` computed in `OverlayWidthSyncDirective` is never used; `copyValue` signal is never written; `warningMessage` linkedSignal always computes the same constant. Remove or wire up. -- [ ] **Duplicate autosize effect**: `resize` effect and `provideAutosize()` are copies. Keep exactly one code path (see Phase 2 — likely neither). -- [ ] **`accepted` mutable boolean + `autoResetAccepted` effect**: ordering-fragile (accept → detach race). Replace with a signal or reset it synchronously where state transitions happen; delete the effect. -- [ ] **`errorMessage`** in wrapper returns static `'Invalid input'` while the template already renders real error messages — deduplicate (panelMessage vs. template `@for` over errors render competing messages). - -## Phase 1 — Contract: FormValueControl + save / revert semantics - -First-class signal-forms citizenship — the component must behave identically in all three modes: bound via `[formField]` (signal forms), plain `[(value)]` model binding, and fully standalone. - -- [ ] **Implement the FormValueControl contract natively** instead of injecting `FormField` and mirroring its state: declare `disabled = input(false)`, `readonly = input(false)`, `required = input(false)`, `errors = input([])` — the form binds these automatically. Remove the `inject(FormField)` workaround. -- [ ] **Accept must write the value model**: today `accept()` only emits `savedModelChange`; `value.set(normalized)` is the actual channel signal forms and `[(value)]` consumers listen to. Order: `value.set()` → `savedModelChange.emit()` → close. -- [ ] **Add `touched = model(false)`**: set on first edit-session end (blur/close) so the form's touched state is real. -- [ ] **Drop the nested mirror `form()`** whose `validate()` replays parent errors (double validation). Keep a local `form()` only for the *draft* (draft-local validation like restrict/normalize checks); bound-field errors come in via the `errors` input. -- [ ] Live propagation stays: keystrokes update `value` while editing; revert sets `value` back to `previous` and emits `reverted`. -- [ ] Add `reverted = output()` (payload: the discarded draft value). Emit on: Escape decline, Discard button, outside-click decline, detach-revert in `handleDetach`. -- [ ] Single choke point for "discard": today revert logic is spread across `(declined)="localForm().reset(previous)"` in the template, `handleDetach`, and wrapper decline handling. Route all paths through one `revert()` method in `AngularInlineText`. -- [ ] Enter accepts on single-line input (currently only Ctrl+Enter). Keep Ctrl+Enter for textarea. -- [ ] `previous` linkedSignal reads `dirty()` inside its computation — document or refactor; latching behavior is correct today but non-obvious and easy to break. Consider explicit `previous = signal()` set at edit-session start (`showForm` → true) instead. -- [ ] Review `handleOutsideClick` half-viewport Euclidean distance gate: replace magic threshold with an input (`declineDistance`) or a simpler rule (click on another `.iusta-editable-wrapper` → decline; otherwise refocus). Document whichever stays. -- [ ] Tests (vitest): accept sets `value` and emits once with normalized value; decline/detach reverts and emits `reverted`; invalid blocks accept; `normalizeValue` trims before compare (no false-dirty); all three binding modes covered (signal form / `[(value)]` / standalone); `touched`/`disabled`/`readonly` round-trip with a real `form()`. - -## Phase 2 — Performance - -- [ ] **Autosize** *(superseded by Phase 4 contenteditable — height becomes text flow; skip if Phase 4 lands first)*: per keystroke today = input-handler resize + effect rAF resize, each doing `height='auto'` + `scrollHeight` read → multiple forced reflows. Interim: `field-sizing: content` primary, `TextareaAutosize` as `@supports not` fallback, delete the component-level effects; reposition overlay via `afterRenderEffect`. -- [ ] **ResizeObserver lifecycle**: observer runs from `ngAfterViewInit` forever, on every instance (a page of 50 inline fields = 50 live observers). Interim fix: observe only while the overlay is open. Superseded by Phase 4's view/edit split, which deletes the observer entirely — if Phase 4 lands first, skip this. -- [ ] **Template object identity**: `[mEditableOverlayControl]="{ showSignal: showForm }"` allocates a fresh object every template execution. Bind the signal directly (split into two inputs) or build the object once in the component. -- [ ] **Overlay config churn**: `overlayConfig` recomputes on every width change while open. Verify CdkConnectedOverlay diffing; if it rebuilds, pass width via `cdkConnectedOverlayWidth` only. -- [ ] Measure before/after: Chrome performance trace of typing in a multiline field; assert single layout pass per keystroke. - -## Phase 3 — Accessibility (@angular/aria first) - -- [ ] `@angular/aria` is a dependency but unused — adopt it for the combobox-like pattern (input + owned panel) where it fits; fall back to CDK a11y, Material last. -- [ ] Wire input ↔ panel: `aria-expanded`, `aria-controls`, panel `role` (likely `dialog` for the confirm card), `aria-describedby` for error/warning messages. -- [ ] Error/warning messages in a live region (`aria-live="polite"`), so screen readers hear validation without focus moves. -- [ ] Replace hand-rolled `handleTab` querySelector-focusable-walk with CDK `FocusTrap.focusFirstTabbableElement()` / `InteractivityChecker` — the panel already has `cdkTrapFocus`. -- [ ] Panel `
` review: focusable container without a role is noise; give it a role or drop the tabindex. -- [ ] Clear button: confirm hover-reveal doesn't hide it from keyboard/AT (it's `visibility: hidden` until `:focus-within` — verify tab order reaches it and add `aria-label` audit). -- [ ] Keyboard spec written down: Enter (single-line save), Ctrl+Enter (textarea save), Escape (revert), Tab-while-dirty (into panel). - -## Phase 4 — Appearance & motion (Framer-grade) - -### Non-negotiables (agreed) - -1. **Per-line dashed underline in multiline** — the underline hugs each text line and stops where the text stops (never covers empty input space). -2. Opening/edit transition must feel smooth and designed, not a restyle-snap. -3. Action/clear buttons float — they reserve **no** layout space (must also work inside `mat-dialog`). -4. Spring-based, interruptible motion (Cheng Lou / react-motion philosophy: no fixed-duration feel, velocity-preserving). - -### Editing surface: contenteditable (the structural fix — decided) - -A textarea is a rectangular box — no border, `::after`, or background can ever hug wrapped text lines, and it can never wrap inline within a paragraph. The ResizeObserver + width-sync + autosize machinery exists only to fight these symptoms. The Notion answer: the rendered text IS the editor. +Our one honest deviation from a normal form: there is one field and no form +tag, therefore no `submit`. `accept()` is a per-field submit, and +`#saveAttempted` is our stand-in for the `form.submitted` half of mat's +`ErrorStateMatcher`. Everything else defers to the bound field. -- [ ] **Single surface**: the inline span carries `contenteditable="plaintext-only"`. Same element at rest and while editing — true inline flow in paragraphs in both states, wraps mid-line, never pushes surrounding text, zero layout shift on click, native caret-at-click-point. -- [ ] **Underline**: `text-decoration: underline dashed` + `text-underline-offset` (native text paint — most performant). Fallback to `repeating-linear-gradient` + `box-decoration-break: clone` only if dash geometry needs exact control. Per-line, text-hugging, in view *and* edit state. -- [ ] **Delete the machinery**: `TextareaAutosize`, `field-sizing` juggling, `OverlayWidthSyncDirective` ResizeObserver, span↔textarea metric parity — all removed. Height/width are just text flow. -- [ ] **Value sync**: `textContent` ↔ draft signal on `input` events; `FormValueControl` contract (Phase 1) is untouched — it lives on the component, not the element. -- [ ] **plaintext-only support**: requires Firefox 136+ (2025). Fallback path: plain `contenteditable` + `beforeinput` filtering of `insertFromPaste`/formatting — `RestrictCharacters` already hooks exactly these events; extend it to double as the fallback sanitizer. -- [ ] **Single-line variant**: block `insertParagraph`/`insertLineBreak` in `beforeinput` (Enter = accept instead); `white-space: nowrap` + fade-out mask at overflow. -- [ ] **A11y wiring (moves from nice-to-have to required)**: `role="textbox"`, `aria-multiline`, `aria-invalid`, `aria-readonly`; label association; verify SR announcement of edit mode. IME test matrix (CJK composition on contenteditable). -- [ ] **Disabled/readonly**: `contenteditable=false` + cursor/appearance states; ensure copy still works. -- [ ] **Floating actions**: clear button (and future affordances) in an absolutely positioned rail pinned to the wrapper edge — zero layout shift, fade+scale entrance. Verify inside `mat-dialog` (stacking context, overflow clipping); CDK overlay panel already escapes the dialog. +Mat-form-field split, applied throughout: the **consumer decides what errors +say** (projected `[editable-error]`, the `mat-error` analogue), the **field +decides when they show** (`invalid && (touched || saveAttempted)`). -### Motion system - -- [ ] **Spring easings, web-native**: CSS `linear()` easings generated from spring curves for enters/exits (no JS, no lib); a tiny WAAPI spring helper only where mid-flight interruption matters (expansion morph). Duration tokens become spring presets (`--iusta-spring-snappy`, `--iusta-spring-gentle`). -- [ ] **Overlay exit animation**: only `animate.enter` exists — the panel pops out. Add `animate.leave` (fade + 4px translate + slight scale-down, accelerate). Exit never blocks interaction. -- [ ] **Panel internal height choreography**: error/warning/action rows cause hard jumps. `interpolate-size: allow-keywords` + height transition, grid-rows `0fr → 1fr` fallback; messages fade/slide staggered ~30ms after the container settles. -- [ ] **Micro-interactions**: Save morphs to checkmark on success (~300ms, then close); invalid accept = ±3px x-shake (200ms); pressed-state scale 0.97 with spring-back; floating buttons fade+scale rather than visibility-flip. -- [ ] **Performance budget**: compositor-only (`transform`/`opacity`) except the sanctioned height choreography. 60fps trace while typing with panel open. -- [ ] **Reduced motion parity**: every animation gets a `prefers-reduced-motion` branch (opacity-only or none). - -## Phase 5 — Theming & styles (preserve the look) - -- [ ] Replace hard-coded `#428bca` default with token chain: `var(--iusta-editable-color, var(--mat-sys-primary, #428bca))`. -- [ ] Overlay pixel offsets (`VISUAL_Y_OFFSET = 7.5` "eyeballed at 13px font") break at other root font sizes. Derive from the same CSS custom properties the SCSS uses (read once per attach via `getComputedStyle`) or express insets in rem on both sides. -- [ ] Remove inline styles from templates (`style="margin-left: 8px"`, `[style.marginLeft]="'-1rem'"`) → SCSS classes. -- [ ] Document the public CSS API: every `--iusta-*` variable, in the README, with defaults. -- [ ] Visual regression: before/after screenshots of fill/outline × empty/filled × idle/editing/invalid, light + dark. - -## Phase 6 — API & DX (pre-1.0 cleanup) - -- [ ] Consistent selector prefix: `angular-inline-text` vs `m-editable-wrapper` vs `mEditableOverlayControl` vs `iusta-*` CSS. Pick one prefix (suggest `iusta`) for selectors, directives, and CSS. -- [ ] Trim public API: `public-api.ts` exports everything, including internals (`OverlayWidthSyncDirective` context plumbing). Export only what consumers compose. -- [ ] Package naming: library is `angular-inline-select` but ships an inline *text* component — align name before publishing, or document the select roadmap. -- [ ] `EditableOverlayControl.state()` throws inside a `computed` when no form is provided — fail at construction time with a clear message instead. -- [ ] README: usage with signal forms, standalone usage, normalization behavior, keyboard map, theming variables. +Guiding precedent: `MatInput`. It implements `MatFormFieldControl` with *no* +value ownership — value restoration is the form's job; the control only keeps +its presentation state honest. Signal forms improves on its `ngDoCheck` +error-state polling by delivering `touched`/`invalid` as inputs. --- -## House style (apply to all new/touched code) - -Conventions distilled from the existing codebase — every phase's code follows these. - -**Imports** — grouped with banner comments, in fixed order: Angular core (with a `// Signal` sub-group for signal primitives), Material & CDK, third-party, core infrastructure (services/models/enums/pipes), shared UI components, domain-specific components. The `@Component.imports` array gets the same grouping comments (`// Material`, `// Pipes`, `// Components`). - -**Dependency injection** — `inject()` only, never constructor injection. Injected services are native private fields: `#document = inject(Document)`. - -**Signals first** — `input()` / `model()` / `output()` / `computed()` / `signal()` / `linkedSignal()` / `viewChild()`; no decorators. Derive, don't store: state that can be computed from other signals is a `computed()` (e.g. selections derived from view children), never a synced copy. Compose small named computeds into larger ones (clause → and → where pattern) instead of one monolithic computation. - -**Host over template wrappers** — bindings and listeners in `host` metadata (`'[attr.id]': '_id()'`, `'[class.x]': 'cond()'`), not `@HostBinding`/`@HostListener`, not wrapper divs. - -**Class body organization** — section separators (`/// Getters`, `/// Lifecycle`, or `// ---` banners) grouping: DI, inputs/models, derived signals, handlers, lifecycle. JSDoc on every public API member (inputs, models, outputs, public methods) explaining intent, not mechanics. - -**Control flow** — guard clauses and early returns over nesting. Action dispatch via discriminated unions + `switch` (`DocumentTableRowAction` pattern) rather than boolean flag parameters. - -**Lazy boundaries** — heavy or rarely-used UI (dialogs) loaded via dynamic `import().then(({ X }) => dialog.open(X, ...))` at the call site; keeps the eager bundle lean. - -**Async hygiene** — cancellable requests take an `AbortSignal`; resolvers receive the signal explicitly. - -**Naming** — verb-first handlers (`handleX`, `openX`, `clearX`), `bulkX` for multi-row operations, `isX`/`hasX` for boolean signals and helpers, `X = model(...)` / `X = input(...)` names describe the datum not the mechanism. - ---- - -## Verification (every phase) - -1. `ng test` (vitest) green; new behavior covered in Phase 1/2 specs. -2. `ng build` library + app, zoneless app boots without change-detection warnings. -3. Manual pass in the demo app: single-line, multiline, outline, required, restricted-input fields — look must be pixel-identical except where a phase says otherwise. +## Shipped on this branch + +- **Error slot takeover.** The `[editable-error]` projection is gated by the + field itself (`errorsVisible`) and takes over the slot entirely via + `ng-content` fallback content; without projection the field renders + message-carrying contract errors. Consumers only write `hasError(kind)` + analogues — never `touched()` checks. +- **Contract adoption** (`touched`, `invalid`, `hidden` inputs + `reset()`). + `errorsVisible = isInvalid && (touched() || #selfTouched() || + #saveAttempted())` — the field's touched verdict wins, `#selfTouched` + covers the `[(value)]`/standalone modes. `isInvalid = invalid() || + errors().length > 0`. `hidden` collapses the host. `reset()` is + presentation-only (MatInput precedent) plus the one draft-control extra: + an open draft is discarded back to the baseline with no `touch`, no + `saved`, no `reverted`, no focus stealing (`#wasOpen = false; accepted = + true; editing.set(false)`). +- **`localForm` and `localModel` removed.** The draft *is* the `value` + channel; component state collapsed to `value` + derived `previous` + + reveal flags. `previous` is a `linkedSignal` frozen on `editing()` (never + field `dirty` — sticky, would never thaw), pinned by a read in `elevate()` + before the freeze. +- **`saved` event** — `{ value, changed }`, exactly once per settled session + (Save, Discard, clear). Legacy `savedModelChange`/`reverted` are marked + superseded; the demo's form example logs all three for comparison. +- **Idle error state.** Host gets `editable-text--invalid` while + `errorsVisible` — solid `--mat-sys-error` underline on the display, the mat + red-underline analogue. `aria-invalid` on the display is suppressed when + merely empty-and-required (MatInput detail — overlaps `aria-required`). +- **Clear commits, mat-faithful.** Clear always commits `''` and marks + touched (`touch.emit()` → field `markAsTouched()` → `touched` input → + reveal); a schema that rejects `''` surfaces through the idle error state + immediately. `required()` keeps hiding the bubble. +- **Demo/UI coverage.** "Mark touched" (reveal with zero interaction) and + "Reset field" (silent draft discard) buttons; event console; browser- + verified: blocked invalid save emits nothing, discard/commit/clear settle + exactly once, reset emits nothing, `markAsTouched()` flips the idle error. +- **Normalization is edge-only.** `normalizeString` = `trim()`: interior + spaces and line breaks are user content and always survive; single-line + fields strip line breaks at the input level. Paragraph demo has a + Normalize on/off toggle + example reset. +- **Hardening.** Bubble close timer cleared via `DestroyRef` (no post-destroy + signal writes); panel ids from CDK `_IdGenerator` — DI-scoped, so the + sequence is deterministic across an SSR render and its client hydration. + +Verified against `@angular/forms/signals` 22.0: `touched`/`invalid`/`hidden` +are auto-bound custom-control inputs; `touch` → `markAsTouched()`; +`FieldState.reset(value?)` writes the model only if a value is passed (it +arrives via the `value` binding) and then invokes the control's `reset()` — +so an open session's rollback-to-baseline deliberately wins over a mid-session +reset value, per design. + +## Remaining + +### Complete Phase 3 — remove the legacy outputs + +Delete `savedModelChange` and `reverted` (breaking; pre-1.0), migrate the +demo bindings to `(saved)` (`$event.value`), drop the comparison entries from +the event console. Do this once the `saved` payload has proven itself in use. +Note `reverted` is the only carrier of the *discarded draft text* — confirm +nothing needs it before deleting. + +## Next up — editable-number & multi-page demo + +**Design rule (no OOP):** new controls never inherit from `AngularInlineText`. +Sharing happens at exactly two seams: + +1. **The contract.** Every control is its own `FormValueControl`; the + `FormField` directive treats them identically. +2. **Composition.** A control that is "text plus a value translation" + *contains* an `` in its template and translates at + the boundary. It forwards the contract in, retypes the events out. + +If a future control needs the session machinery *without* being text-shaped +(inline-select…), that is the trigger to extract headless primitives — a +`createEditSession()` factory of functions and signals, not a class +hierarchy. Not before. + +### Phase N1 — demo shell & routing — **shipped** + +Lazy `/text` (all previous sections) and `/number` pages; the app is a shell +(toolbar: editable title, `routerLink` nav with active state, theme, Sign +In + ``). Shared page scaffolding lives in +`pages/_demo.scss`; the anchor nav, layout-shift tester and table styles +moved into the text page. + +### Phase N2 — `angular-inline-number` — **shipped** + +A `FormValueControl` that **contains** an `` (no +inheritance): + +- **Model:** `value = model` — strings/numbers + coerce on the way in, every outbound write and event is `number | null` + (empty commits `null`). +- **Codec:** `parse`/`format` inputs with dot-decimal defaults (`''` → + `null`, unparseable → `undefined`); Intl/locale variants plug in with + zero API change. +- **Parse gate is just an error:** an unparseable draft appends a synthetic + message-less `{ kind: 'parse' }` to the forwarded errors — the inner + accept guard blocks the save, the inner slot shows the consumer's + projected message. `parseFailed` is public because the synthetic error + never reaches the outer field (signal forms has no custom-control + parse-error channel yet) — consumers gate their message on + `#ref.parseFailed()`. +- **String channel** is a `linkedSignal` frozen while the inner session is + open (same pattern as `previous`), so a reformat can never rewrite the + text under the caret; commits round-trip the codec (`'12.50'` settles and + displays as `12.5`). +- **Contract forwarding:** state inputs in; `touch` + `saved`/ + `savedModelChange` (retyped `number | null`) out; `focus()`/`reset()` + delegate; `[editable-error]` re-projects via `ngProjectAs`. Always + single-line, always edge-normalized. +- 11 specs; browser-verified on the `/number` page: parse gate blocks with + its message, `min`/`max` messages switch live per kind, commits log real + numbers, discard rolls the live number back. + +### Affix templates (`editablePrefix`/`editableSuffix`) — **shipped** + +The matPrefix/matSuffix analogue, generic on `angular-inline-text` and +forwarded by `angular-inline-number`: + +- Declared on an **`ng-template`**, not an element — the affix renders TWICE + (after the in-flow display, and beside the editor inside the panel), + because the panel covers the surrounding copy and a unit written next to + the field would vanish exactly while the user edits. Templates stamp into + both spots; projected elements cannot. +- Never part of the draft: outside the contenteditable, caret-proof, + parser-invisible, `user-select: none`. Rendered `aria-hidden` — units + belong in `ariaLabel`. +- Composition channel: `prefixTemplate`/`suffixTemplate` inputs carry the + `TemplateRef` through wrappers (content queries don't pierce + re-projection); `contentChild` on the directives is the direct-use sugar. +- The in-flow field area (`.editable-text__field`) wraps affixes + display, + dims as a whole while editing, and anchors the clear bubble (after the + suffix, not the text). +- Demo: `/number` price card — `toFixed(2)` codec + euro icon suffix. + Gotcha for consumers: `contentChild` requires non-ES-private fields + (NG1053). + +### Phase N3 — number polish (later) + +- `inputmode="decimal"` / `enterkeyhint` on the editable surfaces (small + generic attr input on editable-text) for mobile keyboards. +- Contract `min`/`max`/`step`-style inputs — meaningful for numbers (unlike + text); auto-bound by the field, surfaced as hints. +- Intl codec preset (locale grouping/decimal comma) shipped as an opt-in + `parse`/`format` pair. + +### Manual QA — Safari / iOS pass + +The `plaintext-only` probe falls back to `contenteditable="true"` + manual +paste sanitization on WebKit builds that misreport support. Needs a hands-on +pass on iOS Safari: paste interception, IME composition elevate, caret +replay, and the Selection-based paste fallback. + +## Later (needs real behavior, not just a declared input) + +- `pending` — block commit while async validation runs; "Validating…" hint in + the panel footer. Accept-as-submit should not commit an unknown-validity + draft. +- `minLength` / `maxLength` — enforce in `replayEdit`/editor input + (contenteditable has no native `maxlength`); expose in panel hints. +- `disabledReasons` — render as a hint/tooltip on the disabled display. + +## Deliberately not implemented + +- `dirty` — field-dirty is sticky; our "Unsaved changes" hint and the + `previous` baseline are session-scoped. Binding field-dirty would make the + hint lie and permanently freeze the baseline. +- `name` — no native form element to carry it. +- `min` / `max` — meaningless for `TValue = string`. +- `pattern` (input) — nothing native to bind it to; validation already + arrives via `errors`. Declaring inputs with no behavior is contract theater. + +## Known deviations (owned, documented) + +- A `field.reset('new value')` issued *while a session is open* loses the + reset value: the control's draft rollback runs after the field's value + write and restores the session baseline. Intentional — an open session's + draft protection wins; reset a closed field to apply a value. +- Rolling the draft back during a mid-session `reset()` re-marks the field + dirty (the rollback flows through `controlValue.set`). Cosmetic; revisit if + it ever matters. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..351617c --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,63 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import angular from 'angular-eslint'; +import unusedImports from 'eslint-plugin-unused-imports'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; + +export default tseslint.config( + { + files: ['eslint.config.mjs'], + extends: [eslint.configs.recommended], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + { + files: ['**/*.ts'], + plugins: { + // @ts-ignore + 'unused-imports': unusedImports, + }, + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + ...angular.configs.tsRecommended, + eslintPluginPrettierRecommended, + ], + processor: angular.processInlineTemplates, + rules: { + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + '@angular-eslint/template/interactive-supports-focus': 'off', + '@angular-eslint/component-class-suffix': 'off', + '@angular-eslint/no-input-rename': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-unused-private-class-members': 'off', + 'prettier/prettier': 'error', + }, + }, + { + files: ['**/*.html'], + ignores: [ + 'node_modules/**', + 'dist/**', + 'build/**', + 'coverage/**', + 'src/index.html', + 'src/app/app.html', + ], + extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], + rules: {}, + }, +); diff --git a/package-lock.json b/package-lock.json index 766fa55..33e87f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,13 @@ "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.3", + "@typescript-eslint/parser": "^8.62.1", + "angular-eslint": "^22.0.0", "jsdom": "^28.0.0", "ng-packagr": "^22.0.0", - "prettier": "^3.8.1", + "prettier": "^3.9.4", "typescript": "~6.0.3", + "typescript-eslint": "^8.62.1", "vitest": "^4.0.8" } }, @@ -327,6 +330,128 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular-eslint/builder": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-22.0.0.tgz", + "integrity": "sha512-T2vWQYUhJs6iUlgocHV12OgoxbmN63f17a+tgW+3sYrKN0KAB3xuHsPOoYpRYoWqkVVC44HD441Ju4IDvo8vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": ">= 0.2200.0 < 0.2300.0", + "@angular-devkit/core": ">= 22.0.0 < 23.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 22.0.0 < 23.0.0", + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-22.0.0.tgz", + "integrity": "sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-22.0.0.tgz", + "integrity": "sha512-mKLScPZhqG64ic0KIQoxqSqCdkPwtEZuTOuunvc9lYTw05MJSHRUM2yVFODlCGq97c6BN1F6KBk2I+a+KFnr1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "22.0.0", + "@angular-eslint/utils": "22.0.0", + "ts-api-utils": "^2.1.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-22.0.0.tgz", + "integrity": "sha512-y6XL5HJ8C31NpBvkVHpU3bWc+Rk9g1zRtHrs39omhuT29eEUcS3zu47HMFV6tf8rHOI97B2Mstg6qYS5XL9ATg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "22.0.0", + "@angular-eslint/utils": "22.0.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@angular-eslint/template-parser": "22.0.0", + "@typescript-eslint/types": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-22.0.0.tgz", + "integrity": "sha512-gsJQx6c+WIWC5d+NAqn4rRdUzwhinUCTNmCM9x4wygV9DrbAfVG+6OFPEbaDMryNvf0HYDcnGclbIbXjukGCaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 22.0.0 < 23.0.0", + "@angular-devkit/schematics": ">= 22.0.0 < 23.0.0", + "@angular-eslint/eslint-plugin": "22.0.0", + "@angular-eslint/eslint-plugin-template": "22.0.0", + "ignore": "7.0.5", + "semver": "7.8.0", + "strip-json-comments": "3.1.1" + }, + "peerDependencies": { + "@angular/cli": ">= 22.0.0 < 23.0.0" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-22.0.0.tgz", + "integrity": "sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "22.0.0", + "eslint-scope": "9.1.2" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-22.0.0.tgz", + "integrity": "sha512-VFodMojghnPYm+B3U+HRYrqebPMj8NyobNjVzDdY8V5XIBW+4ivOSEINIz81G48rmm/NZKwj56+bJ88bVX4KIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "22.0.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.0.0", + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*" + } + }, "node_modules/@angular/aria": { "version": "22.0.2", "resolved": "https://registry.npmjs.org/@angular/aria/-/aria-22.0.2.tgz", @@ -2132,6 +2257,105 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -2181,6 +2405,77 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -4322,6 +4617,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4329,6 +4631,247 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", @@ -4493,6 +5036,31 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4564,6 +5132,30 @@ "node": ">= 14.0.0" } }, + "node_modules/angular-eslint": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-22.0.0.tgz", + "integrity": "sha512-6tHLndzM6rU+2iuICakJS/hD1scK5sWLkcD7828zStT1ViA9zX8z9g/V1IlBiKEdZeMsl+m7K2DlNc34AkYyoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": ">= 22.0.0 < 23.0.0", + "@angular-devkit/schematics": ">= 22.0.0 < 23.0.0", + "@angular-eslint/builder": "22.0.0", + "@angular-eslint/eslint-plugin": "22.0.0", + "@angular-eslint/eslint-plugin-template": "22.0.0", + "@angular-eslint/schematics": "22.0.0", + "@angular-eslint/template-parser": "22.0.0", + "@typescript-eslint/types": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0" + }, + "peerDependencies": { + "@angular/cli": ">= 22.0.0 < 23.0.0", + "eslint": "^9.0.0 || ^10.0.0", + "typescript": "*", + "typescript-eslint": "^8.0.0" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -4606,6 +5198,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4616,6 +5218,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5253,6 +5865,14 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5490,53 +6110,280 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=6" + "node": ">=4.0" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } }, "node_modules/estree-walker": { "version": "3.0.3", @@ -5548,6 +6395,17 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5675,6 +6533,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -5737,6 +6611,20 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -5776,6 +6664,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up-simple": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", @@ -5789,6 +6695,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5937,6 +6866,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -6142,6 +7085,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ignore-walk": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", @@ -6176,6 +7129,17 @@ "dev": true, "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -6229,7 +7193,6 @@ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -6256,7 +7219,6 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -6405,6 +7367,14 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/json-parse-even-better-errors": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", @@ -6429,6 +7399,14 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6459,6 +7437,17 @@ ], "license": "MIT" }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/less": { "version": "4.6.7", "resolved": "https://registry.npmjs.org/less/-/less-4.6.7.tgz", @@ -6496,6 +7485,21 @@ "node": ">=0.10.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/listr2": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", @@ -6542,6 +7546,23 @@ "@lmdb/lmdb-win32-x64": "3.5.4" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -7013,6 +8034,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/needle": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/needle/-/needle-3.5.0.tgz", @@ -7396,6 +8424,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", @@ -7427,6 +8474,40 @@ "license": "MIT", "optional": true }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", @@ -7557,6 +8638,17 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7734,10 +8826,21 @@ "postcss": "^8.4.31" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { @@ -8487,6 +9590,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -8621,6 +9737,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8642,6 +9771,20 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -8689,6 +9832,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", @@ -8740,6 +9907,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -9512,6 +10690,17 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -9617,6 +10806,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index 013410d..a967117 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,13 @@ "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.3", + "@typescript-eslint/parser": "^8.62.1", + "angular-eslint": "^22.0.0", "jsdom": "^28.0.0", "ng-packagr": "^22.0.0", - "prettier": "^3.8.1", + "prettier": "^3.9.4", "typescript": "~6.0.3", + "typescript-eslint": "^8.62.1", "vitest": "^4.0.8" } } diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..83c8b42 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,18 @@ +export default { + singleQuote: true, + trailingComma: 'all', + printWidth: 120, + tabWidth: 2, + useTabs: false, + semi: true, + bracketSpacing: true, + endOfLine: 'auto', + overrides: [ + { + files: '*.html', + options: { + parser: 'angular', + }, + }, + ], +}; diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html new file mode 100644 index 0000000..17ddcf2 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html @@ -0,0 +1,25 @@ + + + + diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts new file mode 100644 index 0000000..741d56e --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts @@ -0,0 +1,244 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form, min } from '@angular/forms/signals'; + +import { + AngularInlineNumber, + defaultParseNumber, + defaultFormatNumber, + type InlineNumberSaved, +} from './angular-inline-number'; +import { AngularInlineText } from '../angular-inline-text/angular-inline-text'; +import { EditableSuffix } from '../angular-inline-text/editable-affix'; + +// ============================================================================= +// Hosts — one per binding mode +// ============================================================================= + +@Component({ + imports: [AngularInlineNumber], + template: ` + + `, +}) +class NumberValueHost { + value = signal(42); + + saved: (number | null)[] = []; + sessions: InlineNumberSaved[] = []; + touchCount = 0; +} + +@Component({ + imports: [AngularInlineNumber, FormField], + template: ``, +}) +class NumberFormHost { + model = signal(10); + field = form(this.model, (path) => { + min(path, 0); + }); +} + +@Component({ + imports: [AngularInlineNumber, EditableSuffix], + template: ` + + + + `, +}) +class NumberSuffixHost { + value = signal(49.9); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +interface Harness { + fixture: ComponentFixture; + host: T; + display: () => HTMLElement; + editor: () => HTMLElement | null; + inner: () => AngularInlineText; +} + +function setup(hostType: new () => T): Harness { + const fixture = TestBed.createComponent(hostType); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + display: () => fixture.nativeElement.querySelector('.editable-text__display') as HTMLElement, + // The elevated editor renders in the CDK overlay container (document level) + editor: () => document.querySelector('.editable-text__editor') as HTMLElement | null, + inner: () => + fixture.debugElement.children[0].children[0].componentInstance as AngularInlineText, + }; +} + +/** Simulates an edit session: elevate via an intercepted keystroke, replace the draft. */ +async function typeText(h: Harness, text: string) { + const display = h.display(); + + const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; + Object.defineProperty(event, 'inputType', { value: 'insertText' }); + Object.defineProperty(event, 'data', { value: 'x' }); + + display.dispatchEvent(event); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + const editor = h.editor(); + if (!editor) throw new Error('elevated editor not found'); + + editor.textContent = text; + editor.dispatchEvent(new Event('input', { bubbles: true })); + h.fixture.detectChanges(); +} + +function accept(h: Harness) { + (h.inner() as unknown as { accept(): void }).accept(); + h.fixture.detectChanges(); +} + +// ============================================================================= +// Specs +// ============================================================================= + +describe('number codec defaults', () => { + it('parses dot decimals, empty to null, garbage to undefined', () => { + expect(defaultParseNumber(' 12.5 ')).toBe(12.5); + expect(defaultParseNumber('')).toBeNull(); + expect(defaultParseNumber(' ')).toBeNull(); + expect(defaultParseNumber('12abc')).toBeUndefined(); + }); + + it('formats null as empty', () => { + expect(defaultFormatNumber(12.5)).toBe('12.5'); + expect(defaultFormatNumber(null)).toBe(''); + }); +}); + +describe('AngularInlineNumber — [(value)] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(NumberValueHost); + }); + + it('renders the formatted committed value', () => { + expect(h.display().textContent).toBe('42'); + }); + + it('accepts a string-typed binding and renders it', () => { + h.host.value.set('7.5'); + h.fixture.detectChanges(); + + expect(h.display().textContent).toBe('7.5'); + }); + + it('parses the live draft into the model as a number', async () => { + await typeText(h, '55'); + + expect(h.host.value()).toBe(55); + }); + + it('the parse gate blocks committing an unparseable draft', async () => { + await typeText(h, '12abc'); + + // Unparseable: the model holds the last good value + expect(h.host.value()).toBe(42); + + accept(h); + + expect(h.inner().editing()).toBe(true); + expect(h.host.saved).toEqual([]); + expect(h.host.sessions).toEqual([]); + }); + + it('an empty draft commits null', async () => { + await typeText(h, ''); + accept(h); + + expect(h.host.value()).toBeNull(); + expect(h.host.saved).toEqual([null]); + expect(h.host.sessions).toEqual([{ value: null, changed: true }]); + }); + + it('commits are numbers round-tripped through the codec', async () => { + await typeText(h, ' 12.50 '); + accept(h); + + expect(h.host.value()).toBe(12.5); + expect(h.host.saved).toEqual([12.5]); + // The display shows the canonical formatting, not the raw draft + expect(h.display().textContent).toBe('12.5'); + }); + + it('discard settles once with changed=false and rolls the model back', async () => { + await typeText(h, '99'); + expect(h.host.value()).toBe(99); // live channel + + (h.inner() as unknown as { cancel(): void }).cancel(); + h.fixture.detectChanges(); + + expect(h.host.value()).toBe(42); + expect(h.host.saved).toEqual([]); + expect(h.host.sessions).toEqual([{ value: 42, changed: false }]); + }); +}); + +describe('AngularInlineNumber — affix forwarding', () => { + it('forwards the suffix template through the composition into both render spots', async () => { + const h = setup(NumberSuffixHost); + + const inFlow = h.fixture.nativeElement.querySelector( + '.editable-text__field .editable-text__affix--suffix .unit', + ) as HTMLElement | null; + expect(inFlow?.textContent).toBe('€'); + + await typeText(h, '55'); + + const inPanel = document.querySelector('.editable-panel__line .editable-text__affix--suffix .unit'); + expect(inPanel?.textContent).toBe('€'); + + // The affix never leaks into the draft or the committed number + accept(h); + expect(h.host.value()).toBe(55); + }); +}); + +describe('AngularInlineNumber — signal form [formField] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(NumberFormHost); + }); + + it('propagates parsed keystrokes live into the field', async () => { + await typeText(h, '5'); + + expect(h.host.field().value()).toBe(5); + }); + + it('schema errors block the commit (min violated)', async () => { + await typeText(h, '-3'); + + expect(h.host.field().value()).toBe(-3); + expect(h.host.field().invalid()).toBe(true); + + accept(h); + + expect(h.inner().editing()).toBe(true); + expect(h.host.model()).toBe(-3); // live channel — not committed, rolls back on discard + }); +}); diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts new file mode 100644 index 0000000..af59001 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts @@ -0,0 +1,200 @@ +import { + Component, + TemplateRef, + input, + model, + output, + computed, + signal, + linkedSignal, + viewChild, + contentChild, +} from '@angular/core'; +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; + +import { AngularInlineText, type InlineTextSaved } from '../angular-inline-text/angular-inline-text'; +import { EditablePrefix, EditableSuffix } from '../angular-inline-text/editable-affix'; + +/** Payload of the `saved` output: one emission per settled edit session. */ +export interface InlineNumberSaved { + /** The value the session settled on — always a number, or `null` for empty. */ + value: number | null; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * Dot-decimal default codec: `''` means empty (`null`), text that is not a + * number means unparseable (`undefined` — raises the parse gate). + */ +export function defaultParseNumber(raw: string): number | null | undefined { + const trimmed = raw.trim(); + if (trimmed === '') return null; + + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function defaultFormatNumber(value: number | null): string { + return value === null ? '' : String(value); +} + +/** + * Inline number: a `FormValueControl` that COMPOSES the inline text + * control — no inheritance. It contains an `` and does + * exactly one job at the boundary: translate between the number world + * (outside) and the string world (inside) through a swappable codec. + * + * - The contract flows through: state inputs forward in, `touch`/`saved` + * retype out, `focus()`/`reset()` delegate, `[editable-error]` re-projects. + * - The parse gate is just an error: an unparseable draft appends a + * synthetic message-less `{ kind: 'parse' }` to the forwarded errors — the + * inner accept guard blocks the save and the inner error slot presents the + * consumer's projected message. No new mechanism. + * - Accepts `number | string | null` on the way in for binding convenience; + * every outbound write and event is `number | null` (empty commits `null`). + */ +@Component({ + selector: 'angular-inline-number', + imports: [AngularInlineText], + templateUrl: './angular-inline-number.html', + styles: ':host { display: inline; }', + host: { + '[style.display]': 'hidden() ? "none" : null', + }, +}) +export class AngularInlineNumber implements FormValueControl { + /** The composed text control — all session machinery lives there. */ + protected inner = viewChild.required(AngularInlineText); + + /** + * The committed value channel. Accepts `number | string | null` for + * binding convenience; the component only ever writes `number | null`. + */ + value = model(null); + + /** Form Value Contract — forwarded into the inner control. */ + errors = input([]); + disabled = input(false); + readonly = input(false); + required = input(false); + touched = input(false); + invalid = input(false); + hidden = input(false); + + placeholder = input('N/A'); + + /** Accessible name for the field (contenteditable has no native label association). */ + ariaLabel = input(undefined); + + /** + * Affix templates — declared as direct content (`ng-template[editableSuffix]`) + * or passed as inputs; either way they forward into the inner control as + * TemplateRefs, since content queries don't pierce re-projection. + */ + prefixTemplate = input | undefined>(undefined); + suffixTemplate = input | undefined>(undefined); + + private contentPrefix = contentChild(EditablePrefix); + private contentSuffix = contentChild(EditableSuffix); + + protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); + protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); + + /** + * The codec — swap both halves to localize (e.g. Intl comma decimals). + * `parse` returns `null` for empty and `undefined` for unparseable text. + */ + parse = input<(raw: string) => number | null | undefined>(defaultParseNumber); + format = input<(value: number | null) => string>(defaultFormatNumber); + + /** Form Value Contract: touch — forwarded from the inner control. */ + touch = output(); + + /** + * Hard commit event: fires once per accepted edit session — always + * `number | null`, never a string. + * + * Roadmap Phase 3: superseded by `saved` — kept during the transition. + */ + savedModelChange = output(); + + /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + saved = output(); + + /** The numeric reading of the (possibly string-typed) model. */ + protected numericValue = computed(() => { + const value = this.value(); + if (value === null || value === undefined) return null; + if (typeof value === 'number') return Number.isNaN(value) ? null : value; + + return this.parse()(value) ?? null; + }); + + /** Two-way `editing` bridge — freezes the string channel during a session. */ + protected innerEditing = signal(false); + + /** + * The string channel feeding the inner control. Follows the formatted + * model while idle; while a session is open it holds the raw draft, so a + * reformat can never rewrite the text under the caret. Committing runs the + * draft through the codec both ways — `'12.50'` settles and displays as + * `'12.5'`. + */ + protected innerValue = linkedSignal({ + source: () => this.format()(this.numericValue()), + computation: (source, prev) => (this.innerEditing() ? (prev?.value ?? source) : source), + }); + + /** + * The parse gate: whether the current draft fails the codec. Public so + * consumers can present a message for it in their `[editable-error]` + * content (the synthetic error itself is message-less). + */ + readonly parseFailed = computed(() => this.parse()(this.innerValue()) === undefined); + + /** + * Errors forwarded to the inner control: the contract errors plus the + * synthetic `{ kind: 'parse' }` while the draft is unparseable. + */ + protected innerErrors = computed(() => + this.parseFailed() ? [...this.errors(), { kind: 'parse' }] : this.errors(), + ); + + /** + * Live channel: every keystroke parses. Parseable drafts flow into the + * model as numbers — schema rules like `min`/`max` validate mid-draft — + * while unparseable ones hold the last good value and raise the parse gate. + */ + protected handleInnerValue(raw: string) { + this.innerValue.set(raw); + + const parsed = this.parse()(raw); + if (parsed !== undefined && parsed !== this.numericValue()) this.value.set(parsed); + } + + /** Retype the settled session: strings inside, numbers outside. */ + protected handleInnerSaved(session: InlineTextSaved) { + const parsed = this.parse()(session.value); + // The parse gate blocks unparseable commits; the fallback covers discards + // rolling back to a baseline the current codec cannot read. + const value = parsed === undefined ? this.numericValue() : parsed; + + if (session.changed) { + this.value.set(value); + this.savedModelChange.emit(value); + } + + this.saved.emit({ value, changed: session.changed }); + } + + /** Form Value Contract: focus — delegates to the inner control. */ + focus(options?: FocusOptions) { + this.inner().focus(options); + } + + /** Form Value Contract: reset — delegates to the inner control. */ + reset() { + this.inner().reset(); + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index 4bd7be4..d2cfbca 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -1,53 +1,159 @@ -@let previous = this.previous(); + + + @if (prefixTpl(); as prefix) { + + } + + @if (suffixTpl(); as suffix) { + + } + - + - @if (isSingleLine()) { - - } @else { - - } +
+
+ @if (prefixTpl(); as prefix) { + + } + + @if (suffixTpl(); as suffix) { + + } +
+ + +
+
+ + + + + diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss index 09eca16..e69de29 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss @@ -1,29 +0,0 @@ -.iusta-editable-text-area { - width: 100%; - max-width: 100%; - - /* wrapping */ - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - - /* let autosize control height */ - height: auto; - - &--no-manual-resize { - resize: none; /* remove the corner handle */ - overflow: hidden; /* no scrollbars while autosizing */ - - /* hide scrollbar visuals just in case */ - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - } - - /* optional: make it feel "input-ish" when focused (tighter vertical feel) */ - .iusta-editable-wrapper:focus-within & { - padding-top: 0; - padding-bottom: 0; - } -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts index 330e71c..d081e89 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts @@ -1,26 +1,482 @@ +import { Component, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form, type ValidationError } from '@angular/forms/signals'; -import { AngularInlineText, normalizeString } from './angular-inline-text'; +import { AngularInlineText, normalizeString, type InlineTextSaved } from './angular-inline-text'; +import { EditableSuffix } from './editable-affix'; +import { replayEdit } from './caret'; -describe('AngularInlineText', () => { - let component: AngularInlineText; - let fixture: ComponentFixture; +// ============================================================================= +// Hosts — one per binding mode +// ============================================================================= - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AngularInlineText], - }).compileComponents(); +@Component({ + imports: [AngularInlineText], + template: ` + + `, +}) +class ValueBindingHost { + value = signal('initial'); + errors = signal([]); + touched = signal(false); + disabled = signal(false); - fixture = TestBed.createComponent(AngularInlineText); - component = fixture.componentInstance; - fixture.detectChanges(); + saved: string[] = []; + revertedDrafts: string[] = []; + sessions: InlineTextSaved[] = []; + touchCount = 0; +} + +@Component({ + imports: [AngularInlineText, FormField], + template: ``, +}) +class SignalFormHost { + model = signal('initial'); + field = form(this.model); +} + +@Component({ + imports: [AngularInlineText], + template: ` + + Custom pattern message + + `, +}) +class ProjectedErrorHost { + value = signal('initial'); + errors = signal([]); +} + +@Component({ + imports: [AngularInlineText, EditableSuffix], + template: ` + + kg + + `, +}) +class SuffixHost { + value = signal('10'); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +interface Harness { + fixture: ComponentFixture; + host: T; + editable: () => AngularInlineText; + display: () => HTMLElement; + editor: () => HTMLElement | null; +} + +function setup(hostType: new () => T): Harness { + const fixture = TestBed.createComponent(hostType); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + editable: () => fixture.debugElement.children[0].componentInstance as AngularInlineText, + display: () => fixture.nativeElement.querySelector('.editable-text__display') as HTMLElement, + // The elevated editor renders in the CDK overlay container (document level) + editor: () => document.querySelector('.editable-text__editor') as HTMLElement | null, + }; +} + +/** Dispatches an intercepted first edit on the display element to elevate the field. */ +async function elevate(h: Harness) { + const display = h.display(); + + const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; + Object.defineProperty(event, 'inputType', { value: 'insertText' }); + Object.defineProperty(event, 'data', { value: 'x' }); + + display.dispatchEvent(event); + h.fixture.detectChanges(); + + // The editor is seeded + focused in a microtask after overlay attach. + await h.fixture.whenStable(); + h.fixture.detectChanges(); +} + +/** Simulates an edit session: elevate, replace the draft, dispatch input. */ +async function typeText(h: Harness, text: string) { + await elevate(h); + + const editor = h.editor(); + if (!editor) throw new Error('elevated editor not found'); + + editor.textContent = text; + editor.dispatchEvent(new Event('input', { bubbles: true })); + h.fixture.detectChanges(); +} + +function accept(h: Harness) { + (h.editable() as unknown as { accept(): void }).accept(); + h.fixture.detectChanges(); +} + +function cancel(h: Harness) { + (h.editable() as unknown as { cancel(): void }).cancel(); + h.fixture.detectChanges(); +} + +// ============================================================================= +// Specs +// ============================================================================= + +describe('normalizeString', () => { + it('trims edge whitespace and preserves interior spacing and line breaks', () => { + expect(normalizeString(' hello \n world ')).toBe('hello \n world'); + }); +}); + +describe('replayEdit', () => { + const sel = (start: number, end = start) => ({ start, end }); + + it('inserts typed text at the caret', () => { + expect(replayEdit('hello', sel(5), { inputType: 'insertText', data: '!' }, false)).toEqual({ + text: 'hello!', + caret: 6, + }); + }); + + it('replaces a selection with typed text', () => { + expect(replayEdit('hello', sel(0, 5), { inputType: 'insertText', data: 'y' }, false)).toEqual({ + text: 'y', + caret: 1, + }); + }); + + it('backspace deletes the character before the caret', () => { + expect( + replayEdit('hello', sel(5), { inputType: 'deleteContentBackward', data: null }, false), + ).toEqual({ text: 'hell', caret: 4 }); }); + it('backspace at offset 0 is a no-op', () => { + expect( + replayEdit('hello', sel(0), { inputType: 'deleteContentBackward', data: null }, false), + ).toBeNull(); + }); + + it('delete-forward removes the character after the caret', () => { + expect( + replayEdit('hello', sel(0), { inputType: 'deleteContentForward', data: null }, false), + ).toEqual({ text: 'ello', caret: 0 }); + }); + + it('line breaks insert newlines in multiline mode only', () => { + expect(replayEdit('ab', sel(1), { inputType: 'insertParagraph', data: null }, false)).toEqual({ + text: 'a\nb', + caret: 2, + }); + expect(replayEdit('ab', sel(1), { inputType: 'insertParagraph', data: null }, true)).toBeNull(); + }); + + it('unknown input types are not replayed', () => { + expect(replayEdit('ab', sel(1), { inputType: 'insertFromDrop', data: 'x' }, false)).toBeNull(); + }); +}); + +describe('AngularInlineText — standalone', () => { it('should create', () => { - expect(component).toBeTruthy(); + const fixture = TestBed.createComponent(AngularInlineText); + fixture.detectChanges(); + expect(fixture.componentInstance).toBeTruthy(); + }); +}); + +describe('AngularInlineText — [(value)] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(ValueBindingHost); + }); + + it('renders the committed value in the display element', () => { + expect(h.display().textContent).toBe('initial'); + }); + + it('typing on the pristine display never mutates it — the field elevates instead', async () => { + await elevate(h); + + expect(h.display().textContent).toBe('initial'); + expect(h.editable().editing()).toBe(true); + expect(h.editor()).not.toBeNull(); + }); + + it('the first intercepted keystroke is replayed into the draft and the live channel', async () => { + await elevate(h); // simulated insertText 'x' at the end + + // Live draft channel: bound parents follow the seed immediately, while + // the session baseline stays pinned at the committed value. + expect(h.host.value()).toBe('initialx'); + expect(h.editable().previous()).toBe('initial'); + }); + + it('propagates keystrokes live while the display stays frozen at the baseline', async () => { + await typeText(h, 'draft text'); + + expect(h.host.value()).toBe('draft text'); + expect(h.host.saved).toEqual([]); + // Frozen display: the page never sees the draft + expect(h.display().textContent).toBe('initial'); }); - it('should normalize surplus whitespace and newlines', () => { - expect(normalizeString(' hello \n world ')).toBe('hello world'); + it('accept commits the normalized value and emits savedModelChange once', async () => { + await typeText(h, ' new value \n here '); + accept(h); + + // Edges trimmed, interior spacing and line breaks preserved + expect(h.host.value()).toBe('new value \n here'); + expect(h.host.saved).toEqual(['new value \n here']); + expect(h.editable().editing()).toBe(false); + }); + + it('accept without changes closes and emits nothing', async () => { + await typeText(h, 'initial'); + accept(h); + + expect(h.host.saved).toEqual([]); + expect(h.host.value()).toBe('initial'); + expect(h.editable().editing()).toBe(false); + }); + + it('cancel restores the baseline and emits the discarded draft', async () => { + await typeText(h, 'abandoned draft'); + cancel(h); + + expect(h.host.value()).toBe('initial'); + expect(h.host.revertedDrafts).toEqual(['abandoned draft']); + expect(h.host.saved).toEqual([]); + expect(h.editable().editing()).toBe(false); + }); + + it('cancel without changes does not emit reverted', async () => { + await typeText(h, 'initial'); + cancel(h); + + expect(h.host.revertedDrafts).toEqual([]); + }); + + it('saved settles a committed session exactly once with changed=true', async () => { + await typeText(h, ' new value '); + accept(h); + + expect(h.host.sessions).toEqual([{ value: 'new value', changed: true }]); + }); + + it('saved settles a discarded session exactly once with changed=false', async () => { + await typeText(h, 'abandoned draft'); + cancel(h); + + expect(h.host.sessions).toEqual([{ value: 'initial', changed: false }]); + }); + + it('saved settles a no-diff accept exactly once with changed=false', async () => { + await typeText(h, 'initial'); + accept(h); + + expect(h.host.sessions).toEqual([{ value: 'initial', changed: false }]); + }); + + it('the bound touched status reveals the idle error state without interaction', () => { + h.host.errors.set([{ kind: 'pattern' }]); + h.fixture.detectChanges(); + + const host = h.fixture.nativeElement.querySelector('angular-inline-text') as HTMLElement; + const display = h.display(); + + // Invalid but untouched: no idle error, no aria-invalid + expect(host.classList.contains('editable-text--invalid')).toBe(false); + expect(display.getAttribute('aria-invalid')).toBeNull(); + + h.host.touched.set(true); + h.fixture.detectChanges(); + + expect(host.classList.contains('editable-text--invalid')).toBe(true); + expect(display.getAttribute('aria-invalid')).toBe('true'); + }); + + it('reset() discards an open draft back to the baseline with no emissions', async () => { + await typeText(h, 'draft in flight'); + + h.editable().reset(); + h.fixture.detectChanges(); + + expect(h.editable().editing()).toBe(false); + expect(h.host.value()).toBe('initial'); + + // A programmatic reset is not a user interaction + expect(h.host.touchCount).toBe(0); + expect(h.host.sessions).toEqual([]); + expect(h.host.revertedDrafts).toEqual([]); + }); + + it('clear commits an empty value and marks the field touched', () => { + (h.editable() as unknown as { clearValue(event: Event): void }).clearValue(new Event('click')); + h.fixture.detectChanges(); + + expect(h.host.value()).toBe(''); + expect(h.host.saved).toEqual(['']); + expect(h.host.sessions).toEqual([{ value: '', changed: true }]); + expect(h.host.touchCount).toBe(1); + }); + + it('errors block accept and the failed attempt reveals them (mat submit semantics)', async () => { + h.host.errors.set([{ kind: 'server', message: 'Taken' }]); + h.fixture.detectChanges(); + + await typeText(h, 'invalid attempt'); + + // Pristine error state: invalid but not yet revealed + expect(document.querySelector('.editable-panel__message--error')).toBeNull(); + + accept(h); + + expect(h.host.saved).toEqual([]); + expect(h.editable().editing()).toBe(true); + // The attempt marks the field touched and reveals the message + expect(h.host.touchCount).toBe(1); + expect(document.querySelector('.editable-panel__message--error')?.textContent?.trim()).toBe( + 'Taken', + ); + }); + + it('emits touch when the edit session closes', () => { + const editable = h.editable(); + + editable.editing.set(true); + h.fixture.detectChanges(); + editable.editing.set(false); + h.fixture.detectChanges(); + + expect(h.host.touchCount).toBe(1); + }); + + it('disabled renders a non-editable display and does not elevate', async () => { + h.host.disabled.set(true); + h.fixture.detectChanges(); + + expect(h.display().getAttribute('contenteditable')).toBe('false'); + + await elevate(h); + expect(h.editable().editing()).toBe(false); + }); +}); + +describe('AngularInlineText — signal form [formField] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(SignalFormHost); + }); + + it('propagates keystrokes live into the field so schema validation can run', async () => { + await typeText(h, 'typed'); + expect(h.host.field().value()).toBe('typed'); + }); + + it('accept commits into the field', async () => { + await typeText(h, 'committed'); + accept(h); + + expect(h.host.field().value()).toBe('committed'); + expect(h.host.model()).toBe('committed'); + }); + + it('cancel leaves the field at the session baseline', async () => { + await typeText(h, 'draft'); + cancel(h); + + expect(h.host.field().value()).toBe('initial'); + }); + + it('marks the field touched when the session closes', () => { + expect(h.host.field().touched()).toBe(false); + + const editable = h.editable(); + editable.editing.set(true); + h.fixture.detectChanges(); + editable.editing.set(false); + h.fixture.detectChanges(); + + expect(h.host.field().touched()).toBe(true); + }); +}); + +describe('AngularInlineText — affix templates', () => { + let h: Harness; + + beforeEach(() => { + h = setup(SuffixHost); + }); + + it('renders the suffix in the in-flow field and again inside the panel', async () => { + const inFlow = h.fixture.nativeElement.querySelector( + '.editable-text__field .editable-text__affix--suffix .unit', + ) as HTMLElement | null; + expect(inFlow?.textContent).toBe('kg'); + + await elevate(h); + + // Second instance beside the editor (in the overlay), outside the contenteditable + const inPanel = document.querySelector('.editable-panel__line .editable-text__affix--suffix .unit'); + expect(inPanel?.textContent).toBe('kg'); + expect(h.editor()?.contains(inPanel!)).toBe(false); + }); + + it('the affix is decorative: aria-hidden and not in the committed value', async () => { + const affix = h.fixture.nativeElement.querySelector('.editable-text__affix--suffix') as HTMLElement; + expect(affix.getAttribute('aria-hidden')).toBe('true'); + + await typeText(h, '25'); + (h.editable() as unknown as { accept(): void }).accept(); + h.fixture.detectChanges(); + + expect(h.host.value()).toBe('25'); + }); +}); + +describe('AngularInlineText — projected [editable-error]', () => { + let h: Harness; + + beforeEach(() => { + h = setup(ProjectedErrorHost); + }); + + it('is gated by the field itself and takes over the slot from the built-in messages', async () => { + h.host.errors.set([{ kind: 'pattern', message: 'Built-in message' }]); + h.fixture.detectChanges(); + + await typeText(h, 'invalid attempt'); + + // Pristine error state: the whole slot stays hidden, projection included + expect(document.querySelector('[editable-error]')).toBeNull(); + + accept(h); + + // The failed attempt reveals the slot: projected content only — the + // built-in message rendering is taken over entirely + expect(document.querySelector('[editable-error]')?.textContent?.trim()).toBe( + 'Custom pattern message', + ); + expect(document.querySelector('.editable-panel__message--error')).toBeNull(); }); }); diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index 82e24ae..77afb48 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -1,161 +1,331 @@ import { Component, - inject, + DestroyRef, ElementRef, + TemplateRef, + inject, // Signals computed, output, model, viewChild, + contentChild, input, effect, + signal, untracked, linkedSignal, } from '@angular/core'; -import { FormValueControl, FormField, form, disabled, readonly, validate } from '@angular/forms/signals'; - -// Material -import { MatInputModule } from '@angular/material/input'; -import { MatIconModule } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; +import { NgTemplateOutlet } from '@angular/common'; +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; // CDK -import { OverlayModule } from '@angular/cdk/overlay'; - -// Directives -import { RestrictCharacters } from './directives/restrict-characters/restrict-characters'; -import { NOOP_STRATEGY, RestrictStrategy } from './directives/restrict-characters/tokens'; -import { EditableOverlayControl } from './directives/editable-overlay-control'; -import { TextareaAutosize } from './directives/textarea-autosize'; +import { CdkConnectedOverlayConfig, ConnectedPosition, OverlayModule } from '@angular/cdk/overlay'; +import { A11yModule, _IdGenerator } from '@angular/cdk/a11y'; -// Components -import { EditableWrapper } from './editable-wrapper/editable-wrapper'; +import { getSelectionOffsets, setCaretOffset, replayEdit } from './caret'; +import { EditablePrefix, EditableSuffix } from './editable-affix'; interface ValueNormalizationDetails { value: string; changed: boolean; } +/** Payload of the `saved` output: one emission per settled edit session. */ +export interface InlineTextSaved { + /** The value the session settled on — the committed value or the restored baseline. */ + value: string; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * Trims leading/trailing whitespace only. Interior spaces and line breaks are + * the user's content and are preserved — single-line fields already strip + * line breaks at the input level. + */ export function normalizeString(value: string): string { - const trim = value.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim(); - return trim; + return value.trim(); +} + +/** + * Whether the platform supports `contenteditable="plaintext-only"`. + * When unsupported (SSR, older Firefox) we fall back to `contenteditable="true"` + * plus manual paste sanitization in `handleEditorPaste`. + */ +const SUPPORTS_PLAINTEXT_ONLY = (() => { + if (typeof document === 'undefined') return false; + + const probe = document.createElement('div'); + + try { + probe.contentEditable = 'plaintext-only'; + return probe.contentEditable === 'plaintext-only'; + } catch { + return false; + } +})(); + +/** + * Default panel padding in px — matches the `--mat-sys-inner-spacing` fallback + * in _editable.scss. The actual value is resolved from the token at elevation + * time so the lift alignment follows the consumer's spacing scale. + */ +const PANEL_PADDING_FALLBACK = 16; + +/** + * Elevated panel positions: preferred is "over" (panel text covers the origin + * text — the offsets cancel the panel padding so its first text line sits + * optically on the origin text), falling back below then above the field. + * `push: true` keeps the panel inside the viewport margins in all cases. + */ +function panelPositions(paddingX: number): ConnectedPosition[] { + return [ + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'top', + offsetX: -paddingX, + offsetY: -(paddingX * 0.75 + 1), // vertical padding is 0.75 × inner spacing, +1 border + }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + ]; } +/** + * Positions for the floating action bubble: prefers inline-end (right of the + * field, vertically centered), then falls back anticlockwise around the field. + * start/end are direction-aware, so RTL flips automatically. + */ +const BUBBLE_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 6 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -6 }, + { originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -6 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 6 }, +]; + +/** + * Inline text: a static in-flow text that elevates into a floating editor. + * + * Contract: + * - The in-flow display element never changes size — focus and Tab are free. + * - The first real edit (keystroke/paste/IME) elevates the field: editing + * happens in an overlay panel at a fixed readable measure over a scrim. + * - `value` updates only on commit (Save / Ctrl+Enter / Enter for + * single-line); Escape, Discard and scrim clicks revert the draft. + */ @Component({ selector: 'angular-inline-text', imports: [ + NgTemplateOutlet, + // CDK OverlayModule, - - // Material - MatInputModule, - MatIconModule, - MatButtonModule, - - // Forms - FormField, - EditableOverlayControl, - EditableWrapper, - TextareaAutosize, - - // Directives - RestrictCharacters, + A11yModule, ], templateUrl: './angular-inline-text.html', styleUrl: './angular-inline-text.scss', + host: { + class: 'editable-text', + '[class.editable-text--editing]': 'editing()', + '[class.editable-text--invalid]': 'errorsVisible()', + '[style.display]': 'hidden() ? "none" : null', + '(mouseenter)': 'openBubble()', + '(mouseleave)': 'scheduleCloseBubble()', + '(focus)': 'focus()', + }, }) export class AngularInlineText implements FormValueControl { - signalForm = inject(FormField, { optional: true }); + /** The static in-flow text. Focusable, caret-able — but never mutated by typing. */ + protected display = viewChild.required>('display'); - protected autosize = viewChild('autosize'); - protected overlayControl = viewChild(EditableOverlayControl); - protected wrapper = viewChild(EditableWrapper); + /** The in-flow field area: prefix + display + suffix. Anchors the action bubble. */ + protected fieldArea = viewChild.required>('fieldArea'); - protected singleLineInput = viewChild>('singleLineInput'); - protected multiLineInput = viewChild>('multiLineInput'); + /** The contenteditable inside the elevated panel. Exists only while editing. */ + protected editor = viewChild>('editor'); - // Signal Form Control - // --------------------------------------------------------------------------- + // DI-scoped (not module-global) so the sequence restarts per app instance — + // deterministic across an SSR render and its client hydration. + protected readonly panelId = inject(_IdGenerator).getId('editable-panel-'); + + /** The committed value channel. Updates only on commit. */ value = model(''); - /** - * The local model is the model that is used to store the value of the control. - */ - localModel = linkedSignal(() => this.value() ?? ''); + /** Form Value Contract: disabled */ + disabled = input(false); - localForm = - this.signalForm?.state ?? - form(this.localModel, (path) => { - disabled(path, () => this.signalForm?.state()?.disabled() ?? false); - readonly(path, () => this.signalForm?.state()?.readonly() ?? false); - validate(path, () => { - const valid = this.signalForm?.state()?.valid() ?? true; - if (valid) return null; + /** Form Value Contract: readonly */ + readonly = input(false); - const errorSummary = this.signalForm?.state()?.errors() ?? []; - return { - kind: 'invalid', - errors: errorSummary, - message: errorSummary[0]?.message ?? 'Invalid value', - }; - }); - }); + /** Form Value Contract: required */ + required = input(false); + + /** Form Value Contract: errors */ + errors = input([]); + + /** Form Value Contract: invalid — the bound field's verdict on validity. */ + invalid = input(false); /** - * This is the previous value of the control. - * It is used to revert the value to the previous value if the control is reverted. + * Form Value Contract: touched — the bound field dictates when the user is + * considered done interacting, so `markAsTouched()`/`markAllAsTouched()` + * reveals errors with no interaction on this control (the `form.submitted` + * half of mat's ErrorStateMatcher). */ - previous = linkedSignal({ - source: () => this.value(), - computation: (source, previous): string => { - const dirty = this.localForm().dirty(); - if (!dirty) return source; + touched = input(false); - return previous ? previous.value : ''; - }, - }); + /** Form Value Contract: hidden */ + hidden = input(false); - isEmpty = computed(() => { - const control = this.overlayControl(); + /** + * Form Value Contract: touch — emitted on the closing edge of an edit + * session (our blur analogue), on a failed save attempt, and on clear. + */ + touch = output(); - if (!control) return false; - return control.isEmpty() ?? false; - }); + /** + * Emitted when a draft is discarded (Escape, Discard button, scrim click, + * detach). Payload is the discarded draft text. + * + * Roadmap Phase 3: superseded by `saved` — kept during the transition. + */ + reverted = output(); - // --------------------------------------------------------------------------- - // Editable Core - // --------------------------------------------------------------------------- - appearance = input<'outline' | 'fill'>('fill'); + /** + * Hard commit event: fires once per accepted edit session. + * + * Roadmap Phase 3: superseded by `saved` — kept during the transition. + */ savedModelChange = output(); - showForm = model(false); - // Directives - // --------------------------------------------------------------------------- - restrictionStrategy = input(NOOP_STRATEGY); + /** + * Emitted exactly once per settled edit session — Save, Discard, and clear + * alike. `changed` says whether the settled value differs from the session + * baseline, so consumers persist iff `changed`. Emitted after + * `savedModelChange`/`reverted`. + */ + saved = output(); + + /** Whether the field is elevated (an edit session is open). Two-way bindable. */ + editing = model(false); - // Class Owned - // --------------------------------------------------------------------------- isSingleLine = input(false); placeholder = input('N/A'); - // Inputs - // --------------------------------------------------------------------------- + /** Accessible name for the field (contenteditable has no native label association). */ + ariaLabel = input(undefined); + + /** + * Affix templates — the matPrefix/matSuffix analogues. The input is the + * composition channel (content queries don't pierce re-projection, so + * wrapping controls forward a TemplateRef); direct consumers use the + * `ng-template[editablePrefix/Suffix]` content sugar instead. Rendered + * twice — in the in-flow field and inside the elevated panel — always + * outside the contenteditable and `aria-hidden` (units belong in + * `ariaLabel`). + */ + prefixTemplate = input | undefined>(undefined); + suffixTemplate = input | undefined>(undefined); + + private contentPrefix = contentChild(EditablePrefix); + private contentSuffix = contentChild(EditableSuffix); + + protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); + protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); /** - * This will trim all surplus characters - * - before emitting the value - * - and after accepting setting the value to this normalized value + * Trims leading/trailing whitespace on commit — the committed value and the + * emitted events carry the trimmed text. Interior spacing is never touched. */ normalizeValue = input(false); /** - * Normalization includes (a growing list of things to normalize): - * - removed all surplus spaces - * - removed all newlines + * The session baseline: follows the committed value while idle and freezes + * for the duration of an edit session — the live channel writes every + * keystroke into `value`, so the draft and the value model are one and the + * baseline is what reverts restore. + * + * Frozen on `editing()`, never on field `dirty`: field-dirty is sticky + * across sessions and a dirty-frozen baseline would never thaw. + */ + previous = linkedSignal({ + source: () => this.value() ?? '', + computation: (source, prev) => (this.editing() ? (prev?.value ?? '') : source), + }); + + isEmpty = computed(() => (this.value() ?? '') === ''); + + /** Session-scoped dirty: the draft differs from the baseline right now. */ + protected isDirty = computed(() => this.normalization().changed); + + /** + * The field dictates validity: `invalid` is the bound field's verdict, + * `errors` covers the `[(value)]`/standalone modes where only errors are + * bound. No inner form — validation happens wherever the value lives. + */ + protected isInvalid = computed(() => this.invalid() || this.errors().length > 0); + + /** + * The in-flow display freezes at the session baseline while editing, so + * live draft propagation through `value` never reflows the page. It shows + * the committed value again the moment the session closes. + */ + protected displayText = computed(() => (this.editing() ? this.previous() : (this.value() ?? ''))); + + /** + * Mat-form-field error state: errors exist as soon as validation fails, but + * they are only *shown* once the field was touched (a previous session + * closed) or the user attempted to save the current draft. + */ + #selfTouched = signal(false); + #saveAttempted = signal(false); + protected errorsVisible = computed( + () => this.isInvalid() && (this.touched() || this.#selfTouched() || this.#saveAttempted()), + ); + + /** + * Fallback error rendering when no `[editable-error]` content is projected: + * contract errors that carry a message. Message-less errors still + * invalidate the field but stay silent here. + */ + protected errorMessages = computed(() => this.errors().filter((error) => !!error.message)); + + /** Emits `touch` on the closing edge of an edit session (the blur analogue). */ + #wasOpen = false; + emitTouchOnClose = effect(() => { + const open = this.editing(); + if (this.#wasOpen && !open) { + untracked(() => { + this.#selfTouched.set(true); + this.touch.emit(); + }); + } + + this.#wasOpen = open; + }); + + /** + * Normalization trims edge whitespace only — nothing interior. `changed` + * compares the (possibly trimmed) draft against the session baseline. */ normalization = computed((): ValueNormalizationDetails => { - const value = this.localForm()?.value() ?? ''; + const value = this.value() ?? ''; const previous = this.previous(); if (!this.normalizeValue()) { @@ -172,82 +342,435 @@ export class AngularInlineText implements FormValueControl { }; }); - // Handlers - // ------------------------------------------------------------------------- + /** + * The contenteditable mode for both surfaces. + * Disabled/readonly fields are not editable; otherwise prefer plaintext-only. + */ + protected editableMode = computed(() => { + if (this.disabled() || this.readonly()) return 'false'; + + return SUPPORTS_PLAINTEXT_ONLY ? 'plaintext-only' : 'true'; + }); + + // --------------------------------------------------------------------------- + // Elevated panel overlay + // --------------------------------------------------------------------------- + + /** + * The panel padding, resolved from `--mat-sys-inner-spacing` when an edit + * session opens (px literals only; anything else falls back to the default). + * Keeps the lift offsets glued to the padding _editable.scss derives from + * the same token. + */ + #panelPadding = signal(PANEL_PADDING_FALLBACK); + + #resolvePanelPadding() { + const raw = getComputedStyle(this.display().nativeElement) + .getPropertyValue('--mat-sys-inner-spacing') + .trim(); + + const px = raw.endsWith('px') ? Number.parseFloat(raw) : NaN; + this.#panelPadding.set(Number.isFinite(px) && px >= 0 ? px : PANEL_PADDING_FALLBACK); + } + + protected panelOverlayConfig = computed( + (): CdkConnectedOverlayConfig => ({ + origin: this.display(), + positions: panelPositions(this.#panelPadding()), + hasBackdrop: true, + backdropClass: 'editable-scrim', + viewportMargin: 16, + push: true, + disableClose: true, // Escape is handled by the panel (revert semantics) + disposeOnNavigation: true, + }), + ); + + // --------------------------------------------------------------------------- + // Elevation: pristine display → floating editor + // --------------------------------------------------------------------------- + + /** Caret offset to restore inside the editor once the panel attaches. */ + #pendingCaret: number | null = null; + + /** + * Opens an edit session. Latches the commit baseline, optionally seeds the + * draft with the replayed first edit, and remembers the caret to restore. + */ + protected elevate(caret: number | null = null, seed?: string) { + if (this.editing() || this.disabled() || this.readonly()) return; + + this.#resolvePanelPadding(); + + // Pin the baseline: `previous` derives from `value` while idle — reading + // it here syncs it to the committed value before `editing` freezes it. + const committed = this.previous(); + + this.#saveAttempted.set(false); + this.#pendingCaret = caret; + this.editing.set(true); + + if (seed !== undefined && seed !== committed) { + // Live draft channel: parents (and their validation) see the seed too. + this.value.set(seed); + } + } + + /** + * The display element is caret-able but immutable: every `beforeinput` is + * cancelled, its intent replayed onto the committed text, and the result + * elevated into the overlay editor — the page never reflows from typing. + */ + protected interceptBeforeInput(event: Event) { + event.preventDefault(); + if (this.disabled() || this.readonly() || this.editing()) return; + + const committed = this.value() ?? ''; + const selection = getSelectionOffsets(this.display().nativeElement) ?? { + start: committed.length, + end: committed.length, + }; + + const replayed = replayEdit(committed, selection, event as InputEvent, this.isSingleLine()); + + if (replayed) this.elevate(replayed.caret, replayed.text); + else this.elevate(selection.start); + } + + /** Paste on the pristine display: cancel, splice into the draft, elevate. */ + protected interceptPaste(event: ClipboardEvent) { + event.preventDefault(); + if (this.disabled() || this.readonly() || this.editing()) return; + + let text = event.clipboardData?.getData('text/plain') ?? ''; + if (this.isSingleLine()) text = text.replace(/\r?\n+/g, ' '); + + const committed = this.value() ?? ''; + const selection = getSelectionOffsets(this.display().nativeElement) ?? { + start: committed.length, + end: committed.length, + }; + + const draft = committed.slice(0, selection.start) + text + committed.slice(selection.end); + this.elevate(selection.start + text.length, draft); + } + + /** + * IME composition cannot be cancelled via `beforeinput` — elevate on + * `compositionstart` instead. Focus moving into the overlay editor aborts + * the in-flight composition; any text it committed into the display is + * cleaned up in `handlePanelAttach`. + */ + protected handleCompositionStart() { + if (this.disabled() || this.readonly() || this.editing()) return; + + const committed = this.value() ?? ''; + const selection = getSelectionOffsets(this.display().nativeElement); + this.elevate(selection?.start ?? committed.length); + } + + /** Panel attached: reset any stray display mutation, focus the editor, restore the caret. */ + protected handlePanelAttach() { + // viewChild('editor') resolves after the overlay view is created. + queueMicrotask(() => { + const displayEl = this.display().nativeElement; + const frozen = this.displayText(); + if ((displayEl.textContent ?? '') !== frozen) displayEl.textContent = frozen; + + const editorEl = this.editor()?.nativeElement; + if (!editorEl) return; + + const draft = this.value() ?? ''; + if ((editorEl.innerText ?? editorEl.textContent ?? '') !== draft) { + editorEl.textContent = draft; + } + + editorEl.focus(); + setCaretOffset(editorEl, this.#pendingCaret ?? draft.length); + this.#pendingCaret = null; + }); + } + + // --------------------------------------------------------------------------- + // Commit / revert + // --------------------------------------------------------------------------- accepted = false; protected accept() { const { value, changed } = this.normalization(); if (!changed) { - this.showForm.set(false); - this.localForm().reset(); + this.close(); + this.saved.emit({ value, changed: false }); return; } - // Validation check uses the activeForm state - if (this.localForm().invalid()) return; + // Mat-style submit attempt: an invalid draft doesn't commit — it reveals + // the errors (and marks the field touched) so the user can react. + if (this.isInvalid()) { + this.#saveAttempted.set(true); + this.#selfTouched.set(true); + this.touch.emit(); + return; + } this.accepted = true; - // Fire the hard commit! + // Commit: the single point where the page is allowed to reflow. The + // baseline follows on close — `previous` unfreezes with the session. + this.value.set(value); + this.savedModelChange.emit(value); - this.showForm.set(false); + this.saved.emit({ value, changed: true }); + this.close(); + } + + /** + * Single choke point for discarding a draft (Escape, Discard button, + * scrim click, detach). Restores the session baseline and notifies the + * parent via `reverted`. + */ + protected revert() { + const draft = this.value() ?? ''; + const baseline = this.previous(); + const hadChanges = draft !== baseline; + + // Roll back the live draft channel to the session baseline. + if (draft !== baseline) this.value.set(baseline); + + if (hadChanges) this.reverted.emit(draft); + + // Revert can run twice per session (cancel, then the detach safety net); + // only the in-session call settles the session. The no-diff accept path + // closes before detach and reports its settlement itself. + if (this.editing()) this.saved.emit({ value: baseline, changed: false }); + } + + /** Discard the draft and close the session. */ + protected cancel() { + this.revert(); + this.close(); + } + + /** Closes the panel and returns focus to the in-flow display for Tab continuity. */ + protected close() { + if (!this.editing()) return; + + this.editing.set(false); + this.display().nativeElement.focus(); + } + + protected handleScrimClick() { + this.cancel(); + } + + /** Detach safety net (e.g. dispose-on-navigation): never lose the baseline silently. */ + protected handlePanelDetach() { + if (this.accepted) { + this.accepted = false; + return; + } + + this.revert(); + // Detach without close() (navigation, destroy): sync the open state. + if (this.editing()) this.editing.set(false); + } + + // --------------------------------------------------------------------------- + // Elevated editor events + // --------------------------------------------------------------------------- + + /** + * Syncs the editor DOM into the draft form on every input. + * The DOM is the source of truth while typing; `innerText` preserves + * line breaks (unlike `textContent` when the browser inserts `
`). + */ + protected handleEditorInput() { + const el = this.editor()?.nativeElement; + if (!el) return; - if (this.isSingleLine()) { - this.singleLineInput()?.nativeElement.blur(); + // innerText preserves line breaks; textContent fallback for jsdom + let text = el.innerText ?? el.textContent ?? ''; + + // An "empty" editable can report a lone line break + if (text === '\n') text = ''; + + // Single-line: strip line breaks that slip in via paste + if (this.isSingleLine() && text.includes('\n')) { + text = text.replace(/\n+/g, ' '); + el.textContent = text; + } + + // Live draft channel: bound parents (and their schema validation) follow + // every keystroke. The page stays still — the display is frozen at the + // session baseline. `revert` rolls this back. + this.value.set(text); + } + + /** + * Paste fallback for browsers without `plaintext-only`: + * insert clipboard text as plain text (never HTML) via Selection APIs. + */ + protected handleEditorPaste(event: ClipboardEvent) { + if (SUPPORTS_PLAINTEXT_ONLY) return; // browser already enforces plain text + + event.preventDefault(); + + const el = this.editor()?.nativeElement; + if (!el) return; + + let text = event.clipboardData?.getData('text/plain') ?? ''; + if (this.isSingleLine()) text = text.replace(/\r?\n+/g, ' '); + if (!text) return; + + const selection = el.ownerDocument.defaultView?.getSelection(); + + if ( + !selection || + selection.rangeCount === 0 || + !el.contains(selection.getRangeAt(0).commonAncestorContainer) + ) { + // No usable caret — append at the end + el.textContent = (el.innerText ?? el.textContent ?? '') + text; } else { - this.multiLineInput()?.nativeElement.blur(); + const range = selection.getRangeAt(0); + range.deleteContents(); + + const node = el.ownerDocument.createTextNode(text); + range.insertNode(node); + + // Collapse the caret after the inserted text + range.setStartAfter(node); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); } - this.localForm().reset(); + this.handleEditorInput(); } - protected handleDetach() { - if (this.accepted) return; + /** Single-line fields accept on Enter instead of inserting a line break. */ + protected handleEnterKey(event: Event) { + if (!this.isSingleLine()) return; - const previous = this.previous(); - const current = this.localForm().value(); + event.preventDefault(); + this.accept(); + } + + protected handleCtrlEnter() { + this.accept(); + } + + /** + * Writes external draft changes (clear, programmatic writes) into the + * editor DOM while the panel is open. Compares against `innerText` so + * in-flight typing (already in sync via `handleEditorInput`) is never + * clobbered mid-keystroke. + */ + syncEditor = effect(() => { + const value = this.value() ?? ''; + const el = this.editor()?.nativeElement; + if (!el) return; + + untracked(() => { + const current = el.innerText ?? el.textContent ?? ''; + if (current !== value) el.textContent = value; + }); + }); - // If they click away and it's different than the latched value, revert - if (previous !== current) { - this.localForm().reset(previous); + // --------------------------------------------------------------------------- + // Floating action bubble (Notion-style, CDK overlay — never clipped) + // --------------------------------------------------------------------------- + + protected bubblePositions = BUBBLE_POSITIONS; + protected bubbleOrigin = computed(() => this.fieldArea()); + + /** Pointer intent: over the field or over the bubble itself. */ + #bubbleHover = signal(false); + #bubbleCloseTimer: ReturnType | null = null; + + /** The delayed close must not fire into a destroyed component. */ + #cancelBubbleTimerOnDestroy = inject(DestroyRef).onDestroy(() => { + if (this.#bubbleCloseTimer !== null) clearTimeout(this.#bubbleCloseTimer); + }); + + /** The bubble shows on hover intent — never for empty/required/locked fields or while editing. */ + protected showBubble = computed(() => { + if (this.required() || this.disabled() || this.readonly()) return false; + if (this.isEmpty() || this.editing()) return false; + + return this.#bubbleHover(); + }); + + protected openBubble() { + if (this.#bubbleCloseTimer !== null) { + clearTimeout(this.#bubbleCloseTimer); + this.#bubbleCloseTimer = null; } + + this.#bubbleHover.set(true); } - protected handleCopied() { - this.overlayControl()?.copyCurrent(); + /** Delayed close so the pointer can cross the gap between field and bubble. */ + protected scheduleCloseBubble() { + if (this.#bubbleCloseTimer !== null) clearTimeout(this.#bubbleCloseTimer); + + this.#bubbleCloseTimer = setTimeout(() => { + this.#bubbleCloseTimer = null; + this.#bubbleHover.set(false); + }, 150); } + /** + * Clear is a commit *and* an interaction (mat-faithful): it always commits + * '' and marks the field touched, so a schema that rejects the cleared + * value surfaces through the idle error state immediately. `required()` + * keeps hiding the bubble — a guaranteed-doomed clear stays unavailable. + */ protected clearValue(event: Event) { event.preventDefault(); event.stopPropagation(); this.value.set(''); this.savedModelChange.emit(''); - this.localForm().reset(); + this.saved.emit({ value: '', changed: true }); + + this.#selfTouched.set(true); + this.touch.emit(); } - provideAutosize() { - if (this.isSingleLine()) return; + // --------------------------------------------------------------------------- + // FormUiControl contract + // --------------------------------------------------------------------------- - return effect(() => { - this.localForm().value(); - untracked(() => requestAnimationFrame(() => this.autosize()?.resize())); - }); + /** Focus the in-flow display element. */ + focus(options?: FocusOptions) { + this.display().nativeElement.focus(options); } - autoResetAccepted = effect(() => { - if (this.showForm()) { - untracked(() => (this.accepted = false)); - } - }); + /** + * Form UI Contract: reset — return to the pristine presentation state. + * Invoked by the bound field after it applied its own value/touched reset. + * + * MatInput precedent: value restoration is the field's job (a + * `reset(value)` arrives through the `value` binding); this only resets + * presentation state. One draft-control extra: an open session's draft is + * sitting in the live `value` channel, so it is discarded back to the + * session baseline — a draft never survives a reset. A programmatic reset + * is not a user interaction: no `touch`, no `saved`, no `reverted`, and no + * focus stealing. + */ + reset() { + this.#selfTouched.set(false); + this.#saveAttempted.set(false); - resize = effect(() => { - // Move the check inside the effect - if (this.isSingleLine()) return; + if (!this.editing()) return; - this.localForm().value(); - untracked(() => requestAnimationFrame(() => this.autosize()?.resize())); - }); + const baseline = this.previous(); + if ((this.value() ?? '') !== baseline) this.value.set(baseline); + + this.#wasOpen = false; // suppress the touch emission for this close + this.accepted = true; // suppress the detach revert safety net + this.editing.set(false); + } } diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/caret.ts b/projects/angular-inline-select/src/lib/angular-inline-text/caret.ts new file mode 100644 index 0000000..39db6fc --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/caret.ts @@ -0,0 +1,124 @@ +/** + * Pure helpers for the display → overlay editor handoff: + * mapping between plain-text offsets and DOM selections, and replaying the + * intercepted first edit onto the committed text. + * + * Text offsets (not DOM ranges) are the transfer currency because the display + * element and the overlay editor are different DOM trees. + */ + +export interface SelectionOffsets { + start: number; + end: number; +} + +export interface ReplayedEdit { + /** The draft text after applying the intercepted edit. */ + text: string; + /** The caret offset within the draft text. */ + caret: number; +} + +/** + * Reads the current selection inside `root` as plain-text offsets. + * Returns `null` when the selection lives outside of `root`. + */ +export function getSelectionOffsets(root: HTMLElement): SelectionOffsets | null { + const doc = root.ownerDocument; + const selection = doc.defaultView?.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null; + + const probe = doc.createRange(); + probe.selectNodeContents(root); + probe.setEnd(range.startContainer, range.startOffset); + const start = probe.toString().length; + + probe.setEnd(range.endContainer, range.endOffset); + const end = probe.toString().length; + + return { start, end: Math.max(start, end) }; +} + +/** + * Places a collapsed caret at `offset` (plain-text) inside `root`. + * Offsets past the end clamp to the end of the content. + */ +export function setCaretOffset(root: HTMLElement, offset: number): void { + const doc = root.ownerDocument; + const selection = doc.defaultView?.getSelection(); + if (!selection) return; + + let remaining = Math.max(0, offset); + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + let target: Text | null = null; + let targetOffset = 0; + let node: Node | null; + + while ((node = walker.nextNode())) { + const text = node as Text; + if (remaining <= text.data.length) { + target = text; + targetOffset = remaining; + break; + } + remaining -= text.data.length; + } + + const range = doc.createRange(); + if (target) { + range.setStart(target, targetOffset); + } else { + // Offset beyond content (or empty root) — caret at the very end. + range.selectNodeContents(root); + range.collapse(false); + } + range.collapse(true); + + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Applies the intent of an intercepted `beforeinput` event onto `text`. + * Covers the edits a pristine field can receive from the keyboard; anything + * exotic returns `null`, which elevates without modifying the draft. + */ +export function replayEdit( + text: string, + { start, end }: SelectionOffsets, + event: Pick, + singleLine: boolean, +): ReplayedEdit | null { + const insert = (chunk: string): ReplayedEdit => ({ + text: text.slice(0, start) + chunk + text.slice(end), + caret: start + chunk.length, + }); + + switch (event.inputType) { + case 'insertText': + return event.data ? insert(event.data) : null; + + case 'insertLineBreak': + case 'insertParagraph': + return singleLine ? null : insert('\n'); + + case 'deleteContentBackward': { + if (start !== end) return { text: text.slice(0, start) + text.slice(end), caret: start }; + if (start === 0) return null; + return { text: text.slice(0, start - 1) + text.slice(start), caret: start - 1 }; + } + + case 'deleteContentForward': { + if (start !== end) return { text: text.slice(0, start) + text.slice(end), caret: start }; + if (end >= text.length) return null; + return { text: text.slice(0, start) + text.slice(end + 1), caret: start }; + } + + default: + return null; + } +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts deleted file mode 100644 index bb4a120..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/directives/editable-overlay-control.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - Directive, - inject, - ElementRef, - - // Signals - input, - computed, - signal, - linkedSignal, - type WritableSignal, -} from '@angular/core'; -import { FieldTree, FormField } from '@angular/forms/signals'; - -/** - * Directive to control the overlay of the editable component. This is used to - * - open and close the overlay of the editable component. - * - set the class of the editable component based on the state of the control. - */ -@Directive({ - selector: '[mEditableOverlayControl]', - exportAs: 'editableOverlayControl', - host: { - class: 'iusta-editable', - '[class.iusta-editable--empty]': 'isOpen() === false && isEmpty()', - '[class.iusta-editable--filled]': 'isOpen() === false && !isEmpty()', - '(focus)': 'showSignal().set(true)', - '(blur)': 'onBlur($event)', - }, -}) -export class EditableOverlayControl { - host = inject>(ElementRef); - #control = inject(FormField, { optional: true }); - - mEditableOverlayControl = input.required<{ - showSignal: WritableSignal; - localForm?: FieldTree; - }>(); - - /** - * This control expects the distinction between local form and injected form. - * The injected form involves what user expects - why the local form is the actual - * control. Both can coexist but fulfill different purposes. - */ - state = computed(() => { - const config = this.mEditableOverlayControl(); - const state = config?.localForm?.() ?? this.#control?.state(); - - if (!state) { - throw new Error('Please Provide FormField to properly control editable wrapper'); - } - - return state; - }); - - /** Writable show signal (the actual signal instance) */ - showSignal = computed(() => this.mEditableOverlayControl().showSignal); - currentValue = computed(() => this.state().value()); - - /** Boolean open value */ - isOpen = computed(() => this.showSignal()()); - - /** - * Whether the control is empty - */ - isEmpty = computed(() => { - const state = this.state(); - if (!state) return false; // ← safe default - - if (typeof this.state().value() === 'string') { - return this.state().value() === ''; - } - - return this.state().value() === null || this.state().value() === undefined; - }); - - /* - * Whether the value is required - */ - required = computed(() => !!this.state().required()); - - /** - * The value to copy - */ - copyValue = signal(undefined); - - /** - * The warning message to display - */ - warningMessage = linkedSignal({ - source: () => this.#control?.state().value() ?? undefined, - computation: () => 'You have unsaved changes', - }); - - resetWarningMessage() { - this.warningMessage.set('You have unsaved changes'); - } - - /** - * Handler for the blur event. - * @param event - The focus event - */ - onBlur(event: FocusEvent) { - if (this.state().dirty()) return; - - // 2. Check where the focus is going (relatedTarget) - const nextTarget = event.relatedTarget as HTMLElement | null; - - // If focus is moving into the editable panel (e.g., the Save button), - if (nextTarget?.closest('.editable-panel')) return; - - // 3. Otherwise, safe to close - this.showSignal().set(false); - } - - copyCurrent() { - const value = this.copyValue() ?? this.currentValue(); - if (!value) return; - navigator.clipboard.writeText(String(value)); - } -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts deleted file mode 100644 index 691b2c3..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/directives/overlay-width-sync.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - Directive, - ElementRef, - OnDestroy, - AfterViewInit, - inject, - InjectionToken, - Injector, - Signal, - - // Signals - computed, - input, - model, -} from '@angular/core'; -import { CdkConnectedOverlay, ConnectedPosition, CdkConnectedOverlayConfig, Overlay } from '@angular/cdk/overlay'; - -/** - * These offsets are in line with the ones appearing in the editable styles. - * There these sizes are relative to rem but here we eye balled them to be 1rem = 13px font size. - */ -export const VISUAL_Y_OFFSET = 7.5; -export const VISUAL_X_OFFSET = 9; - -export const VISUAL_X_OFFSET_OUTLINE = 9.75; -export const VISUAL_Y_OFFSET_OUTLINE = 9.5; - -export const OVERLAY_POSITIONS: ConnectedPosition[] = [ - { - originX: 'start', - originY: 'bottom', - overlayX: 'start', - overlayY: 'top', - offsetY: VISUAL_Y_OFFSET, - offsetX: -VISUAL_X_OFFSET, - panelClass: ['__bottom'], - }, - { - originX: 'start', - originY: 'top', - overlayX: 'start', - overlayY: 'bottom', - offsetY: -VISUAL_Y_OFFSET, - offsetX: -VISUAL_X_OFFSET, - panelClass: ['__top'], - }, -]; - -export const OVERLAY_POSITIONS_OUTLINE: ConnectedPosition[] = [ - { - originX: 'start', - originY: 'bottom', - overlayX: 'start', - overlayY: 'top', - offsetY: VISUAL_Y_OFFSET_OUTLINE, - offsetX: -VISUAL_X_OFFSET_OUTLINE, - panelClass: ['__bottom'], - }, - { - originX: 'start', - originY: 'top', - overlayX: 'start', - overlayY: 'bottom', - offsetY: -VISUAL_Y_OFFSET_OUTLINE, - offsetX: -VISUAL_X_OFFSET_OUTLINE, - panelClass: ['__top'], - }, -]; - -/** - * Appearance type for editable components. - */ -export type EditableAppearance = 'outline' | 'fill'; - -/** - * Default appearance value. Components using this directive should use this - * as their input default to maintain consistency. - */ -export const DEFAULT_EDITABLE_APPEARANCE: EditableAppearance = 'fill'; - -/** - * Configuration interface for host directive usage. - * Components can provide this to configure the directive via DI - * instead of template inputs. - */ -export interface OverlayWidthSyncContext { - /** Signal indicating whether the overlay is open */ - isOpen: Signal; - /** Signal for the appearance style */ - appearance: Signal; - /** Signal for the element to measure width from */ - originElement: Signal; - /** Optional signal for width offset override */ - widthOffsetOverride?: Signal; - /** Function to manually trigger overlay repositioning */ - connectedOverlay?: Signal; -} - -export const DEFAULT_CDK_CONNECTED_OVERLAY_CONFIG: CdkConnectedOverlayConfig = { - viewportMargin: 8, - push: true, - disposeOnNavigation: true, -} as const; - -/** - * Injection token for host directive configuration. - * Components using the directive as a hostDirective should provide this. - */ -export const OVERLAY_WIDTH_SYNC_CONTEXT = new InjectionToken('OVERLAY_WIDTH_SYNC_CONTEXT'); - -/** - * Directive that automatically syncs an overlay's minimum width with its origin element - * and handles repositioning when the origin resizes. - * - * It also provides computed overlay positions and width offsets based on the appearance. - */ -@Directive({ - selector: '[mOverlayWidthSync]', - exportAs: 'overlayWidthSync', -}) -export class OverlayWidthSyncDirective implements AfterViewInit, OnDestroy { - #overlay = inject(Overlay); - #elementRef = inject(ElementRef); - #injector = inject(Injector); - - scrollStrategy = computed(() => this.#overlay.scrollStrategies.block()); - - /** - * Lazily resolved context for host directive usage. - * Uses Injector.get() to avoid circular dependency during construction. - */ - #contextCache: OverlayWidthSyncContext | null | undefined = undefined; - - #getContext(): OverlayWidthSyncContext | null { - if (this.#contextCache === undefined) { - this.#contextCache = this.#injector.get(OVERLAY_WIDTH_SYNC_CONTEXT, null, { optional: true }); - } - - return this.#contextCache; - } - - defaultConfig = DEFAULT_CDK_CONNECTED_OVERLAY_CONFIG; - - // --------------------------------------------------------------------------- - // Inputs (used when no context is provided) - // --------------------------------------------------------------------------- - - /** - * The visual appearance style. Determines default positions and offsets. - * - 'fill': Default style with standard offsets - * - 'outline': Outline style with slightly larger offsets - */ - appearanceInput = input(DEFAULT_EDITABLE_APPEARANCE, { alias: 'appearance' }); - - /** - * Optional override for the width offset. - * If not provided, automatically calculated based on appearance. - */ - widthOffsetOverrideInput = input(undefined, { alias: 'widthOffsetOverride' }); - - /** - * Whether the overlay is currently open (used to conditionally apply width) - */ - isOpenInput = input(false, { alias: 'isOpen' }); - - minWidth = input(250, { alias: 'widthSyncMin' }); - - /** - * Optional element to measure width from. - * If not provided, measures the host element. - */ - originElementInput = input(undefined, { alias: 'originElement' }); - - /** - * The measured width of the element - */ - measuredWidth = model(0); - - // --------------------------------------------------------------------------- - // Resolved values (prefer context over inputs) - // --------------------------------------------------------------------------- - - /** Resolved isOpen - prefers context over input */ - #isOpen = computed(() => this.#getContext()?.isOpen() ?? this.isOpenInput()); - - /** Resolved appearance - prefers context over input */ - #appearance = computed(() => this.#getContext()?.appearance() ?? this.appearanceInput()); - - /** Resolved originElement - prefers context over input */ - #originElement = computed(() => this.#getContext()?.originElement() ?? this.originElementInput()); - - /** Resolved widthOffsetOverride - prefers context over input */ - #widthOffsetOverride = computed(() => this.#getContext()?.widthOffsetOverride?.() ?? this.widthOffsetOverrideInput()); - - // --------------------------------------------------------------------------- - // Computed values based on appearance - // --------------------------------------------------------------------------- - - /** - * The width offset based on appearance (or explicit override) - */ - widthOffset = computed(() => { - const override = this.#widthOffsetOverride(); - if (override !== undefined) return override; - - const offset = this.#appearance() === 'outline' ? VISUAL_X_OFFSET_OUTLINE : VISUAL_X_OFFSET; - return offset * 2; - }); - - /** - * The computed width including any offsets - */ - overlayWidth = computed(() => Math.max(this.measuredWidth() + this.widthOffset(), this.minWidth())); - - /** - * The overlay positions based on appearance. - */ - overlayPositions = computed((): ConnectedPosition[] => { - if (this.#appearance() === 'outline') { - return OVERLAY_POSITIONS_OUTLINE; - } - - return OVERLAY_POSITIONS; - }); - - // --------------------------------------------------------------------------- - // Resize handling - // --------------------------------------------------------------------------- - - #resizeObserver?: ResizeObserver; - #rafId: number | null = null; - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - ngAfterViewInit() { - const element = this.#originElement() ?? this.#elementRef.nativeElement; - - // Initialize with current width - this.measuredWidth.set(element.getBoundingClientRect().width); - - // Watch for size changes - this.#resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - - // Use contentRect to avoid layout thrashing - this.measuredWidth.set(entry.contentRect.width); - - // Trigger overlay reposition if needed - this.#scheduleOverlayReposition(); - }); - - this.#resizeObserver.observe(element); - } - - ngOnDestroy() { - this.#resizeObserver?.disconnect(); - this.#resizeObserver = undefined; - - if (this.#rafId !== null) { - cancelAnimationFrame(this.#rafId); - this.#rafId = null; - } - } - - private connectedOverlay = computed(() => this.#getContext()?.connectedOverlay?.()); - - /** - * Throttled overlay reposition using requestAnimationFrame - */ - #scheduleOverlayReposition() { - if (!this.#isOpen()) return; - - const overlay = this.connectedOverlay(); - if (!overlay?.overlayRef) return; - - if (this.#rafId !== null) return; - - this.#rafId = requestAnimationFrame(() => { - this.#rafId = null; - - overlay.overlayRef?.updatePosition(); - }); - } - - /** - * Public method to manually trigger overlay repositioning - */ - updateOverlayPosition() { - this.#scheduleOverlayReposition(); - } -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts deleted file mode 100644 index 1f1fbb3..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/restrict-characters.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { computed, Directive, ElementRef, inject, input, signal } from '@angular/core'; -import { NOOP_STRATEGY, RestrictStrategy } from './tokens'; - -export interface BlockedInputEvent { - kind: 'beforeinput' | 'paste' | 'keydown'; - attempted?: string; - strategy?: string; -} - -/** - * RestrictCharacters Directive - * =========================== - * Attribute directive that restricts / transforms user input based on a provided strategy instance. - * - * - Host element should remain a text input/textarea (no native input type switching). - * - Strategy instance is passed in via `[strategy]`. - * - Delegates input-related DOM events to the strategy. - * - IME-safe: ignores `beforeinput` while composition is active. - * - * Usage - * ----- - * ```html - * - * ``` - */ -@Directive({ - selector: '[mRestrictCharacters]', - exportAs: 'restrictCharacters', - host: { - '(beforeinput)': 'onBeforeInput($event)', - '(paste)': 'onPaste($event)', - '(keydown)': 'onKeydown($event)', - '(compositionstart)': 'onCompositionStart()', - '(compositionend)': 'onCompositionEnd()', - }, -}) -export class RestrictCharacters { - strategy = input(NOOP_STRATEGY); - - #elRef = inject>(ElementRef); - - // Signals for directive state - #composing = signal(false); - - // Computed: active strategy (mostly just for readability) - #activeStrategy = computed(() => this.strategy()); - - get el() { - return this.#elRef.nativeElement; - } - - // ctx as a method: always up-to-date, no capture issues - #ctx() { - const el = this.el; - - return { - el, - setValueAndNotify: (v: string) => { - el.value = v; - el.dispatchEvent(new Event('input', { bubbles: true })); - }, - proposedAfterInsert: (insert: string) => { - const { value, selectionStart, selectionEnd } = el; - const s = selectionStart ?? value.length; - const e = selectionEnd ?? value.length; - return value.slice(0, s) + insert + value.slice(e); - }, - insertTextAtSelection: (text: string) => { - const { value, selectionStart, selectionEnd } = el; - const s = selectionStart ?? value.length; - const e = selectionEnd ?? value.length; - - const next = value.slice(0, s) + text + value.slice(e); - - el.value = next; - el.dispatchEvent(new Event('input', { bubbles: true })); - - const pos = s + text.length; - el.setSelectionRange(pos, pos); - }, - }; - } - - onCompositionStart() { - this.#composing.set(true); - } - - onCompositionEnd() { - this.#composing.set(false); - } - - onBeforeInput(e: InputEvent) { - if (this.#composing()) return; - this.#activeStrategy()?.beforeInput?.(this.#ctx(), e); - } - - onPaste(e: ClipboardEvent) { - this.#activeStrategy()?.paste?.(this.#ctx(), e); - } - - onKeydown(e: KeyboardEvent) { - this.#activeStrategy()?.keydown?.(this.#ctx(), e); - } -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts deleted file mode 100644 index 9030947..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/directives/restrict-characters/tokens.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export interface RestrictStrategy { - /** - * Optional identifier (for debugging / logging). - * Not required for execution. - */ - readonly strategy?: string; - - beforeInput?(ctx: RestrictContext, e: InputEvent): void; - paste?(ctx: RestrictContext, e: ClipboardEvent): void; - keydown?(ctx: RestrictContext, e: KeyboardEvent): void; -} - -export interface RestrictContext { - el: HTMLInputElement | HTMLTextAreaElement; - setValueAndNotify(value: string): void; - proposedAfterInsert(insert: string): string; - insertTextAtSelection?(text: string): void; -} - -/** - * Explicit "do nothing" strategy. - * - * This is a single frozen object shared by the whole app. - * It is NOT provided via DI and has zero runtime behavior. - */ -export const NOOP_STRATEGY: RestrictStrategy = Object.freeze({ - strategy: 'noop', -}); - -/** - * Optional: only needed if you still want to register strategies via DI. - * Can be removed if you always pass `[strategy]="..."` directly. - */ -export const RESTRICT_STRATEGIES = new InjectionToken('RESTRICT_STRATEGIES'); diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts b/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts deleted file mode 100644 index 0479282..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/directives/textarea-autosize.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Directive, ElementRef, OnInit, inject, output } from '@angular/core'; - -/** - * Simple drop-in replacement for the cdkTextareaAutosize directive keeping the resize handler. - * It does not include everything that cdkTextareaAutosize does, but it is a good starting point. - */ -@Directive({ - selector: 'textarea[mTextareaAutosize]', - exportAs: 'mTextareaAutosize', - host: { - '(input)': 'onInput()', - }, -}) -export class TextareaAutosize implements OnInit { - private elementRef = inject(ElementRef); - resized = output(); - - protected onInput() { - this.resize(); - } - - ngOnInit() { - if (this.elementRef.nativeElement.scrollHeight) { - requestAnimationFrame(() => this.resize()); - } - } - - resize() { - const el = this.elementRef.nativeElement as HTMLTextAreaElement; - el.style.height = 'auto'; - el.style.height = el.scrollHeight + 'px'; - - this.resized.emit(); - } -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html deleted file mode 100644 index 6aed874..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.html +++ /dev/null @@ -1,36 +0,0 @@ -@if (!hideDiscard()) { - -} - - diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss deleted file mode 100644 index 91141f3..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.scss +++ /dev/null @@ -1,52 +0,0 @@ -@use '@angular/material' as mat; - -:host { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.action-reset { - @include mat.button-overrides( - ( - // neutral but still clearly clickable - text-label-text-color: var(--iusta-sys-on-surface-variant, #6b7280), - text-state-layer-color: var(--iusta-sys-on-surface, #111827), - text-container-shape: var(--iusta-editable-radius, 0.25rem) - ) - ); -} - -.action-save { - @include mat.button-overrides( - ( - filled-container-shape: var(--iusta-editable-radius, 0.25rem), - ) - ); -} - -/* 1. Define the rotation */ -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -/* 2. Target the mat-icon only when the parent button has aria-busy="true" */ -button[aria-busy='true'] mat-icon { - animation: spin 1s linear infinite; - - /* Ensures the rotation happens around the center of the icon */ - display: inline-block; - line-height: 1; -} - -/* 3. Optional: visual feedback for the button itself */ -button[aria-busy='true'] { - pointer-events: none; /* Prevent double-clicks while loading */ - opacity: 0.8; - cursor: wait; -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts deleted file mode 100644 index ecd6b54..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-action-buttons/editable-action-buttons.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, input, output } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; - -@Component({ - selector: 'm-editable-action-buttons', - imports: [ - // Material - MatIconModule, - MatButtonModule, - ], - templateUrl: './editable-action-buttons.html', - styleUrl: './editable-action-buttons.scss', -}) -export class EditableActionButtons { - accept = output(); - decline = output(); - disableAccept = input(false); - disable = input(false); - isLoading = input(false); - - hideDiscard = input(false); -} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-affix.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-affix.ts new file mode 100644 index 0000000..a150c51 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-affix.ts @@ -0,0 +1,36 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Suffix template for an inline field — the matSuffix analogue. + * + * Declared on an `ng-template` (not an element) because the affix renders + * TWICE: after the in-flow display while idle, and beside the editor inside + * the elevated panel while editing — the panel covers the surrounding copy, + * so a unit written next to the field would vanish exactly while the user + * edits. A template stamps into both places; projected elements cannot. + * + * The affix is never part of the draft: it sits outside the contenteditable, + * the caret cannot enter it and the parser never sees it. It renders + * `aria-hidden` — put the unit in `ariaLabel` ("Price in euros") where + * assistive tech actually hears it. + * + * ```html + * + * euro + * + * ``` + */ +@Directive({ + selector: 'ng-template[editableSuffix]', +}) +export class EditableSuffix { + readonly templateRef = inject>(TemplateRef); +} + +/** Prefix template for an inline field — the matPrefix analogue. See {@link EditableSuffix}. */ +@Directive({ + selector: 'ng-template[editablePrefix]', +}) +export class EditablePrefix { + readonly templateRef = inject>(TemplateRef); +} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts new file mode 100644 index 0000000..e08733c --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts @@ -0,0 +1,25 @@ +import { Directive } from '@angular/core'; + +/** + * Marker for parent-provided error content — the mat-error analogue. + * + * Projected into the panel's error slot and shown by the field itself under + * mat-form-field rules (invalid AND touched-or-save-attempted) — consumers + * decide *what* the error says, never *when* it shows. When present it takes + * over the slot entirely; without it the field renders the message-carrying + * errors itself. + * + * ```html + * + * Callsigns look like “AUR-01”. + * + * ``` + * + * The element must be a direct, unconditional child — projection matches the + * `[editable-error]` attribute, and control-flow blocks don't match attribute + * selectors. Gate variable content with `@if` INSIDE the element. + */ +@Directive({ + selector: '[editable-error]', +}) +export class EditableError {} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html deleted file mode 100644 index 677ff5b..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.html +++ /dev/null @@ -1,65 +0,0 @@ - - - -
- @for (error of control().errors(); track error.kind) { -
- {{ error.message ?? 'Invalid value' }} -
- } - - @if (isDirty() && !isInvalid()) { -
- warning - {{ warningMessage() }} -
- } - - @if (isDirty()) { -
-
- @if (currentValue()) { - - } -
- -
- -
-
- } -
-
diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss deleted file mode 100644 index 24ed64e..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.scss +++ /dev/null @@ -1,3 +0,0 @@ -// Structural styles for the editable wrapper live in the shared stylesheet -// (styles/inline-text.scss) because the overlay panel renders outside of this -// component's encapsulation scope. diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts deleted file mode 100644 index b91b39b..0000000 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-wrapper/editable-wrapper.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { - Component, - ElementRef, - inject, - - // Signals - computed, - output, - contentChild, - viewChild, - input, - Signal, -} from '@angular/core'; -import { CdkConnectedOverlay, CdkConnectedOverlayConfig, OverlayModule } from '@angular/cdk/overlay'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { A11yModule } from '@angular/cdk/a11y'; - -import { EditableOverlayControl } from '../directives/editable-overlay-control'; -import { EditableActionButtons } from '../editable-action-buttons/editable-action-buttons'; -import { - DEFAULT_EDITABLE_APPEARANCE, - EditableAppearance, - OverlayWidthSyncContext, - OverlayWidthSyncDirective, - OVERLAY_WIDTH_SYNC_CONTEXT, -} from '../directives/overlay-width-sync'; - -@Component({ - selector: 'm-editable-wrapper', - imports: [ - // CDK - OverlayModule, - A11yModule, - - // Material - MatButtonModule, - MatIconModule, - - // Components - EditableActionButtons, - ], - hostDirectives: [OverlayWidthSyncDirective], - providers: [ - { - provide: OVERLAY_WIDTH_SYNC_CONTEXT, - useExisting: EditableWrapper, - }, - ], - templateUrl: './editable-wrapper.html', - styleUrl: './editable-wrapper.scss', - host: { - class: 'iusta-editable-wrapper', - '[class]': 'customClass()', - '[class.iusta-editable-wrapper--has-prefix]': 'hasPrefix()', - '[class.iusta-editable-wrapper--outline]': 'appearance() === "outline"', - '[class.invalid]': 'isInvalid()', - '[class.is-editing]': 'isOpen()', - - '(keydown.control.enter)': 'handleAccept()', - '(keydown.escape)': 'handleDecline()', - '(keydown.tab)': 'handleTab($event)', - '(keydown.shift.tab)': 'handleTab($event)', - }, -}) -export class EditableWrapper implements OverlayWidthSyncContext { - widthOffsetOverride?: Signal | undefined; - // --------------------------------------------------------------------------- - // Host directive - // --------------------------------------------------------------------------- - private widthSync = inject(OverlayWidthSyncDirective); - - // --------------------------------------------------------------------------- - // Content + View refs - // --------------------------------------------------------------------------- - wrapperRef = inject(ElementRef); - connectedOverlay = viewChild(CdkConnectedOverlay); - protected overlayControl = contentChild.required(EditableOverlayControl); - protected panelRef = viewChild>('panel'); - - // --------------------------------------------------------------------------- - // Inputs - // --------------------------------------------------------------------------- - appearance = input(DEFAULT_EDITABLE_APPEARANCE); - hasPrefix = input(false); - customClass = input(''); - - // --------------------------------------------------------------------------- - // Derived state (signals) - // --------------------------------------------------------------------------- - protected inputElement = computed(() => this.overlayControl().host); - protected control = computed(() => this.overlayControl().state()); - protected isInvalid = computed(() => this.control().invalid()); - protected isDirty = computed(() => this.control().dirty()); - protected currentValue = computed(() => this.overlayControl().currentValue()); - protected warningMessage = computed(() => this.overlayControl().warningMessage()); - - /** - * Only open if the input is active AND it needs attention (dirty or invalid). - * Part of OverlayWidthSyncContext interface. - */ - isOpen = computed(() => { - const active = this.overlayControl().isOpen(); - const needsAttention = this.isDirty() || this.isInvalid(); - return active && needsAttention; - }); - - /** - * The element to measure width from. - * Part of OverlayWidthSyncContext interface. - */ - originElement = computed(() => this.wrapperRef.nativeElement); - - // --------------------------------------------------------------------------- - // Width measurement - // --------------------------------------------------------------------------- - - protected overlayConfig = computed( - (): CdkConnectedOverlayConfig => ({ - origin: this.wrapperRef, - panelClass: 'iusta-editable-panel', - width: this.width(), - positions: this.widthSync.overlayPositions(), - minWidth: '300px', - usePopover: 'inline', - }), - ); - - width = computed(() => this.widthSync.overlayWidth()); - - /** Expose overlay positions for child components that need it */ - overlayPositions = computed(() => this.widthSync.overlayPositions()); - - // --------------------------------------------------------------------------- - // Messages - // --------------------------------------------------------------------------- - protected errorMessage = computed(() => { - const errors = this.control().errors(); - return errors ? 'Invalid input' : null; - }); - - protected panelMessage = computed((): { text: string; kind: 'error' | 'hint' } | null => { - if (this.isInvalid()) { - return { text: this.errorMessage() ?? 'Invalid input', kind: 'error' }; - } - - if (this.isDirty()) { - return { text: 'You have unsaved changes', kind: 'hint' }; - } - - return null; - }); - - // --------------------------------------------------------------------------- - // Outputs - // --------------------------------------------------------------------------- - accepted = output(); - declined = output(); - detached = output(); - attached = output(); - copied = output(); - - // --------------------------------------------------------------------------- - // Actions - // --------------------------------------------------------------------------- - protected handleAccept() { - if (this.isInvalid()) return; - this.accepted.emit(); - } - - protected handleDecline() { - this.declined.emit(); - } - - protected handleDetach() { - this.detached.emit(); - } - - protected handleAttach() { - this.attached.emit(); - this.widthSync.updateOverlayPosition(); - } - - // --------------------------------------------------------------------------- - // Focus + close helpers - // --------------------------------------------------------------------------- - private focusOrigin() { - this.control().focusBoundControl(); - this.widthSync.updateOverlayPosition(); - } - - // --------------------------------------------------------------------------- - // Keyboard Navigation - // --------------------------------------------------------------------------- - - /** - * Handles outside clicks while the field has unsaved changes. - * Blocks switching to other editables, allows clicks inside the current one, - * declines the edit on clearly distant clicks, and otherwise keeps focus - * on the current field to prevent accidental data loss. - */ - protected handleOutsideClick(event: MouseEvent) { - if (!this.isDirty()) return; - - const originEl = this.wrapperRef.nativeElement; - const targetEl = event.target as HTMLElement | null; - if (!targetEl) return; - - // Dirty → distance gate (squared Euclidean) - const rect = originEl.getBoundingClientRect(); - const x = event.clientX; - const y = event.clientY; - - const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0; - const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0; - - const threshold = Math.ceil(window.innerHeight / 2); - const thresholdSq = threshold * threshold; - - // Far away → explicit decline - if (dx * dx + dy * dy > thresholdSq) { - this.handleDecline(); - return; - } - } - - protected handleBackdropClick() { - if (!this.isDirty()) return; - this.focusOrigin(); - } - - /* - * Handles tab key presses while the field has unsaved changes. - * Prevents leaving the field and instead moves focus into the overlay panel. - */ - protected handleTab(event: Event) { - if (!this.isDirty()) return; - - // Dirty: don't allow leaving; instead move focus into the overlay panel. - event.preventDefault(); - - queueMicrotask(() => { - const panelEl = this.panelRef()?.nativeElement; - if (!panelEl) return; - - const firstFocusable = panelEl.querySelector( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', - ); - - firstFocusable?.focus(); - }); - } - - updateOverlayPosition() { - this.widthSync.updateOverlayPosition(); - } -} diff --git a/projects/angular-inline-select/src/lib/styles/_editable-text.scss b/projects/angular-inline-select/src/lib/styles/_editable-text.scss new file mode 100644 index 0000000..32a9085 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-text.scss @@ -0,0 +1,171 @@ +// ============================================================================= +// editable-text-* — the inline TEXT component: in-flow display element and +// the elevated contenteditable editing surface. +// +// Theming contract — every visual property resolves in this order: +// var(--editable-text-, var(--mat-sys-, )) +// +// Design contract: +// - The in-flow display element NEVER changes size and, apart from the dashed +// underline, looks and flows exactly like the surrounding text. No grow +// rules, no measurement — layout shift is impossible by construction. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// HOST: +// ----------------------------------------------------------------------------- +.editable-text { + display: inline; +} + +// ----------------------------------------------------------------------------- +// ELEMENT: in-flow field area (prefix + display + suffix) +// ----------------------------------------------------------------------------- +.editable-text__field { + display: inline; + + transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + + // Dimmed as a whole — affixes included — under the scrim while editing. + .editable-text--editing & { + opacity: var(--editable-text-dim-opacity, 0.35); + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: affix (unit, icon — never part of the draft, aria-hidden) +// ----------------------------------------------------------------------------- +.editable-text__affix { + display: inline; + white-space: nowrap; + user-select: none; + + color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); + + &--prefix { + margin-inline-end: 0.25ch; + } + + &--suffix { + margin-inline-start: 0.25ch; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: in-flow display (static committed text) +// ----------------------------------------------------------------------------- +.editable-text__display { + display: inline; + white-space: pre-wrap; + overflow-wrap: anywhere; + cursor: text; + outline: none; + + font-family: inherit; + font-size: inherit; + color: inherit; + + // Per-line dashed underline that stops where the text stops (multiline-safe) + text-decoration-line: underline; + text-decoration-style: dashed; + text-decoration-thickness: 0.0625rem; + text-underline-offset: 0.4em; + text-decoration-color: var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); + + transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + + // Pristine focus affordance: solid underline, nothing moves. + &:focus-visible { + text-decoration-style: solid; + text-decoration-thickness: 0.125rem; + } + + &[contenteditable='false'] { + cursor: default; + } + + // Placeholder for empty content (no ::placeholder on contenteditable) + &:empty::before { + content: attr(data-placeholder); + font-style: italic; + opacity: var(--editable-text-placeholder-opacity, 0.3875); + } + + &--filled { + color: var(--editable-text-color, inherit); + } + + // Idle error state — the mat red-underline analogue: the field is invalid + // and the field says errors show (touched / save attempt). Color only — + // the dashed style stays, so the field still reads as inline-editable. + .editable-text--invalid & { + text-decoration-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); + } + + // The dashed affordance rests while the elevated editor is open — the + // whole field area dims via `.editable-text__field`. + .editable-text--editing & { + text-decoration-line: none; + } + + // --------------------------------------------------------------------------- + // MODIFIER: single-line — never wraps; ellipsizes when the container is + // narrower than the content (e.g. fixed-layout table cells). + // + // `overflow: clip` — never `hidden`: a scroll container (hidden/auto/scroll) + // moves an inline-block's baseline to its bottom margin edge, which lifts + // the text out of the surrounding line. `clip` keeps the natural text + // baseline, so the field sits in running copy exactly like normal + // characters. The clip margin keeps the offset dashed underline visible. + // --------------------------------------------------------------------------- + &--single-line { + display: inline-block; + min-width: 0; + max-width: 100%; + + white-space: nowrap; + overflow: clip; + overflow-clip-margin: 0.6em; + text-overflow: ellipsis; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: elevated editing surface (inside .editable-panel) +// ----------------------------------------------------------------------------- +.editable-text__editor { + display: block; + white-space: pre-wrap; + overflow-wrap: anywhere; + outline: none; + cursor: text; + + min-height: 1.5em; + max-height: 60vh; + overflow-y: auto; + + // M3 Typography: Body Large + font-weight: var(--mat-sys-body-large-weight, 400); + line-height: var(--mat-sys-body-large-line-height, 1.5); + letter-spacing: var(--mat-sys-body-large-tracking, 0.03125rem); + + font-family: inherit; + font-size: inherit; + color: var(--editable-text-editor-color, var(--mat-sys-on-surface, inherit)); + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + + &:empty::before { + content: attr(data-placeholder); + font-style: italic; + opacity: var(--editable-text-placeholder-opacity, 0.3875); + } +} + +// ----------------------------------------------------------------------------- +// Reduced motion +// ----------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .editable-text__display { + transition: none; + } +} diff --git a/projects/angular-inline-select/src/lib/styles/_editable.scss b/projects/angular-inline-select/src/lib/styles/_editable.scss new file mode 100644 index 0000000..237f05b --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable.scss @@ -0,0 +1,200 @@ +// ----------------------------------------------------------------------------- +// SCRIM: subtle backdrop while elevated (CDK applies the fade transition) +// ----------------------------------------------------------------------------- +.editable-scrim { + background: var(--editable-scrim-color, oklch(from var(--mat-sys-surface) l c h / 0.55)); +} + +// ----------------------------------------------------------------------------- +// PANEL: the elevated editor card +// ----------------------------------------------------------------------------- +.editable-panel { + // The readable measure — a constant. No measurement, no observers. + width: var( + --editable-panel-width, + min(60ch, calc(100dvw - 2 * var(--mat-sys-inner-spacing, 16px))) + ); + box-sizing: border-box; + + display: flex; + flex-direction: column; + gap: var(--mat-sys-form-field-gap, calc(var(--spacing, 0.25rem) * 2)); + + // Coupled to the connected-position offsets in angular-inline-text.ts, + // which resolve `--mat-sys-inner-spacing` at elevation time and cancel this + // padding so the panel's first text line sits optically on the origin text. + padding: calc(var(--mat-sys-inner-spacing, 16px) * 0.75) var(--mat-sys-inner-spacing, 16px); + + background: var(--editable-panel-background, var(--mat-sys-surface-container, #fff)); + border: 1px solid + var( + --editable-panel-border-color, + color-mix( + in oklch, + var(--mat-sys-on-surface, #000) 20%, + var(--mat-sys-surface-container, #fff) + ) + ); + border-radius: var(--editable-panel-radius, var(--mat-sys-corner-large, var(--radius, 0.625rem))); + + box-shadow: var( + --editable-panel-shadow, + 0.5px 0.5px 1px hsl(0deg 0% 0% / 0.05), + 1px 1px 2px hsl(0deg 0% 0% / 0.05), + 2px 2px 4px hsl(0deg 0% 0% / 0.05), + 4px 4px 6px hsl(0deg 0% 0% / 0.04) + ); +} + +// ----------------------------------------------------------------------------- +// ELEMENT: editor line (prefix + editor + suffix, editor takes the measure) +// ----------------------------------------------------------------------------- +.editable-panel__line { + display: flex; + align-items: baseline; + + .editable-text__editor { + flex: 1 1 auto; + min-width: 0; + } + + // In the panel the affixes sit beside the editor, not in running copy. + .editable-text__affix { + flex: 0 0 auto; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENTS: panel footer (messages + actions) +// ----------------------------------------------------------------------------- +.editable-panel__footer { + display: flex; + flex-direction: column; + gap: var(--mat-sys-form-field-gap, calc(var(--spacing, 0.25rem) * 2)); +} + +.editable-panel__message { + word-break: break-word; + + font-family: var(--mat-sys-body-small-font, inherit); + font-size: var(--mat-sys-body-small-size, 0.75rem); + font-weight: var(--mat-sys-body-small-weight, 400); + line-height: var(--mat-sys-body-small-line-height, 1rem); + letter-spacing: var(--mat-sys-body-small-tracking, 0.025rem); + + animation: editable-message-enter 0.2s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); + + &--error { + color: var(--editable-message-error-color, var(--mat-sys-error, #dc3545)); + } + + &--hint { + color: var(--editable-message-hint-color, var(--mat-sys-outline, #6b7280)); + } +} + +// ----------------------------------------------------------------------------- +// PROJECTED ERROR: parent-provided error/hint content (the mat-error +// analogue), projected into the panel footer via ``. +// ----------------------------------------------------------------------------- +.editable-panel [editable-error] { + display: block; + word-break: break-word; + + font-family: var(--editable-error-font, var(--mat-sys-body-small-font, inherit)); + font-size: var(--editable-error-size, var(--mat-sys-body-small-size, 0.75rem)); + font-weight: var(--editable-error-weight, var(--mat-sys-body-small-weight, 400)); + line-height: var(--editable-error-line-height, var(--mat-sys-body-small-line-height, 1rem)); + letter-spacing: var(--editable-error-tracking, var(--mat-sys-body-small-tracking, 0.025rem)); + + color: var(--editable-error-color, var(--mat-sys-error, #dc3545)); + + animation: editable-message-enter 0.2s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); + + // Empty when the parent's @if gates the content away — collapse entirely. + &:empty { + display: none; + } +} + +@keyframes editable-message-enter { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Action buttons are intentionally unstyled: they will become injectable +// templates. Only their placement is owned here — trailing, side by side. +.editable-panel__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--mat-sys-form-field-gap, calc(var(--spacing, 0.25rem) * 2)); +} + +// ----------------------------------------------------------------------------- +// ANIMATION: panel lift +// ----------------------------------------------------------------------------- +@keyframes editable-panel-lift { + 0% { + opacity: 0; + transform: scale(0.99); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +.editable-panel-enter { + transform-origin: top left; + animation: editable-panel-lift 0.15s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); + will-change: opacity, transform; +} + +// ----------------------------------------------------------------------------- +// BUBBLE: floating quick actions beside the field (CDK overlay container) +// ----------------------------------------------------------------------------- +.editable-bubble { + display: inline-flex; + align-items: center; + gap: calc(var(--mat-sys-inner-spacing, 16px) / 4); + padding: calc(var(--mat-sys-inner-spacing, 16px) / 4); + + background: var(--editable-bubble-background, var(--mat-sys-surface, #fff)); + border: 1px solid var(--editable-bubble-border-color, var(--mat-sys-outline-variant, #c4c7c5)); + border-radius: var( + --editable-bubble-radius, + var(--mat-sys-corner-small, calc(var(--radius, 0.625rem) * 0.6)) + ); + + animation: editable-bubble-enter 0.15s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); +} + +@keyframes editable-bubble-enter { + from { + opacity: 0; + transform: translateY(2px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// ----------------------------------------------------------------------------- +// Reduced motion +// ----------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .editable-panel-enter, + .editable-panel__message, + .editable-panel [editable-error], + .editable-bubble { + animation: none; + } +} diff --git a/projects/angular-inline-select/src/lib/styles/_index.scss b/projects/angular-inline-select/src/lib/styles/_index.scss new file mode 100644 index 0000000..b43ded6 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_index.scss @@ -0,0 +1,13 @@ +// ============================================================================= +// angular-inline-select — global styles entry point +// +// Rendered outside component encapsulation (panel, scrim and bubble live in +// the CDK overlay container), so consumers include this once, globally: +// +// @use '/src/lib/styles'; +// +// - editable → reusable chrome (panel, scrim, bubble), --editable-* tokens +// - editable-text → the text component surfaces, --editable-text-* tokens +// ============================================================================= +@use './editable'; +@use './editable-text'; diff --git a/projects/angular-inline-select/src/lib/styles/inline-text.scss b/projects/angular-inline-select/src/lib/styles/inline-text.scss deleted file mode 100644 index fbbebed..0000000 --- a/projects/angular-inline-select/src/lib/styles/inline-text.scss +++ /dev/null @@ -1,547 +0,0 @@ -// ============================================================================= -// angular-inline-text — shared (global) styles -// ============================================================================= -// These classes target elements that render outside component encapsulation -// (CDK overlay panels) or projected content, so they must be included globally: -// -// @use 'path/to/angular-inline-select/src/lib/styles/inline-text'; -// -// Requires the Angular Material system variables (--mat-sys-*) from mat.theme(). - -// ----------------------------------------------------------------------------- -// ELEMENT: The Input Field -// ----------------------------------------------------------------------------- -.iusta-editable { - border: none; - padding: 0; - margin: 0; - background-color: transparent; - text-decoration-color: transparent; - - outline: none; - position: relative; - - // content layer sits above ::after (-1) without magic "1" - z-index: 0; - - // Fallback width for browsers without field-sizing support - width: max-content; // best-effort auto width based on content (where supported) - field-sizing: content; // overrides where supported - min-width: 0; // keep it usable even if max-content behaves oddly - max-width: 100%; - - &:disabled { - cursor: default; - } - - // M3 Typography: Body Large - font-weight: var(--mat-sys-body-large-weight, 400); - line-height: var(--mat-sys-body-large-line-height, 1.5); - letter-spacing: var(--mat-sys-body-large-tracking, 0.03125rem); - - font-family: inherit; - font-size: inherit; - color: inherit; - - // --------------------------------------------------------------------------- - // Focus state: hide underline immediately (keep your focus sizing) - // --------------------------------------------------------------------------- - .iusta-editable-wrapper:focus-within &, - .iusta-editable-wrapper.is-editing & { - color: var(--mat-sys-on-surface); - caret-color: var(--mat-sys-primary); - - field-sizing: fixed; - width: 100%; - max-width: 100%; - - // Helps in flex rows so it can actually take available space - flex: 1 1 auto; - - transition: - filter var(--iusta-t-fast, 0.28s) var(--iusta-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)), - opacity var(--iusta-t-fast, 0.28s) var(--iusta-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); - } - - // --------------------------------------------------------------------------- - // Static Modifier Logic (Overrides field-sizing) - // --------------------------------------------------------------------------- - .iusta-editable-wrapper--static & { - width: 100%; - max-width: 100%; - field-sizing: fixed; - flex: 1 1 auto; - } - - // MODIFIER: Truncation - &--truncate { - display: block; - width: 100%; - field-sizing: fixed; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - // MODIFIER: Persistent (marker class for wrapper's :has() selector) - &--persistent { - // marker - } - - &--filled { - color: var(--iusta-editable-color, #428bca); - } - - &--empty { - --_iusta-editable-empty-opacity: 0.3875; - - color: inherit; - - &:disabled { - opacity: 1; - -webkit-text-fill-color: currentColor; - } - } - - &::placeholder { - font-style: italic; - opacity: var(--_iusta-editable-empty-opacity); - } -} - -@media (prefers-reduced-motion: reduce) { - .iusta-editable { - transition: none; - } -} - -// ----------------------------------------------------------------------------- -// BLOCK: Editable Wrapper (The Container) -// ----------------------------------------------------------------------------- -.iusta-editable-wrapper { - // Shared animation tokens (M3-ish) - --iusta-editable-editing-background: color-mix(in srgb, var(--iusta-editable-color, #428bca) 5%, transparent); - --iusta-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); - --iusta-ease-emphasized: cubic-bezier(0, 0, 0.2, 1); - --iusta-t-fast: 0.2s; - --iusta-t-slow: 0.32s; - - --iusta-editable-outline-inset-top-bottom: -0.5rem; - --iusta-editable-outline-inset-left-right: -0.75rem; - - --iusta-editable-focus-min: var(--iusta-editable-focus-min-value, 310px); - --iusta-editable-shortcut-optical-offset: -1px; - - position: relative; - display: inline-flex; - align-items: center; - border-radius: var(--iusta-editable-radius, 0.25rem); - - // Create local stacking context so ::after can sit behind content safely - isolation: isolate; - - transition: - width var(--iusta-t-slow) var(--iusta-ease-standard), - flex-grow var(--iusta-t-slow) var(--iusta-ease-standard); - - // --------------------------------------------------------------------------- - // SIZING LOGIC - // --------------------------------------------------------------------------- - - // 1. Dynamic Behavior (Default): content-sized unless static - &:not(&--static) { - width: auto; - min-width: 4ch; - max-width: 100%; - } - - // 2. Static Behavior (Modifier): always full width - &--static { - width: 100%; - flex: 1 1 auto; - } - - // 3. Active/Editing Behavior (Overrides everything) - &:focus-within, - &.is-editing { - // Always expand to the parent's available width (shell controls max) - width: 100%; - - // Lift wrapper cap while editing - max-inline-size: 100%; - - // Prefer at least 310px, but never overflow small parents - min-inline-size: min(var(--iusta-editable-focus-min), 100%); - - // Helps in flex rows so it can actually take available space - flex: 1 1 auto; - - // Focus/active transition: quicker - transition: - width var(--iusta-t-fast) var(--iusta-ease-emphasized), - flex-grow var(--iusta-t-fast) var(--iusta-ease-emphasized); - - z-index: 1001; // Match CDK overlay z-index - } - - /** - * BEM MODIFIER: Standard grow - */ - &--standard-grow { - &:focus-within { - width: 100%; - max-inline-size: 100%; - min-inline-size: min(var(--iusta-editable-focus-min), 100%); - flex: 1 1 auto; - - transition: - width var(--iusta-t-fast) var(--iusta-ease-emphasized), - flex-grow var(--iusta-t-fast) var(--iusta-ease-emphasized); - } - } - - // --------------------------------------------------------------------------- - // SHAPE: visual border/background (pseudo-element) - // --------------------------------------------------------------------------- - &::after { - content: ''; - position: absolute; - inset: var(--iusta-editable-outline-inset-top-bottom) var(--iusta-editable-outline-inset-left-right); - pointer-events: none; - - z-index: -1; - - border-bottom: 0.125rem solid var(--iusta-editable-outline-color, var(--iusta-editable-color, #428bca)); - border-radius: calc(var(--iusta-editable-radius, 0.25rem)) calc(var(--iusta-editable-radius, 0.25rem)) 0 0; - - background: var(--iusta-editable-editing-background); - } - - // --------------------------------------------------------------------------- - // DEFAULT BEHAVIOR: Fade in on focus (unless persistent) - // --------------------------------------------------------------------------- - &:not(:has(.iusta-editable--persistent)) { - &::after { - opacity: 0; - transition: none; - } - - &:focus-within::after, - &.is-editing::after { - opacity: 1; - transition: - opacity 0.18s ease, - border-color var(--iusta-t-fast) var(--iusta-ease-emphasized); - } - } - - // --------------------------------------------------------------------------- - // PERSISTENT BEHAVIOR: Always visible, color-only changes - // --------------------------------------------------------------------------- - &:has(.iusta-editable--persistent) { - &::after { - opacity: 1; - - transition: - border-color var(--iusta-t-fast) var(--iusta-ease-standard), - border-width var(--iusta-t-fast) var(--iusta-ease-standard), - background-color var(--iusta-t-fast) var(--iusta-ease-standard); - } - - &:focus-within::after, - &.is-editing::after { - transition: - border-color var(--iusta-t-fast) var(--iusta-ease-emphasized), - border-width var(--iusta-t-fast) var(--iusta-ease-emphasized), - background-color var(--iusta-t-fast) var(--iusta-ease-emphasized); - } - } - - // Shared focus/edit styles (apply to ALL variants) - &:focus-within::after, - &.is-editing::after { - border-bottom-color: var(--mat-sys-primary); - } - - // --------------------------------------------------------------------------- - // MODIFIER: Outline variant - // --------------------------------------------------------------------------- - &--outline { - &::after { - inset: -0.5rem -0.75rem; - border: 1px solid var(--iusta-editable-outline-color, var(--iusta-editable-color, #428bca)); - border-radius: 4px; - background: transparent; - } - - // Default outline: fade behavior (unless persistent) - &:not(:has(.iusta-editable--persistent)) { - &::after { - opacity: 0; - transition: none; - } - - &:focus-within::after, - &.is-editing::after { - opacity: 1; - - transition: - opacity var(--iusta-t-fast) var(--iusta-ease-emphasized), - border-color var(--iusta-t-fast) var(--iusta-ease-emphasized), - border-width var(--iusta-t-fast) var(--iusta-ease-emphasized); - } - } - - // Persistent outline: always visible, subtle default color - &:has(.iusta-editable--persistent) { - &::after { - opacity: 1; - border-color: var(--iusta-editable-outline-color, var(--mat-sys-outline-variant, #c4c7c5)); - - transition: - border-color var(--iusta-t-fast) var(--iusta-ease-standard), - border-width var(--iusta-t-fast) var(--iusta-ease-standard), - background-color var(--iusta-t-fast) var(--iusta-ease-standard); - } - } - - // Shared outline focus/edit styles - &:focus-within::after, - &.is-editing::after { - border-color: var(--mat-sys-primary); - background: var(--mat-sys-surface); - } - } - - &.invalid:focus-within::after { - background: var(--mat-sys-surface); - border-color: var(--mat-sys-error); - } - - // --------------------------------------------------------------------------- - // Shortcut action (clear value) - // --------------------------------------------------------------------------- - - &__shortcut { - align-self: center; - - // Default: hidden on desktop - opacity: 0; - visibility: hidden; - pointer-events: none; - - transition: - opacity 0.15s var(--iusta-ease-standard), - visibility 0.15s var(--iusta-ease-standard); - } - - // Reveal on intent (desktop + keyboard) - &:hover &__shortcut, - &:focus-within &__shortcut { - opacity: 1; - visibility: visible; - pointer-events: auto; - } - - // Touch / coarse pointer: always visible - @media (hover: none), (pointer: coarse) { - &__shortcut { - opacity: 1; - visibility: visible; - pointer-events: auto; - transition: none; - } - } -} - -@media (prefers-reduced-motion: reduce) { - .iusta-editable-wrapper { - transition: none; - } - - .iusta-editable-wrapper::after { - transition: none; - } -} - -// ----------------------------------------------------------------------------- -// ELEMENT: Clear button (replacement for the aria-grid icon button) -// ----------------------------------------------------------------------------- -.iusta-editable-clear { - appearance: none; - -webkit-appearance: none; - box-sizing: border-box; - - display: inline-grid; - place-items: center; - - width: 28px; - height: 28px; - padding: 0; - margin: 0; - - border: none; - border-radius: 6px; - background: transparent; - color: var(--mat-sys-error, #dc3545); - - cursor: pointer; - font: inherit; - line-height: 1; - -webkit-tap-highlight-color: transparent; - - &:hover, - &:focus-visible { - background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 10%, transparent); - } - - &:focus-visible { - outline: 1.5px solid var(--mat-sys-error, #dc3545); - outline-offset: 1px; - } -} - -// ----------------------------------------------------------------------------- -// BLOCK: The Main Overlay Panel content -// ----------------------------------------------------------------------------- -.editable-panel__inner { - display: flex; - flex-direction: column; - gap: 1rem; - - padding: 8px 12px; -} - -// ----------------------------------------------------------------------------- -// ELEMENT: Actions Row -// ----------------------------------------------------------------------------- -.editable-panel__inner-actions { - display: flex; - align-items: center; - justify-content: space-between; - - &--revert { - display: flex; - align-items: flex-start; - } - - &--accept { - display: flex; - align-items: center; - } -} - -// ----------------------------------------------------------------------------- -// ELEMENT: Shared Message (Error / Hint) -// ----------------------------------------------------------------------------- -.editable-panel__inner-message { - width: 100%; - word-break: break-word; - - font-family: var(--mat-sys-body-small-font, inherit); - font-size: var(--mat-sys-body-small-size, 0.75rem); - font-weight: var(--mat-sys-body-small-weight, 400); - line-height: var(--mat-sys-body-small-line-height, 1rem); - letter-spacing: var(--mat-sys-body-small-tracking, 0.025rem); - - margin-left: -0.15rem; - padding-left: 0; - - text-align: left; - - animation: message-enter 0.2s var(--iusta-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); - - &--error { - color: var(--mat-sys-error, #dc3545); - } - - &--warning { - color: var(--mat-sys-outline, #6b7280); // neutral warning tone - } -} - -@keyframes message-enter { - from { - opacity: 0; - transform: translateY(-2px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -// ----------------------------------------------------------------------------- -// BLOCK: Overlay panel + card (rendered in the CDK overlay container) -// ----------------------------------------------------------------------------- -.iusta-editable-panel { - background: transparent; - - // 1. Shared Colors - --_surface-color: var( - --iusta-sys-dropdown-background-color, - var(--mat-autocomplete-background-color, var(--mat-sys-surface-container, #fff)) - ); - --_border-color: color-mix(in srgb, var(--iusta-sys-on-surface, #000) 20%, var(--iusta-sys-surface-container, #fff)); - - // 2. Shadows - --_shadow-def: - 0.5px 0.5px 1px hsl(0deg 0% 0% / 0.05), 1px 1px 2px hsl(0deg 0% 0% / 0.05), 2px 2px 4px hsl(0deg 0% 0% / 0.05), - 4px 4px 6px hsl(0deg 0% 0% / 0.04); - - // 3. Animation Defaults (Downwards) - --anim-origin: top center; - --anim-start-y: -4px; - - // 4. Shape Defaults (Bottom Rounded) - --_radius: 0 0 var(--mat-sys-corner-large, 16px) var(--mat-sys-corner-large, 16px); - --_border-width: 0 1px 1px 1px; // Top Right Bottom Left - - /* TOP variant: Opens upwards */ - &.__top { - --anim-origin: bottom center; - --anim-start-y: 4px; - - // Rounded Top - --_radius: var(--mat-sys-corner-large, 16px) var(--mat-sys-corner-large, 16px) 0 0; - --_border-width: 1px 1px 0 1px; - } -} - -.iusta-editable-card { - width: 100%; - box-sizing: border-box; - - // Background - background-color: var(--_surface-color, var(--mat-sys-surface-container, #fff)); - - // Shadow - box-shadow: var(--_shadow-def); - - // Borders - border-style: solid; - border-color: var(--_border-color, var(--mat-sys-outline-variant, #c4c7c5)); - border-width: var(--_border-width, 1px); - border-radius: var(--_radius, 0.5rem); -} - -// ----------------------------------------------------------------------------- -// ANIMATION: overlay panel enter -// ----------------------------------------------------------------------------- -@keyframes iusta-dynamic-enter { - 0% { - opacity: 0; - transform: translate3d(0, var(--anim-start-y, 0), 0); - } - 100% { - opacity: 1; - transform: translate3d(0, 0, 0); - } -} - -.dropdown-animation-enter { - transform-origin: var(--anim-origin, top center); - - animation: iusta-dynamic-enter 0.15s cubic-bezier(0.86, 0, 0.14, 1); - will-change: opacity, transform; -} diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts index 1abbe6a..7380218 100644 --- a/projects/angular-inline-select/src/public-api.ts +++ b/projects/angular-inline-select/src/public-api.ts @@ -3,10 +3,7 @@ */ export * from './lib/angular-inline-text/angular-inline-text'; -export * from './lib/angular-inline-text/editable-wrapper/editable-wrapper'; -export * from './lib/angular-inline-text/editable-action-buttons/editable-action-buttons'; -export * from './lib/angular-inline-text/directives/editable-overlay-control'; -export * from './lib/angular-inline-text/directives/overlay-width-sync'; -export * from './lib/angular-inline-text/directives/textarea-autosize'; -export * from './lib/angular-inline-text/directives/restrict-characters/restrict-characters'; -export * from './lib/angular-inline-text/directives/restrict-characters/tokens'; +export * from './lib/angular-inline-text/editable-error'; +export * from './lib/angular-inline-text/editable-affix'; +export * from './lib/angular-inline-text/caret'; +export * from './lib/angular-inline-number/angular-inline-number'; diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html index 694309e..679d687 100644 --- a/projects/app/src/app/app.html +++ b/projects/app/src/app/app.html @@ -8,6 +8,11 @@ [normalizeValue]="true" /> + +
@@ -22,92 +27,4 @@
- - -
- Push left - - - - -
- -
-
-
-

Inline text in a paragraph

-

Single-line and multi-line editables embedded in running copy.

-
- -
-

- The - - project ships editable text right inside running copy — no form fields, no mode switches. The value below is a - multi-line editable that autosizes while you type: -

- - -
-
- -
-
-

Inline text in a table

-

100 rows, every name and note editable in place. The table scrolls inside the viewport.

-
- -
- - - - - - - - - - - - - - - - - - -
#{{ row.position }}Name - - Notes - -
-
-
-
+ diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts index dc39edb..aa69865 100644 --- a/projects/app/src/app/app.routes.ts +++ b/projects/app/src/app/app.routes.ts @@ -1,3 +1,15 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: 'text', + loadComponent: () => + import('./pages/text-playground/text-playground').then((m) => m.TextPlayground), + }, + { + path: 'number', + loadComponent: () => + import('./pages/number-playground/number-playground').then((m) => m.NumberPlayground), + }, + { path: '', pathMatch: 'full', redirectTo: 'text' }, +]; diff --git a/projects/app/src/app/app.scss b/projects/app/src/app/app.scss index 5a7935c..307f012 100644 --- a/projects/app/src/app/app.scss +++ b/projects/app/src/app/app.scss @@ -1,9 +1,5 @@ @use '@angular/material' as mat; -// The toolbar is ~64px high; the examples subtract it so each one -// fills the remaining viewport exactly. -$toolbar-height: 64px; - .toolbar { @include mat.toolbar-overrides( ( @@ -24,193 +20,22 @@ $toolbar-height: 64px; max-width: min(50ch, 50vw); } -.spacer { - flex: 1 1 auto; -} - -.toolbar-actions { - display: flex; - gap: 8px; -} - -/* --- Right floating nav --- */ - -.floating-nav { - position: fixed; - right: 16px; - top: 50%; - transform: translateY(-50%); - z-index: 20; - - display: flex; - flex-direction: column; - gap: 8px; - - a { - display: block; - padding: 8px 14px; - border-radius: 999px; - - background: var(--mat-sys-surface-container-high); - color: var(--mat-sys-on-surface); - border: 1px solid var(--mat-sys-outline-variant); - - font: var(--mat-sys-label-large); - text-decoration: none; - - transition: - background-color 0.15s ease, - color 0.15s ease; - - &:hover, - &:focus-visible { - background: var(--mat-sys-primary-container); - color: var(--mat-sys-on-primary-container); - } - } -} - -/* --- Examples: each fills the viewport --- */ - -main { - display: flex; - flex-direction: column; - - // Animate the manual push so the shift is gradual — ResizeObserver fires - // on every frame of the transition, not just once. - transition: margin-left 0.3s ease; - - // Continuous stress test: keeps the layout shifting while an editable is - // open, forcing constant re-measure + overlay reposition. - &.oscillate { - animation: push-left 2s ease-in-out infinite alternate; - } -} - -@keyframes push-left { - from { - margin-left: 0; - } - to { - margin-left: 320px; - } -} - -/* --- Layout shift tester (bottom left) --- */ - -.shift-controls { - position: fixed; - left: 16px; - bottom: 16px; - z-index: 20; - - display: flex; - align-items: center; - gap: 8px; - - padding: 8px 12px; - border-radius: 999px; - background: var(--mat-sys-surface-container-high); - border: 1px solid var(--mat-sys-outline-variant); - - &__label { - font: var(--mat-sys-label-large); - color: var(--mat-sys-on-surface-variant); - padding-right: 4px; - } -} - -.example { - // Fill exactly the viewport below the sticky toolbar - min-height: calc(100svh - #{$toolbar-height}); - box-sizing: border-box; - - // When jumping via # anchors, land below the sticky toolbar - scroll-margin-top: $toolbar-height; - - width: 100%; - max-width: 1100px; - margin: 0 auto; - padding: 24px 88px 24px 24px; // extra right padding clears the floating nav - +.toolbar-nav { display: flex; - flex-direction: column; - gap: 16px; -} + gap: 4px; + margin-left: 16px; -.example__header { - h2 { - font: var(--mat-sys-headline-medium); - margin: 0 0 4px; - } - - p { - color: var(--mat-sys-on-surface-variant); - margin: 0; + .toolbar-nav__active { + background: var(--mat-sys-secondary-container); + color: var(--mat-sys-on-secondary-container); } } -// The body stretches so the example really fills the viewport -.example__body { +.spacer { flex: 1 1 auto; - min-height: 0; } -/* --- Paragraph example --- */ - -.example-card { +.toolbar-actions { display: flex; - flex-direction: column; - justify-content: center; - gap: 1.5rem; - - padding: clamp(16px, 4vw, 48px); - border-radius: 0.75rem; - border: 1px solid var(--mat-sys-outline-variant); - background: var(--mat-sys-surface-bright); -} - -.prose { - font: var(--mat-sys-body-large); - max-width: 70ch; - margin: 0; -} - -.prose-block { - display: block; - max-width: 70ch; -} - -/* --- Table example: 100 rows, scrolls inside the viewport --- */ - -.table-scroll { - overflow: auto; - border: 1px solid var(--mat-sys-outline-variant); - border-radius: 0.75rem; - background: var(--mat-sys-surface-bright); -} - -.demo-table { - width: 100%; - - // Room for the editable's focus ring inside cells - td { - padding-top: 4px; - padding-bottom: 4px; - } -} - -/* --- Small screens: tuck the nav to the bottom right --- */ - -@media (max-width: 720px) { - .floating-nav { - top: auto; - bottom: 16px; - transform: none; - flex-direction: row; - } - - .example { - padding: 16px; - } + gap: 8px; } diff --git a/projects/app/src/app/app.spec.ts b/projects/app/src/app/app.spec.ts index 75753d6..784fb3f 100644 --- a/projects/app/src/app/app.spec.ts +++ b/projects/app/src/app/app.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [provideRouter([])], }).compileComponents(); }); diff --git a/projects/app/src/app/app.ts b/projects/app/src/app/app.ts index 81385cd..4153746 100644 --- a/projects/app/src/app/app.ts +++ b/projects/app/src/app/app.ts @@ -8,48 +8,37 @@ import { computed, } from '@angular/core'; import { DOCUMENT } from '@angular/common'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; // Material import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { MatTableModule } from '@angular/material/table'; import { MatDialog } from '@angular/material/dialog'; // Components import { AngularInlineText } from '../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; import { Login } from './login/login'; -export interface DemoRow { - position: number; - name: string; - notes: string; -} - -const SAMPLE_NAMES = [ - 'Aurora', - 'Borealis', - 'Cascade', - 'Drift', - 'Ember', - 'Flux', - 'Gossamer', - 'Halo', - 'Iris', - 'Junction', -]; - +/** + * The shell: sticky toolbar (editable title, page navigation, theme, login) + * around a router outlet. The playgrounds live in lazy pages. + */ @Component({ selector: '[app-root]', templateUrl: './app.html', changeDetection: ChangeDetectionStrategy.Eager, styleUrl: './app.scss', imports: [ + // Router + RouterOutlet, + RouterLink, + RouterLinkActive, + // Material MatToolbarModule, MatButtonModule, MatIconModule, - MatTableModule, // Components AngularInlineText, @@ -67,42 +56,13 @@ export class App { */ protected readonly title = signal('Inline Text Playground'); - // --------------------------------------------------------------------------- - // Paragraph example - // --------------------------------------------------------------------------- - protected projectName = signal('Aurora'); - protected summary = signal( - 'Click any highlighted text on this page and start typing. ' + - 'Save with Ctrl+Enter or the Save button, discard with Escape — ' + - 'the overlay only appears once you actually change something.', - ); - - // --------------------------------------------------------------------------- - // Table example (100 rows) - // --------------------------------------------------------------------------- - protected displayedColumns = ['position', 'name', 'notes']; - - protected rows: DemoRow[] = Array.from({ length: 100 }, (_, i) => ({ - position: i + 1, - name: `${SAMPLE_NAMES[i % SAMPLE_NAMES.length]} ${i + 1}`, - notes: `Editable notes for row ${i + 1}`, - })); - - // --------------------------------------------------------------------------- - // Layout shift tester - // --------------------------------------------------------------------------- - // Pushes the whole content area aside with a left margin to stress-test the - // ResizeObserver in OverlayWidthSyncDirective: the editable wrapper resizes, - // the overlay has to re-measure and reposition while open. - protected pushMargin = signal(0); - protected oscillate = signal(false); - // --------------------------------------------------------------------------- // Login // --------------------------------------------------------------------------- protected openLoginDialog() { const ref = this.#dialog.open(Login, { width: 'min(60ch, 100dvw)', + height: 'min(60dvh, 100dvw)', }); ref.afterClosed().subscribe((displayName?: string) => { diff --git a/projects/app/src/app/login/login.html b/projects/app/src/app/login/login.html index 29e5bd7..f614383 100644 --- a/projects/app/src/app/login/login.html +++ b/projects/app/src/app/login/login.html @@ -3,17 +3,22 @@

Sign In

Pick a display name — it becomes the toolbar title.

diff --git a/projects/app/src/app/pages/_demo.scss b/projects/app/src/app/pages/_demo.scss new file mode 100644 index 0000000..4f0b7ae --- /dev/null +++ b/projects/app/src/app/pages/_demo.scss @@ -0,0 +1,122 @@ +// ============================================================================= +// Shared demo-page scaffolding: example sections, cards, toggles, event log. +// @use'd by every playground page — the toolbar/shell styles stay in app.scss. +// ============================================================================= + +// The toolbar is ~64px high; the examples subtract it so each one +// fills the remaining viewport exactly. +$toolbar-height: 64px; + +.example { + // Fill exactly the viewport below the sticky toolbar + min-height: calc(100svh - #{$toolbar-height}); + box-sizing: border-box; + + // When jumping via # anchors, land below the sticky toolbar + scroll-margin-top: $toolbar-height; + + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 24px 88px 24px 24px; // extra right padding clears the floating nav + + display: flex; + flex-direction: column; + gap: 16px; +} + +.example__header { + h2 { + font: var(--mat-sys-headline-medium); + margin: 0 0 4px; + } + + p { + color: var(--mat-sys-on-surface-variant); + margin: 0; + } +} + +// The body stretches so the example really fills the viewport +.example__body { + flex: 1 1 auto; + min-height: 0; +} + +.example-stack { + display: flex; + flex-direction: column; + justify-content: center; + gap: 24px; +} + +.example-card { + display: flex; + flex-direction: column; + gap: 1.5rem; + + padding: clamp(16px, 4vw, 48px); + border-radius: 0.75rem; + border: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface-bright); + + // When jumping via # anchors, land below the sticky toolbar + scroll-margin-top: $toolbar-height; + + &__title { + font: var(--mat-sys-title-medium); + margin: 0; + } +} + +.form-toggles { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.form-toggles__hint { + font: var(--mat-sys-body-small); + color: var(--mat-sys-on-surface-variant); + margin: 0; +} + +// Emitted-events console: newest entry on top. +.event-log { + display: flex; + flex-direction: column; + gap: 4px; + + padding: 12px; + border-radius: 0.5rem; + border: 1px solid var(--mat-sys-outline-variant); + background: var(--mat-sys-surface-container); +} + +.event-log__entry { + font: var(--mat-sys-body-small); + font-family: monospace; + color: var(--mat-sys-on-surface-variant); + + // The newest emission is the one being inspected — make it pop. + &:first-child { + color: var(--mat-sys-on-surface); + } +} + +.prose { + font: var(--mat-sys-body-large); + max-width: 70ch; + margin: 0; +} + +.prose-block { + display: block; + max-width: 70ch; +} + +@media (max-width: 720px) { + .example { + padding: 16px; + } +} diff --git a/projects/app/src/app/pages/number-playground/number-playground.html b/projects/app/src/app/pages/number-playground/number-playground.html new file mode 100644 index 0000000..51f3f17 --- /dev/null +++ b/projects/app/src/app/pages/number-playground/number-playground.html @@ -0,0 +1,99 @@ +
+
+
+

Inline number

+

+ angular-inline-number composes the text control — no inheritance — and translates numbers at the + contract boundary through a swappable parse/format codec. +

+
+ +
+
+

Standalone [(value)]

+

+ The vessel carries + + crew members. The binding accepts a string or a number on the way in; every keystroke parses live and every + commit is a number — model: {{ crewCount() ?? '∅' }} ({{ crewCountType() }}). +

+
+ +
+

Price + suffix

+

+ Unit price: + + euro + + per crate. The codec formats two decimals, the editableSuffix template renders with the field + in both states — idle in the copy and inside the elevated editor — and is never part of the draft. The unit + lives in ariaLabel; the icon is decorative — model: {{ price() ?? '∅' }}. +

+
+ +
+

Signal form + numeric validation

+

+ Cargo tonnage: + + + @if (tonnage.parseFailed()) { + Enter a number — digits, optionally a dot for decimals. + } @else if (tonnageMissing()) { + A tonnage is required. + } @else if (tonnageOutOfRange()) { + Tonnage must be between 0 and 500. + } + + + tons. Schema: required, min(0), max(500) — validated live against the + parsed number while you type; the parse gate blocks drafts that aren't numbers at all — model: + {{ cargoModel().tonnage ?? '∅' }}. +

+ +
+ + + + + +
+ +

+ Try “600” (max), “-5” (min), “12abc” (parse gate — Save stays blocked), or clear while optional and toggle + Required back on for the idle error underline. Empty commits null, never a fake zero. +

+ + @if (emittedEvents().length > 0) { +
+ @for (entry of emittedEvents(); track $index) { + {{ entry }} + } +
+ } +
+
+
+
diff --git a/projects/app/src/app/pages/number-playground/number-playground.scss b/projects/app/src/app/pages/number-playground/number-playground.scss new file mode 100644 index 0000000..372784c --- /dev/null +++ b/projects/app/src/app/pages/number-playground/number-playground.scss @@ -0,0 +1,6 @@ +@use '../demo'; + +// Optical: inline mat-icon affixes sit on the text baseline, not raised. +mat-icon[inline] { + vertical-align: text-bottom; +} diff --git a/projects/app/src/app/pages/number-playground/number-playground.ts b/projects/app/src/app/pages/number-playground/number-playground.ts new file mode 100644 index 0000000..bacc9a5 --- /dev/null +++ b/projects/app/src/app/pages/number-playground/number-playground.ts @@ -0,0 +1,98 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, +} from '@angular/core'; +import { FormField, form, required, min, max, readonly, disabled } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +// Components +import { AngularInlineNumber } from '../../../../../angular-inline-select/src/lib/angular-inline-number/angular-inline-number'; +import { EditableSuffix } from '../../../../../angular-inline-select/src/lib/angular-inline-text/editable-affix'; + +@Component({ + selector: 'app-number-playground', + templateUrl: './number-playground.html', + styleUrl: './number-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + MatIconModule, + + // Forms + FormField, + + // Components + AngularInlineNumber, + EditableSuffix, + ], +}) +export class NumberPlayground { + // --------------------------------------------------------------------------- + // Standalone [(value)] example — strings coerce in, numbers come out + // --------------------------------------------------------------------------- + protected crewCount = signal(12); + + protected crewCountType = computed(() => + this.crewCount() === null ? 'null' : typeof this.crewCount(), + ); + + // --------------------------------------------------------------------------- + // Price example: codec formatting + suffix template + // --------------------------------------------------------------------------- + protected price = signal(49.9); + + protected priceFormat = (value: number | null): string => + value === null ? '' : value.toFixed(2); + + // --------------------------------------------------------------------------- + // Signal form example: numeric schema + field state toggles + // --------------------------------------------------------------------------- + protected fieldRequired = signal(true); + protected fieldReadonly = signal(false); + protected fieldDisabled = signal(false); + + protected cargoModel = signal<{ tonnage: number | null }>({ tonnage: 120 }); + + protected cargoForm = form(this.cargoModel, (path) => { + // The schema only decides validity — the error texts live in the + // projected [editable-error] content (the mat-error split). + required(path.tonnage, { when: () => this.fieldRequired() }); + min(path.tonnage, 0); + max(path.tonnage, 500); + + readonly(path.tonnage, { when: () => this.fieldReadonly() }); + disabled(path.tonnage, { when: () => this.fieldDisabled() }); + }); + + // The `hasError(...)` analogues — WHICH error the projected content + // describes; WHEN errors show is the field's job. The parse gate is the + // control's own signal (`#tonnage.parseFailed()` in the template) because + // the synthetic parse error never reaches the outer field. + protected tonnageMissing = computed(() => + this.cargoForm.tonnage().errors().some((error) => error.kind === 'required'), + ); + + protected tonnageOutOfRange = computed(() => + this.cargoForm + .tonnage() + .errors() + .some((error) => error.kind === 'min' || error.kind === 'max'), + ); + + // Event console: number-typed payloads, newest first. + protected emittedEvents = signal([]); + + protected logEmit(name: string, payload: unknown) { + this.emittedEvents.update((events) => + [`${name} → ${JSON.stringify(payload)}`, ...events].slice(0, 8), + ); + } +} diff --git a/projects/app/src/app/pages/text-playground/text-playground.html b/projects/app/src/app/pages/text-playground/text-playground.html new file mode 100644 index 0000000..4f4805d --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.html @@ -0,0 +1,162 @@ + + +
+ Push left + + + + +
+ +
+
+
+

Inline text in a paragraph

+

Single-line and multi-line editables embedded in running copy.

+
+ +
+
+

+ The + + project ships editable text right inside running copy — no form fields, no mode switches. The value below is + a multi-line editable that autosizes while you type: +

+ + + +
+ + +
+ +

+ Normalization trims edge whitespace on save — interior spaces and line breaks always survive. Add spaces + around the summary, save with both settings, and reset to try again. +

+
+ +
+

Signal form + validation

+

+ Vessel + + + @if (callsignMissing()) { + A callsign is required. + } @else if (callsignPatternBroken()) { + Callsigns look like “AUR-01” — two to four capital letters, a dash, then digits. + } + + + reports through a signal form. The error texts are projected via editable-error — the mat-error + split: the template decides what errors say, the field decides when they show. Validation follows the field + live while Discard rolls it back — model: + {{ vesselModel().callsign || '∅' }}. +

+ +
+ + + + + +
+ +

+ The clear bubble only appears on hover while the field is optional — required fields must not be emptied in + one click. Clearing while optional and then toggling Required back on shows the idle error underline; “Mark + touched” reveals errors with no interaction, and “Reset field” silently discards an open draft back to the + baseline. +

+ + @if (emittedEvents().length > 0) { +
+ @for (entry of emittedEvents(); track $index) { + {{ entry }} + } +
+ } +
+
+
+ +
+
+

Inline text in a table

+

+ 100 rows of mixed lengths, every name and note editable in place — long names ellipsize in + their fixed column, long notes wrap. The table scrolls inside the viewport. +

+
+ +
+ + + + + + + + + + + + + + + + + + +
#{{ row.position }}Name + + Notes + +
+
+
+
diff --git a/projects/app/src/app/pages/text-playground/text-playground.scss b/projects/app/src/app/pages/text-playground/text-playground.scss new file mode 100644 index 0000000..d0c5f8d --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.scss @@ -0,0 +1,130 @@ +@use '../demo'; + +/* --- Right floating nav --- */ + +.floating-nav { + position: fixed; + right: 16px; + top: 50%; + transform: translateY(-50%); + z-index: 20; + + display: flex; + flex-direction: column; + gap: 8px; + + a { + display: block; + padding: 8px 14px; + border-radius: 999px; + + background: var(--mat-sys-surface-container-high); + color: var(--mat-sys-on-surface); + border: 1px solid var(--mat-sys-outline-variant); + + font: var(--mat-sys-label-large); + text-decoration: none; + + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover, + &:focus-visible { + background: var(--mat-sys-primary-container); + color: var(--mat-sys-on-primary-container); + } + } +} + +/* --- Examples: each fills the viewport --- */ + +main { + display: flex; + flex-direction: column; + + // Animate the manual push so the shift is gradual — ResizeObserver fires + // on every frame of the transition, not just once. + transition: margin-left 0.3s ease; + + // Continuous stress test: keeps the layout shifting while an editable is + // open, forcing constant re-measure + overlay reposition. + &.oscillate { + animation: push-left 2s ease-in-out infinite alternate; + } +} + +@keyframes push-left { + from { + margin-left: 0; + } + to { + margin-left: 320px; + } +} + +/* --- Layout shift tester (bottom left) --- */ + +.shift-controls { + position: fixed; + left: 16px; + bottom: 16px; + z-index: 20; + + display: flex; + align-items: center; + gap: 8px; + + padding: 8px 12px; + border-radius: 999px; + background: var(--mat-sys-surface-container-high); + border: 1px solid var(--mat-sys-outline-variant); + + &__label { + font: var(--mat-sys-label-large); + color: var(--mat-sys-on-surface-variant); + padding-right: 4px; + } +} + +/* --- Table example: 100 rows, scrolls inside the viewport --- */ + +.table-scroll { + overflow: auto; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 0.75rem; + background: var(--mat-sys-surface-bright); +} + +.demo-table { + width: 100%; + + // Fixed layout: column widths never re-derive from content, so typing in an + // editable can't push columns around. Name ellipsizes, notes wrap in place. + table-layout: fixed; + + .mat-column-position { + width: 4rem; + } + + .mat-column-name { + width: 28%; + } + + // Room for the editable's focus ring inside cells + td { + padding-top: 4px; + padding-bottom: 4px; + } +} + +/* --- Small screens: tuck the nav to the bottom right --- */ + +@media (max-width: 720px) { + .floating-nav { + top: auto; + bottom: 16px; + transform: none; + flex-direction: row; + } +} diff --git a/projects/app/src/app/pages/text-playground/text-playground.ts b/projects/app/src/app/pages/text-playground/text-playground.ts new file mode 100644 index 0000000..b369515 --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.ts @@ -0,0 +1,151 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, +} from '@angular/core'; +import { FormField, form, required, pattern, disabled, readonly } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; + +// Components +import { AngularInlineText } from '../../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; + +export interface DemoRow { + position: number; + name: string; + notes: string; +} + +const INITIAL_PROJECT_NAME = 'Aurora'; +const INITIAL_SUMMARY = + 'Click any highlighted text on this page and start typing. ' + + 'Save with Ctrl+Enter or the Save button, discard with Escape — ' + + 'the overlay only appears once you actually change something.'; + +// Mixed lengths on purpose: short names sit naturally, long ones must +// ellipsize inside the fixed-width name column without pushing it. +const SAMPLE_NAMES = [ + 'Iris', + 'Aurora Borealis', + 'Halo', + 'Gossamer Drift Relay', + 'Ember', + 'Junction Point Observatory of the Western Rim', + 'Cascade', + 'Flux Capacitor Calibration and Maintenance Facility Northwest', + 'Drift', + 'The Extraordinarily Long Research Vessel Designation That Never Fits Anywhere', +]; + +// Mixed lengths on purpose: empty shows the placeholder, short ones stay on +// one line, long ones must wrap to several lines inside the notes column. +const SAMPLE_NOTES = [ + '', + 'Stable.', + 'Needs a follow-up during the next maintenance window.', + 'Recalibrated twice this cycle. The drift is within tolerance, but keep an eye on the secondary readings until the next full diagnostic.', + 'Long-form note to exercise wrapping: the array was realigned after the last storm season, power draw is nominal, and the relay handshake completes in under forty milliseconds. Crew rotation is scheduled for the third week, pending transport availability and weather on the pass.', +]; + +@Component({ + selector: 'app-text-playground', + templateUrl: './text-playground.html', + styleUrl: './text-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + MatTableModule, + + // Forms + FormField, + + // Components + AngularInlineText, + ], +}) +export class TextPlayground { + // --------------------------------------------------------------------------- + // Paragraph example + // --------------------------------------------------------------------------- + protected projectName = signal(INITIAL_PROJECT_NAME); + protected summary = signal(INITIAL_SUMMARY); + + // Normalization playground: toggle trimming on the multi-line summary and + // reset the copy to retry — commits trim edge whitespace only, interior + // spaces and line breaks always survive. + protected summaryNormalize = signal(true); + + protected resetParagraphExample() { + this.projectName.set(INITIAL_PROJECT_NAME); + this.summary.set(INITIAL_SUMMARY); + } + + // --------------------------------------------------------------------------- + // Signal form example: schema-driven validation + field state toggles + // --------------------------------------------------------------------------- + protected fieldRequired = signal(true); + protected fieldReadonly = signal(false); + protected fieldDisabled = signal(false); + + protected vesselModel = signal({ callsign: 'AUR-01' }); + + protected vesselForm = form(this.vesselModel, (path) => { + // The schema only decides validity — the error texts live in the + // projected [editable-error] content (the mat-error split). A field + // without projected content renders message-carrying errors itself. + required(path.callsign, { when: () => this.fieldRequired() }); + pattern(path.callsign, /^[A-Z]{2,4}-\d{1,3}$/); + + readonly(path.callsign, { when: () => this.fieldReadonly() }); + disabled(path.callsign, { when: () => this.fieldDisabled() }); + }); + + // The `hasError(...)` analogues: pick WHICH error the projected content + // describes. WHEN errors show (touched / save attempt) is the field's own + // job — no touched() check here. + protected callsignMissing = computed(() => + this.vesselForm.callsign().errors().some((error) => error.kind === 'required'), + ); + + protected callsignPatternBroken = computed(() => + this.vesselForm.callsign().errors().some((error) => error.kind === 'pattern'), + ); + + // Event console: everything the callsign field emits, newest first — makes + // the legacy outputs vs. the settled-session `saved` event comparable live. + protected emittedEvents = signal([]); + + protected logEmit(name: string, payload: unknown) { + this.emittedEvents.update((events) => + [`${name} → ${JSON.stringify(payload)}`, ...events].slice(0, 8), + ); + } + + // --------------------------------------------------------------------------- + // Table example (100 rows) + // --------------------------------------------------------------------------- + protected displayedColumns = ['position', 'name', 'notes']; + + // 10 × 5 pools with coprime-ish striding so name and note lengths combine + // in every variation across the 100 rows. + protected rows: DemoRow[] = Array.from({ length: 100 }, (_, i) => ({ + position: i + 1, + name: `${SAMPLE_NAMES[i % SAMPLE_NAMES.length]} ${i + 1}`, + notes: SAMPLE_NOTES[(i + Math.floor(i / 5)) % SAMPLE_NOTES.length], + })); + + // --------------------------------------------------------------------------- + // Layout shift tester + // --------------------------------------------------------------------------- + // Pushes the whole content area aside with a left margin to stress-test + // layout stability: the in-flow display text must move with the page while + // idle, and typing in the elevated editor must never shift the page. + protected pushMargin = signal(0); + protected oscillate = signal(false); +} diff --git a/projects/app/src/styles.scss b/projects/app/src/styles.scss index 8a3b669..5dceab2 100644 --- a/projects/app/src/styles.scss +++ b/projects/app/src/styles.scss @@ -3,9 +3,10 @@ // components according to the Material 3 design spec. @use '@angular/material' as mat; -// Shared (global) styles for angular-inline-text: editable field, wrapper and -// overlay panel classes that render outside component encapsulation. -@use '../../angular-inline-select/src/lib/styles/inline-text'; +// Shared (global) styles for angular-inline-select: editable chrome (panel, +// scrim, bubble) and the text component surfaces — they render in the CDK +// overlay container, outside component encapsulation. +@use '../../angular-inline-select/src/lib/styles'; html { height: 100%; From edb2f6dbab45d4b345a372f13b9f9c6529949b29 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Mon, 6 Jul 2026 14:45:49 +0200 Subject: [PATCH 03/48] feat(EditablePhone): added libphonejs for this --- ROADMAP.md | 183 ++++++++++++ angular.json | 3 +- package-lock.json | 7 + package.json | 1 + projects/angular-inline-select/package.json | 8 +- .../phone/ng-package.json | 5 + .../phone/src/angular-inline-phone.html | 34 +++ .../phone/src/angular-inline-phone.spec.ts | 202 +++++++++++++ .../phone/src/angular-inline-phone.ts | 270 ++++++++++++++++++ .../phone/src/libphonenumber-codec.spec.ts | 77 +++++ .../phone/src/libphonenumber-codec.ts | 110 +++++++ .../phone/src/phone-codec.ts | 70 +++++ .../phone/src/public-api.ts | 11 + .../angular-inline-number.html | 1 + .../angular-inline-number.spec.ts | 4 +- .../angular-inline-number.ts | 5 +- .../angular-inline-text.html | 9 + .../angular-inline-text.ts | 19 ++ .../lib/angular-inline-text/editable-hint.ts | 24 ++ .../angular-inline-select/src/public-api.ts | 1 + .../angular-inline-select/tsconfig.lib.json | 2 +- .../angular-inline-select/tsconfig.spec.json | 2 +- projects/app/src/app/app.html | 1 + projects/app/src/app/app.routes.ts | 5 + .../phone-playground/phone-playground.html | 92 ++++++ .../phone-playground/phone-playground.scss | 1 + .../phone-playground/phone-playground.ts | 74 +++++ tsconfig.json | 4 +- 28 files changed, 1218 insertions(+), 7 deletions(-) create mode 100644 projects/angular-inline-select/phone/ng-package.json create mode 100644 projects/angular-inline-select/phone/src/angular-inline-phone.html create mode 100644 projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts create mode 100644 projects/angular-inline-select/phone/src/angular-inline-phone.ts create mode 100644 projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts create mode 100644 projects/angular-inline-select/phone/src/libphonenumber-codec.ts create mode 100644 projects/angular-inline-select/phone/src/phone-codec.ts create mode 100644 projects/angular-inline-select/phone/src/public-api.ts create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-hint.ts create mode 100644 projects/app/src/app/pages/phone-playground/phone-playground.html create mode 100644 projects/app/src/app/pages/phone-playground/phone-playground.scss create mode 100644 projects/app/src/app/pages/phone-playground/phone-playground.ts diff --git a/ROADMAP.md b/ROADMAP.md index 9f17d07..436b264 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,6 +171,189 @@ forwarded by `angular-inline-number`: - Intl codec preset (locale grouping/decimal comma) shipped as an opt-in `parse`/`format` pair. +## Next up — `angular-inline-phone` + +### The core decision: own the UI, never the metadata + +Phone handling is two problems with opposite build-vs-buy answers: + +1. **The engine** (what is a valid number, how does it format): this is + Google's libphonenumber metadata — ~250 regions, updated continuously as + carriers change numbering plans. **Never hand-roll this.** Correctness is + a moving target that Google chases for us. +2. **The UI** (input surface, country affordance, error presentation): we + already own a better one than any widget ships. **Never import someone + else's DOM/CSS again.** + +`intl-tel-input` is rejected on architecture, not quality: it is a DOM+CSS +widget (the twice-broken CSS is structural — their markup IS their API), its +`utils.js` is a ~260 kB monolith you load whole, and every piece of UI it +offers (input, dropdown, flag sprite) is something our inline paradigm +replaces. What we actually want from that stack is the thing underneath it: +**`libphonenumber-js`** — the maintained, modular rewrite of Google's +library. + +### Tree-shaking strategy (three independent seams) + +1. **Secondary entry point.** The phone control and its adapter live in + `angular-inline-select/phone` (ng-packagr secondary entry point, the same + mechanism as `@angular/material/button`). Apps that never import it carry + zero phone bytes — the core library stays engine-free. +2. **Codec injection, again.** Like number's `parse`/`format`, the control + takes a `PhoneCodec` — a plain interface of functions (no OOP): + ```ts + interface PhoneCodec { + parse(raw: string, defaultCountry?: string): PhoneParseResult; + // e164 + country on success; a reason ('too-short' | 'too-long' | + // 'invalid-country' | 'not-a-number') on failure + format(e164: string, style: 'national' | 'international'): string; + placeholderExample?(country: string): string; + } + ``` + The control never imports libphonenumber-js; it consumes the codec. + `PhoneParseResult` carries the full interpretation, not just pass/fail: + `{ e164, country, dialCode, formatted }` on success plus a + `reason`/`warning` tier (see below) — the UI renders *what the engine + understood*, live. +3. **Metadata injection into the adapter.** `libphonenumber-js/core` exports + metadata-free functions; the metadata is an argument. Our adapter is a + factory: + ```ts + createLibphonenumberCodec(metadata) // consumer picks the payload + ``` + Consumers choose `libphonenumber-js/metadata.min.json` (~all countries, + validation-grade), `.max` (stricter type detection), `mobile`, or a + **custom subset built with the package's metadata generator CLI** + (`--countries DE,AT,CH` → a few kB). `libphonenumber-js` becomes an + optional peer dependency of the secondary entry point only. + +The flag/country affordance uses **flag emoji** (two regional-indicator code +points from the ISO country code) — zero sprites, zero CSS dependency, the +entire class of intl-tel-input breakage is structurally impossible. + +### Value contract + +- `value = model` holding **E.164** (`'+4917112345678'`) — + canonical, serializable, locale-free; empty commits `null` (same decision + as number). `saved`/`savedModelChange` always emit E.164 or `null`. +- Display formatting is presentation: `displayFormat` input + (`'national' | 'international'`), rendered through the codec on commit — + same round-trip principle as `'12.50'` → `12.5`. +- Detected country, national form, and parse reason are exposed as public + computeds (like `parseFailed` on number) for consumer error content and + UI, not stuffed into the value. + +### Phases + +**P1 — codec + adapter — shipped.** `PhoneCodec`/`PhoneParseResult` + +`countryFlagEmoji` in `angular-inline-select/phone` (secondary entry point; +`libphonenumber-js` is an optional peer dep). `createLibphonenumberCodec( +metadata, examples?)` over `libphonenumber-js/core`. Severity emerges from +parseability: readable-but-suspicious input parses with a `warning` +(committable), unreadable input fails with a `reason` (gated) — pinned +against the real engine (`'017'@DE` → E.164 `+49017` + `too-short` warning; +`'abc'` → `not-a-number`; national digits without country → +`invalid-country`). **Measured bundle cost:** the demo's `/phone` lazy chunk +— control + adapter + full min-metadata for every country — is ~173 kB raw / +**~36 kB transfer**, loaded only on that route; the number page's chunk stays +at ~3.8 kB (entry-point isolation proven). A custom country subset shrinks +it further. + +**P2 — `angular-inline-phone` — shipped** (as specified below, plus the +`editableHint` slot and generic `inputMode` input on `angular-inline-text`; +number now sends `inputmode="decimal"`, phone `"tel"`). Browser-verified: +`… abc` blocked with the projected parse message, `⚠ +49 017` committed +(warn-don't-block), `+33…` flips the flag to 🇫🇷 live, commits log E.164. +One discovery: the unit-test builder globs from `sourceRoot`, so the +secondary entry's specs need `"include": ["**/*.spec.ts", +"../phone/src/**/*.spec.ts"]` in the test target. + +**P2 — `angular-inline-phone` (composition MVP).** Same shape as number: +contains `angular-inline-text`, forwards the contract, retypes events to +E.164. `defaultCountry` input for national-format typing; `+CC` input +overrides it (parser detects). + +- **The live interpretation preview is the centerpiece** (production + lesson: "the user must SEE it — phone numbers are flimsy"). While the + session is open, a hint line in the panel footer shows what the engine + understood of the current draft, per keystroke: 🇩🇪 `+49` · "will save as + +49 171 1234567" — or the parse reason. This delivers as-you-type + *visibility* with zero caret rewriting: the draft is never touched, the + interpretation renders next to it. (Needs a small generic `editableHint` + slot on `angular-inline-text` — hint template rendered in the panel + footer; also future home for maxLength counters.) +- **Two-tier severity, warn-don't-block** (production lesson: the old + control shipped soft issues as warnings, never blocked them). Commit gate + = structurally impossible input only (`not-a-number`, `invalid-country`); + soft findings (`too-short`/`too-long`/`possible-local-only`) surface as a + warning in the preview line and via a public signal, but commit stays + allowed; business strictness (`isValid`, mobile-only) ships as + signal-forms validators for the consumer's schema. +- **Flag emoji as detection feedback, not decoration**: the built-in prefix + shows the *detected* country (falling back to `defaultCountry`), updating + live — its job is deciphering `+49` vs `+21` at a glance, idle and while + editing. No picker in the MVP. +- **Example-number placeholders**: `numberType` input + (`'mobile' | 'fixed-or-mobile'`) feeds `codec.placeholderExample()` — + the placeholder shows a real example for the default country. +- `inputmode="tel"` via the new generic attr input on the text control + (pulled forward from N3). +- **No live reformatting of the draft** — validate live, preview live, + format on commit (round-trip through the codec, like `'12.50'` → `12.5`). + Confirmed by production: the old control ran `formatOnDisplay: false` for + the same reason. + +**P3 — as-you-type formatting (hard, separate).** libphonenumber's +`AsYouType` inserts separators while typing — which rewrites the draft under +the caret, the exact thing our architecture forbids. Needs caret-preserving +reformat math (map caret through inserted separators). Only attempt with a +dedicated spec suite; the control must stay correct without it. + +**P4 — country picker.** An interactive prefix (flag + dial code) opening a +country list in the panel. This is inline-select territory — a natural +trigger for extracting the `createEditSession()` primitives. Not before. + +**Demo:** `/phone` page — `defaultCountry="DE"` field, E.164 model display, +per-reason projected errors, event console, and a bundle-size note comparing +min vs custom metadata. + +### Production lessons absorbed (from the previous intl-tel-input control) + +- E.164 out on accept (`getNumber(0)`) — unchanged, already the contract. +- Numeric validation-error table (`TOO_SHORT`, `INVALID_COUNTRY_CODE`, + `IS_POSSIBLE_LOCAL_ONLY`, …) surfaced as *warnings*, never commit + blockers → the two-tier severity design above. +- `formatOnDisplay: false` in production → confirms MVP skips draft + reformatting. +- `placeholderNumberType` driven by an `isMobilePhone` input → the + `numberType` + example-placeholder feature. +- `dialCode` exposed as a model + data attribute → public + `country`/`dialCode` computeds and the flag-as-feedback prefix. +- The flag-hell that disappears by being signal-native end-to-end: no + `#isUpdatingFromControl`/`#isUpdatingFromInput` circular-update guards, no + `#didInitialSync` + `queueMicrotask` + `requestAnimationFrame` double + reset, no "parent must seed `previous`" workaround (the derived + `previous` baseline covers it), no `afterRenderEffect` init/destroy + lifecycle for a foreign widget. That entire class of code existed to + bridge an imperative DOM library into signals; composing our own control + makes it unrepresentable. + +### Open questions (brainstorm) + +1. **Extensions** (`x123`) — E.164 doesn't carry them; libphonenumber does + (`ext` field). Support in v1 or explicitly out of scope? (Decides the + value shape — breaking to change later.) +2. **Warning presentation**: does the warning tier stay phone-internal + (rendered in its preview line) or does `angular-inline-text` grow a + first-class warning slot next to errors? Leaning: keep it in the preview + line until a second control needs warnings. +3. **Who owns the default codec instance** — DI token with + `providePhoneCodec(...)` app-wide vs per-instance input? Proposal: input + with DI fallback, like mat's ErrorStateMatcher. +4. **Idle flag**: show the flag prefix on the committed display too, or + only while editing? Leaning: idle too — deciphering `+49` at a glance is + exactly the idle use case. + ### Manual QA — Safari / iOS pass The `plaintext-only` probe falls back to `contenteditable="true"` + manual diff --git a/angular.json b/angular.json index 7c4f85a..88c070f 100644 --- a/angular.json +++ b/angular.json @@ -93,7 +93,8 @@ "test": { "builder": "@angular/build:unit-test", "options": { - "tsConfig": "projects/angular-inline-select/tsconfig.spec.json" + "tsConfig": "projects/angular-inline-select/tsconfig.spec.json", + "include": ["**/*.spec.ts", "../phone/src/**/*.spec.ts"] } } } diff --git a/package-lock.json b/package-lock.json index 33e87f0..a6b6847 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", + "libphonenumber-js": "^1.13.8", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -7500,6 +7501,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.8.tgz", + "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", + "license": "MIT" + }, "node_modules/listr2": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", diff --git a/package.json b/package.json index a967117..55e9bf3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", + "libphonenumber-js": "^1.13.8", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/projects/angular-inline-select/package.json b/projects/angular-inline-select/package.json index 80a903a..20430cc 100644 --- a/projects/angular-inline-select/package.json +++ b/projects/angular-inline-select/package.json @@ -6,7 +6,13 @@ "@angular/core": "^22.0.3", "@angular/forms": "^22.0.3", "@angular/cdk": "^22.0.2", - "@angular/material": "^22.0.2" + "@angular/material": "^22.0.2", + "libphonenumber-js": "^1.13.0" + }, + "peerDependenciesMeta": { + "libphonenumber-js": { + "optional": true + } }, "dependencies": { "tslib": "^2.3.0" diff --git a/projects/angular-inline-select/phone/ng-package.json b/projects/angular-inline-select/phone/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/angular-inline-select/phone/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.html b/projects/angular-inline-select/phone/src/angular-inline-phone.html new file mode 100644 index 0000000..9877cff --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.html @@ -0,0 +1,34 @@ + +{{ flag() }} + + +{{ preview() }} + + + + + diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts new file mode 100644 index 0000000..106f7c8 --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts @@ -0,0 +1,202 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form, required } from '@angular/forms/signals'; + +import metadata from 'libphonenumber-js/metadata.min.json'; +import examples from 'libphonenumber-js/examples.mobile.json'; + +import { AngularInlineText } from 'angular-inline-select'; + +import { AngularInlinePhone, type InlinePhoneSaved } from './angular-inline-phone'; +import { createLibphonenumberCodec } from './libphonenumber-codec'; + +const codec = createLibphonenumberCodec(metadata, examples); + +// ============================================================================= +// Hosts +// ============================================================================= + +@Component({ + imports: [AngularInlinePhone], + template: ` + + `, +}) +class PhoneValueHost { + codec = codec; + value = signal('+491712345678'); + + saved: (string | null)[] = []; + sessions: InlinePhoneSaved[] = []; + touchCount = 0; +} + +@Component({ + imports: [AngularInlinePhone, FormField], + template: ``, +}) +class PhoneFormHost { + codec = codec; + model = signal(null); + field = form(this.model, (path) => { + required(path); + }); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +interface Harness { + fixture: ComponentFixture; + host: T; + display: () => HTMLElement; + editor: () => HTMLElement | null; + inner: () => AngularInlineText; + phone: () => AngularInlinePhone; +} + +function setup(hostType: new () => T): Harness { + const fixture = TestBed.createComponent(hostType); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + display: () => fixture.nativeElement.querySelector('.editable-text__display') as HTMLElement, + editor: () => document.querySelector('.editable-text__editor') as HTMLElement | null, + inner: () => + fixture.debugElement.children[0].children[0].componentInstance as AngularInlineText, + phone: () => fixture.debugElement.children[0].componentInstance as AngularInlinePhone, + }; +} + +async function typeText(h: Harness, text: string) { + const display = h.display(); + + const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; + Object.defineProperty(event, 'inputType', { value: 'insertText' }); + Object.defineProperty(event, 'data', { value: 'x' }); + + display.dispatchEvent(event); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + const editor = h.editor(); + if (!editor) throw new Error('elevated editor not found'); + + editor.textContent = text; + editor.dispatchEvent(new Event('input', { bubbles: true })); + h.fixture.detectChanges(); +} + +function accept(h: Harness) { + (h.inner() as unknown as { accept(): void }).accept(); + h.fixture.detectChanges(); +} + +// ============================================================================= +// Specs +// ============================================================================= + +describe('AngularInlinePhone — [(value)] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(PhoneValueHost); + }); + + it('renders the committed value formatted (international by default)', () => { + expect(h.display().textContent).toBe('+49 171 2345678'); + }); + + it('shows the detected country flag as the prefix', () => { + const prefix = h.fixture.nativeElement.querySelector( + '.editable-text__affix--prefix', + ) as HTMLElement | null; + expect(prefix?.textContent?.trim()).toBe('🇩🇪'); + }); + + it('commits national input as E.164 through the codec round-trip', async () => { + await typeText(h, '0170 9876543'); + accept(h); + + expect(h.host.value()).toBe('+491709876543'); + expect(h.host.saved).toEqual(['+491709876543']); + expect(h.display().textContent).toBe('+49 170 9876543'); + }); + + it('the parse gate blocks structurally unreadable drafts', async () => { + await typeText(h, 'not a phone'); + accept(h); + + expect(h.inner().editing()).toBe(true); + expect(h.host.saved).toEqual([]); + expect(h.host.value()).toBe('+491712345678'); // last good value held + }); + + it('suspicious-but-readable drafts commit with a warning (warn, do not block)', async () => { + await typeText(h, '017'); + + expect(h.phone().parseWarning()).toBe('too-short'); + expect(h.phone().parseFailed()).toBe(false); + + accept(h); + + expect(h.inner().editing()).toBe(false); + expect(h.host.saved).toEqual(['+49017']); + expect(h.host.sessions).toEqual([{ value: '+49017', changed: true }]); + }); + + it('an empty draft commits null', async () => { + await typeText(h, ''); + accept(h); + + expect(h.host.value()).toBeNull(); + expect(h.host.sessions).toEqual([{ value: null, changed: true }]); + }); + + it('the live preview interprets the draft without touching it', async () => { + const hintText = () => + document.querySelector('.editable-panel__message--hint')?.textContent?.trim(); + + // Unreadable: raw draft untouched, preview shows the … marker + await typeText(h, 'abc'); + expect(h.editor()?.textContent).toBe('abc'); + expect(hintText()).toBe('… abc'); + + // Readable but suspicious: ⚠ marker, still committable + await typeText(h, '0171 23456789012345'); + expect(hintText()?.startsWith('⚠')).toBe(true); + + // Valid: ✓ + the international reading + await typeText(h, '0171 2345678'); + expect(hintText()).toBe('✓ +49 171 2345678'); + }); +}); + +describe('AngularInlinePhone — signal form [formField] binding', () => { + let h: Harness; + + beforeEach(() => { + h = setup(PhoneFormHost); + }); + + it('uses an example-number placeholder for the default country', () => { + expect(h.display().getAttribute('data-placeholder')).toBe('01512 3456789'); + }); + + it('propagates the parsed E.164 live into the field', async () => { + await typeText(h, '0171 2345678'); + + expect(h.host.field().value()).toBe('+491712345678'); + }); +}); diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.ts new file mode 100644 index 0000000..a83e1e8 --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.ts @@ -0,0 +1,270 @@ +import { + Component, + TemplateRef, + input, + model, + output, + computed, + signal, + linkedSignal, + viewChild, + contentChild, +} from '@angular/core'; +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; + +import { + AngularInlineText, + EditablePrefix, + EditableSuffix, + type InlineTextSaved, +} from 'angular-inline-select'; + +import { + countryFlagEmoji, + type PhoneCodec, + type PhoneCountry, + type PhoneNumberKind, + type PhoneParseWarning, +} from './phone-codec'; + +/** Payload of the `saved` output: one emission per settled edit session. */ +export interface InlinePhoneSaved { + /** The value the session settled on — E.164, or `null` for empty. */ + value: string | null; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * Inline phone: a `FormValueControl` for phone numbers that COMPOSES the + * inline text control — no inheritance, no third-party DOM/CSS. The engine + * is an injected {@link PhoneCodec}; the canonical value is E.164. + * + * - **The live interpretation preview is the point**: while editing, the + * panel hint shows what the engine understood of the draft on every + * keystroke (flag · formatted number · validity marker). The draft itself + * is never reformatted — no caret fights, ever. + * - **Two-tier severity**: structurally unreadable input (`not-a-number`, + * `invalid-country`, hopeless length) blocks the commit through the usual + * parse gate; suspicious-but-readable numbers (`too-short`, `too-long`, + * `unrecognized`) commit fine and surface as a ⚠ in the preview and via + * the public `parseWarning` signal. + * - **Flag emoji as detection feedback**: the built-in prefix shows the + * detected (or default) country so `+49` vs `+21` reads at a glance — + * idle and while editing. A consumer `editablePrefix` overrides it. + */ +@Component({ + selector: 'angular-inline-phone', + imports: [AngularInlineText], + templateUrl: './angular-inline-phone.html', + styles: ':host { display: inline; }', + host: { + '[style.display]': 'hidden() ? "none" : null', + }, +}) +export class AngularInlinePhone implements FormValueControl { + /** The composed text control — all session machinery lives there. */ + protected inner = viewChild.required(AngularInlineText); + + /** + * The committed value channel. Accepts any parseable phone string for + * binding convenience; the component only ever writes E.164 or `null`. + */ + value = model(null); + + /** The engine. See `createLibphonenumberCodec` for the shipped adapter. */ + codec = input.required(); + + /** Country assumed for national-format input; `+CC` input overrides it. */ + defaultCountry = input(undefined); + + /** How the committed value renders while idle. */ + displayFormat = input<'national' | 'international'>('international'); + + /** Feeds the example-number placeholder. */ + numberKind = input('fixed-or-mobile'); + + /** The country-detection prefix. Off, or overridden by `editablePrefix` content. */ + showFlag = input(true); + + /** Form Value Contract — forwarded into the inner control. */ + errors = input([]); + disabled = input(false); + readonly = input(false); + required = input(false); + touched = input(false); + invalid = input(false); + hidden = input(false); + + /** Placeholder override; defaults to an example number for `defaultCountry`. */ + placeholder = input(undefined); + + /** Accessible name — put the expected country/format here, AT never hears the flag. */ + ariaLabel = input(undefined); + + /** Consumer affix channel (a projected `editablePrefix` beats the flag). */ + prefixTemplate = input | undefined>(undefined); + suffixTemplate = input | undefined>(undefined); + + private contentPrefix = contentChild(EditablePrefix); + private contentSuffix = contentChild(EditableSuffix); + + protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); + protected consumerPrefixTpl = computed( + () => this.prefixTemplate() ?? this.contentPrefix()?.templateRef, + ); + + /** Form Value Contract: touch — forwarded from the inner control. */ + touch = output(); + + /** + * Hard commit event: fires once per accepted edit session — always E.164 + * or `null`, never raw input. + * + * Roadmap Phase 3: superseded by `saved` — kept during the transition. + */ + savedModelChange = output(); + + /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + saved = output(); + + /** The canonical (E.164) reading of the model. */ + protected canonical = computed(() => { + const value = this.value(); + if (value === null || value === undefined || value === '') return null; + + const result = this.codec().parse(value, this.defaultCountry()); + return result?.ok ? result.e164 : value; + }); + + /** Two-way `editing` bridge — freezes the string channel during a session. */ + protected innerEditing = signal(false); + + /** + * The string channel feeding the inner control: the formatted committed + * value while idle, the raw draft while a session is open. Commits + * round-trip the codec — `'01712345678'` settles as `'+49 171 2345678'`. + */ + protected innerValue = linkedSignal({ + source: () => { + const canonical = this.canonical(); + if (canonical === null) return ''; + + return this.codec().format(canonical, this.displayFormat(), this.defaultCountry()); + }, + computation: (source, prev) => (this.innerEditing() ? (prev?.value ?? source) : source), + }); + + /** The engine's live interpretation of the current draft. Public — consumers render from it. */ + readonly parseResult = computed(() => this.codec().parse(this.innerValue(), this.defaultCountry())); + + /** The parse gate: structurally unreadable input cannot commit. */ + readonly parseFailed = computed(() => this.parseResult()?.ok === false); + + /** Soft finding on a committable draft (`too-short`, `unrecognized`, …). */ + readonly parseWarning = computed(() => { + const result = this.parseResult(); + return result?.ok ? (result.warning ?? null) : null; + }); + + /** Detected country of the current draft/value, falling back to `defaultCountry`. */ + readonly country = computed(() => { + const result = this.parseResult(); + return (result?.ok ? result.country : undefined) ?? this.defaultCountry(); + }); + + /** Country calling code without `+`, e.g. `'49'`. */ + readonly dialCode = computed(() => { + const result = this.parseResult(); + return result?.ok ? result.dialCode : undefined; + }); + + protected flag = computed(() => { + const country = this.country(); + return this.showFlag() && country ? countryFlagEmoji(country) : ''; + }); + + /** + * The interpretation preview, rebuilt per keystroke and rendered in the + * panel hint: language-neutral (flag, digits, ✓/⚠/… markers) so the + * library ships no words to translate. + */ + protected preview = computed(() => { + const raw = this.innerValue().trim(); + if (!raw) return ''; + + const result = this.parseResult(); + if (result?.ok) { + const marker = result.warning ? '⚠' : '✓'; + return `${marker} ${result.international}`; + } + + // `||`: an empty pretty-print (e.g. no digits at all) falls back to the raw draft + const incomplete = this.codec().formatIncomplete?.(raw, this.defaultCountry()) || raw; + return `… ${incomplete}`; + }); + + /** + * Errors forwarded to the inner control: the contract errors plus a + * synthetic message-less `{ kind: 'parse' }` while the draft is + * structurally unreadable — the inner accept guard and error slot do the + * rest. Warnings deliberately stay out of here: they never block. + */ + protected innerErrors = computed(() => + this.parseFailed() ? [...this.errors(), { kind: 'parse' }] : this.errors(), + ); + + protected effectivePlaceholder = computed(() => { + const placeholder = this.placeholder(); + if (placeholder !== undefined) return placeholder; + + const country = this.defaultCountry(); + const example = country + ? this.codec().placeholderExample?.(country, this.numberKind()) + : undefined; + + return example ?? 'phone'; + }); + + /** + * Live channel: every keystroke parses. Readable drafts flow into the + * model as E.164 (schema validators see the canonical value mid-draft), + * unreadable ones hold the last good value and raise the parse gate. + */ + protected handleInnerValue(raw: string) { + this.innerValue.set(raw); + + const result = this.codec().parse(raw, this.defaultCountry()); + if (result === null) { + if (this.canonical() !== null) this.value.set(null); + return; + } + + if (result.ok && result.e164 !== this.canonical()) this.value.set(result.e164); + } + + /** Retype the settled session: raw strings inside, E.164 outside. */ + protected handleInnerSaved(session: InlineTextSaved) { + const result = this.codec().parse(session.value, this.defaultCountry()); + // The parse gate blocks unreadable commits; the fallback covers discards + // rolling back to a baseline the current codec cannot read. + const value = result === null ? null : result.ok ? result.e164 : this.canonical(); + + if (session.changed) { + this.value.set(value); + this.savedModelChange.emit(value); + } + + this.saved.emit({ value, changed: session.changed }); + } + + /** Form Value Contract: focus — delegates to the inner control. */ + focus(options?: FocusOptions) { + this.inner().focus(options); + } + + /** Form Value Contract: reset — delegates to the inner control. */ + reset() { + this.inner().reset(); + } +} diff --git a/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts b/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts new file mode 100644 index 0000000..d85ba29 --- /dev/null +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts @@ -0,0 +1,77 @@ +import metadata from 'libphonenumber-js/metadata.min.json'; +import examples from 'libphonenumber-js/examples.mobile.json'; + +import { createLibphonenumberCodec } from './libphonenumber-codec'; +import { countryFlagEmoji } from './phone-codec'; + +const codec = createLibphonenumberCodec(metadata, examples); + +describe('createLibphonenumberCodec', () => { + it('parses a national number against the default country', () => { + const result = codec.parse('0171 2345678', 'DE'); + + expect(result).toEqual({ + ok: true, + e164: '+491712345678', + country: 'DE', + dialCode: '49', + national: '0171 2345678', + international: '+49 171 2345678', + }); + }); + + it('detects the country from +CC input, overriding the default', () => { + const result = codec.parse('+33 1 42 68 53 00', 'DE'); + + expect(result?.ok).toBe(true); + if (result?.ok) expect(result.country).toBe('FR'); + }); + + it('empty input is null, not an error', () => { + expect(codec.parse('', 'DE')).toBeNull(); + expect(codec.parse(' ', 'DE')).toBeNull(); + }); + + it('structurally unreadable input fails with a reason (the commit gate)', () => { + expect(codec.parse('abc', 'DE')).toEqual({ ok: false, reason: 'not-a-number' }); + // National digits without any country context + expect(codec.parse('0171 2345678')).toEqual({ ok: false, reason: 'invalid-country' }); + expect(codec.parse('+999 123456')).toEqual({ ok: false, reason: 'invalid-country' }); + }); + + it('suspicious-but-readable numbers parse with a warning (warn, do not block)', () => { + const short = codec.parse('017', 'DE'); + expect(short?.ok).toBe(true); + if (short?.ok) { + expect(short.warning).toBe('too-short'); + expect(short.e164).toBe('+49017'); + } + + const long = codec.parse('0171 23456789012345', 'DE'); + expect(long?.ok).toBe(true); + if (long?.ok) expect(long.warning).toBe('too-long'); + }); + + it('formats E.164 for display in both styles', () => { + expect(codec.format('+491712345678', 'international')).toBe('+49 171 2345678'); + expect(codec.format('+491712345678', 'national')).toBe('0171 2345678'); + // Unreadable input passes through unchanged + expect(codec.format('garbage', 'international')).toBe('garbage'); + }); + + it('pretty-prints incomplete drafts for the preview', () => { + expect(codec.formatIncomplete?.('01712', 'DE')).toBe('0171 2'); + }); + + it('provides example-number placeholders', () => { + expect(codec.placeholderExample?.('DE', 'mobile')).toBe('01512 3456789'); + }); +}); + +describe('countryFlagEmoji', () => { + it('builds regional-indicator pairs from ISO codes', () => { + expect(countryFlagEmoji('DE')).toBe('🇩🇪'); + expect(countryFlagEmoji('fr')).toBe('🇫🇷'); + expect(countryFlagEmoji('001')).toBe(''); + }); +}); diff --git a/projects/angular-inline-select/phone/src/libphonenumber-codec.ts b/projects/angular-inline-select/phone/src/libphonenumber-codec.ts new file mode 100644 index 0000000..4b2ac80 --- /dev/null +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.ts @@ -0,0 +1,110 @@ +import { + parsePhoneNumberFromString, + validatePhoneNumberLength, + formatIncompletePhoneNumber, + getExampleNumber, + type MetadataJson, + type Examples, + type CountryCode, +} from 'libphonenumber-js/core'; + +import type { + PhoneCodec, + PhoneCountry, + PhoneParseResult, + PhoneParseWarning, +} from './phone-codec'; + +/** + * `PhoneCodec` over `libphonenumber-js/core` — the metadata-free build; the + * metadata payload is YOUR choice and the tree-shaking lever: + * + * ```ts + * import metadata from 'libphonenumber-js/metadata.min.json'; // everything, validation-grade + * import examples from 'libphonenumber-js/examples.mobile.json'; // optional, for placeholders + * const codec = createLibphonenumberCodec(metadata, examples); + * ``` + * + * Apps serving few countries generate a subset instead (a few kB): + * `npx libphonenumber-generate-metadata metadata.custom.json --countries DE,AT,CH`. + * + * Severity emerges from parseability: input the engine can still read as a + * number (merely too short/long or unrecognized) parses with a `warning` and + * stays committable; input it cannot read at all fails with a `reason`. + */ +export function createLibphonenumberCodec(metadata: MetadataJson, examples?: Examples): PhoneCodec { + const lengthIssue = (raw: string, defaultCountry?: PhoneCountry) => + defaultCountry + ? validatePhoneNumberLength(raw, defaultCountry as CountryCode, metadata) + : validatePhoneNumberLength(raw, metadata); + + return { + parse(raw: string, defaultCountry?: PhoneCountry): PhoneParseResult | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + const issue = lengthIssue(trimmed, defaultCountry); + const phone = parsePhoneNumberFromString( + trimmed, + { defaultCountry: defaultCountry as CountryCode | undefined, extract: false }, + metadata, + ); + + if (!phone) { + return { + ok: false, + reason: + issue === 'INVALID_COUNTRY' + ? 'invalid-country' + : issue === 'TOO_SHORT' + ? 'too-short' + : issue === 'TOO_LONG' + ? 'too-long' + : 'not-a-number', + }; + } + + const warning: PhoneParseWarning | undefined = + issue === 'TOO_SHORT' + ? 'too-short' + : issue === 'TOO_LONG' + ? 'too-long' + : issue === 'INVALID_LENGTH' + ? 'invalid-length' + : !phone.isValid() + ? 'unrecognized' + : undefined; + + return { + ok: true, + e164: phone.number, + country: phone.country, + dialCode: String(phone.countryCallingCode), + national: phone.formatNational(), + international: phone.formatInternational(), + ...(warning ? { warning } : {}), + }; + }, + + format(value: string, style: 'national' | 'international', defaultCountry?: PhoneCountry): string { + const phone = parsePhoneNumberFromString( + value, + { defaultCountry: defaultCountry as CountryCode | undefined, extract: false }, + metadata, + ); + if (!phone) return value; + + return style === 'national' ? phone.formatNational() : phone.formatInternational(); + }, + + formatIncomplete(raw: string, defaultCountry?: PhoneCountry): string { + return formatIncompletePhoneNumber(raw, defaultCountry as CountryCode | undefined, metadata); + }, + + placeholderExample(country: PhoneCountry): string | undefined { + if (!examples) return undefined; + + return getExampleNumber(country as CountryCode, examples, metadata)?.formatNational(); + }, + }; +} diff --git a/projects/angular-inline-select/phone/src/phone-codec.ts b/projects/angular-inline-select/phone/src/phone-codec.ts new file mode 100644 index 0000000..ee89af1 --- /dev/null +++ b/projects/angular-inline-select/phone/src/phone-codec.ts @@ -0,0 +1,70 @@ +/** + * The phone engine contract. `angular-inline-phone` consumes this interface + * and never imports a phone library directly — swap the engine (custom + * metadata subset, a different port, a server API) without touching the UI. + */ + +/** ISO 3166-1 alpha-2 country code, e.g. 'DE'. */ +export type PhoneCountry = string; + +export type PhoneNumberKind = 'mobile' | 'fixed-or-mobile'; + +/** + * Structural failures — the engine could not produce a number at all. + * These gate the commit (there is nothing reliable to save). + */ +export type PhoneParseReason = 'not-a-number' | 'invalid-country' | 'too-short' | 'too-long'; + +/** + * Soft findings — an E.164 exists and MAY be committed, but the number is + * suspicious. Surfaced as warnings, never commit blockers (production + * lesson: a "too short" number is sometimes a weird-but-real local number). + */ +export type PhoneParseWarning = 'too-short' | 'too-long' | 'invalid-length' | 'unrecognized'; + +export interface PhoneParseSuccess { + ok: true; + /** The canonical value: E.164 (`'+491712345678'`). */ + e164: string; + country?: PhoneCountry; + /** Country calling code without the `+`, e.g. `'49'`. */ + dialCode?: string; + national: string; + international: string; + warning?: PhoneParseWarning; +} + +export interface PhoneParseFailure { + ok: false; + reason: PhoneParseReason; +} + +export type PhoneParseResult = PhoneParseSuccess | PhoneParseFailure; + +export interface PhoneCodec { + /** Full interpretation of raw input. Returns `null` for empty input. */ + parse(raw: string, defaultCountry?: PhoneCountry): PhoneParseResult | null; + + /** Formats a committed value for display. Returns the input unchanged when it cannot be read. */ + format(value: string, style: 'national' | 'international', defaultCountry?: PhoneCountry): string; + + /** + * Best-effort pretty-print of an incomplete draft — used for the live + * interpretation preview, NEVER applied to the draft itself. + */ + formatIncomplete?(raw: string, defaultCountry?: PhoneCountry): string; + + /** A real example number in national format, for placeholder use. */ + placeholderExample?(country: PhoneCountry, kind: PhoneNumberKind): string | undefined; +} + +/** + * Flag emoji for an ISO country code — two regional-indicator code points. + * No sprites, no stylesheets, nothing that can break. + */ +export function countryFlagEmoji(country: PhoneCountry): string { + const code = country.toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) return ''; + + return String.fromCodePoint(...[...code].map((char) => 0x1f1e6 + char.charCodeAt(0) - 65)); +} diff --git a/projects/angular-inline-select/phone/src/public-api.ts b/projects/angular-inline-select/phone/src/public-api.ts new file mode 100644 index 0000000..6a71ebf --- /dev/null +++ b/projects/angular-inline-select/phone/src/public-api.ts @@ -0,0 +1,11 @@ +/* + * Public API Surface of angular-inline-select/phone + * + * Secondary entry point: apps that never import it carry zero phone bytes. + * `libphonenumber-js` is an optional peer dependency used only by + * `createLibphonenumberCodec` — bring your own `PhoneCodec` to skip it. + */ + +export * from './phone-codec'; +export * from './libphonenumber-codec'; +export * from './angular-inline-phone'; diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html index 17ddcf2..8ff105d 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html @@ -8,6 +8,7 @@ [(editing)]="innerEditing" [isSingleLine]="true" [normalizeValue]="true" + inputMode="decimal" [errors]="innerErrors()" [invalid]="invalid()" [disabled]="disabled()" diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts index 741d56e..c56469d 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts @@ -208,7 +208,9 @@ describe('AngularInlineNumber — affix forwarding', () => { await typeText(h, '55'); - const inPanel = document.querySelector('.editable-panel__line .editable-text__affix--suffix .unit'); + const inPanel = document.querySelector( + '.editable-panel__line .editable-text__affix--suffix .unit', + ); expect(inPanel?.textContent).toBe('€'); // The affix never leaks into the draft or the committed number diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts index af59001..348581c 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts @@ -12,7 +12,10 @@ import { } from '@angular/core'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; -import { AngularInlineText, type InlineTextSaved } from '../angular-inline-text/angular-inline-text'; +import { + AngularInlineText, + type InlineTextSaved, +} from '../angular-inline-text/angular-inline-text'; import { EditablePrefix, EditableSuffix } from '../angular-inline-text/editable-affix'; /** Payload of the `saved` output: one emission per settled edit session. */ diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index d2cfbca..74f2a93 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -25,6 +25,7 @@ [attr.aria-invalid]="isEmpty() && required() ? null : errorsVisible() || null" [attr.aria-expanded]="editing()" [attr.data-placeholder]="placeholder()" + [attr.inputmode]="inputMode() ?? null" spellcheck="false" (beforeinput)="interceptBeforeInput($event)" (paste)="interceptPaste($event)" @@ -70,6 +71,7 @@ [attr.aria-invalid]="errorsVisible() || null" [attr.aria-describedby]="panelId + '-messages'" [attr.data-placeholder]="placeholder()" + [attr.inputmode]="inputMode() ?? null" (input)="handleEditorInput()" (paste)="handleEditorPaste($event)" (keydown.enter)="handleEnterKey($event)" @@ -101,6 +103,13 @@
} + + @if (hintTpl(); as hint) { +
+ +
+ } + @if (isDirty() && !errorsVisible()) {
Unsaved changes
} diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index 77afb48..085146d 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -26,6 +26,7 @@ import { A11yModule, _IdGenerator } from '@angular/cdk/a11y'; import { getSelectionOffsets, setCaretOffset, replayEdit } from './caret'; import { EditablePrefix, EditableSuffix } from './editable-affix'; +import { EditableHint } from './editable-hint'; interface ValueNormalizationDetails { value: string; @@ -249,6 +250,24 @@ export class AngularInlineText implements FormValueControl { protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); + /** + * Panel hint template — live per-keystroke feedback (interpretation + * previews, counters) rendered in the panel footer, independent of the + * error state. Same dual channel as the affixes: input for composition, + * `ng-template[editableHint]` content for direct use. + */ + hintTemplate = input | undefined>(undefined); + + private contentHint = contentChild(EditableHint); + + protected hintTpl = computed(() => this.hintTemplate() ?? this.contentHint()?.templateRef); + + /** + * `inputmode` for the editable surfaces — virtual-keyboard hint on mobile + * ('decimal', 'tel', 'email', …). + */ + inputMode = input(undefined); + /** * Trims leading/trailing whitespace on commit — the committed value and the * emitted events carry the trimmed text. Interior spacing is never touched. diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-hint.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-hint.ts new file mode 100644 index 0000000..34e7b29 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-hint.ts @@ -0,0 +1,24 @@ +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Hint template for the elevated panel's footer — rendered while the session + * is open, above the actions and independent of the error state. The home of + * live, per-keystroke feedback that must never touch the draft itself: + * interpretation previews (inline-phone), character counters, etc. + * + * Declared on an `ng-template` so wrapping controls can forward it as a + * `TemplateRef` through the `hintTemplate` input (content queries don't + * pierce re-projection). + * + * ```html + * + * {{ note().length }}/200 + * + * ``` + */ +@Directive({ + selector: 'ng-template[editableHint]', +}) +export class EditableHint { + readonly templateRef = inject>(TemplateRef); +} diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts index 7380218..f8ae4ae 100644 --- a/projects/angular-inline-select/src/public-api.ts +++ b/projects/angular-inline-select/src/public-api.ts @@ -5,5 +5,6 @@ export * from './lib/angular-inline-text/angular-inline-text'; export * from './lib/angular-inline-text/editable-error'; export * from './lib/angular-inline-text/editable-affix'; +export * from './lib/angular-inline-text/editable-hint'; export * from './lib/angular-inline-text/caret'; export * from './lib/angular-inline-number/angular-inline-number'; diff --git a/projects/angular-inline-select/tsconfig.lib.json b/projects/angular-inline-select/tsconfig.lib.json index ffc453e..7711d69 100644 --- a/projects/angular-inline-select/tsconfig.lib.json +++ b/projects/angular-inline-select/tsconfig.lib.json @@ -8,7 +8,7 @@ "declarationMap": true, "types": [] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "phone/src/**/*.ts"], "exclude": ["**/*.spec.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/projects/angular-inline-select/tsconfig.spec.json b/projects/angular-inline-select/tsconfig.spec.json index fa2e8be..9a636d4 100644 --- a/projects/angular-inline-select/tsconfig.spec.json +++ b/projects/angular-inline-select/tsconfig.spec.json @@ -6,7 +6,7 @@ "outDir": "../../out-tsc/spec", "types": ["vitest/globals"] }, - "include": ["src/**/*.d.ts", "src/**/*.spec.ts"], + "include": ["src/**/*.d.ts", "src/**/*.spec.ts", "phone/src/**/*.d.ts", "phone/src/**/*.spec.ts"], "angularCompilerOptions": { "extendedDiagnostics": { "checks": { diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html index 679d687..918a726 100644 --- a/projects/app/src/app/app.html +++ b/projects/app/src/app/app.html @@ -11,6 +11,7 @@
diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts index aa69865..e8b9784 100644 --- a/projects/app/src/app/app.routes.ts +++ b/projects/app/src/app/app.routes.ts @@ -11,5 +11,10 @@ export const routes: Routes = [ loadComponent: () => import('./pages/number-playground/number-playground').then((m) => m.NumberPlayground), }, + { + path: 'phone', + loadComponent: () => + import('./pages/phone-playground/phone-playground').then((m) => m.PhonePlayground), + }, { path: '', pathMatch: 'full', redirectTo: 'text' }, ]; diff --git a/projects/app/src/app/pages/phone-playground/phone-playground.html b/projects/app/src/app/pages/phone-playground/phone-playground.html new file mode 100644 index 0000000..357b4c8 --- /dev/null +++ b/projects/app/src/app/pages/phone-playground/phone-playground.html @@ -0,0 +1,92 @@ +
+
+
+

Inline phone

+

+ angular-inline-phone composes the text control over an injected PhoneCodec + (libphonenumber-js, metadata of your choice). Canonical value: E.164. No third-party DOM, no CSS to break — + the flag is a unicode emoji. +

+
+ +
+
+

Standalone [(value)]

+

+ Support hotline: + + — the flag shows the detected country, the panel previews the engine's interpretation on every + keystroke, and the draft itself is never reformatted — model: {{ hotline() ?? '∅' }}. +

+
+ +
+

Signal form + live interpretation

+

+ Contact number: + + + @if (contact.parseFailed()) { + That doesn’t read as a phone number — digits, spaces and an optional leading “+”. + } @else if (phoneMissing()) { + A contact number is required. + } + + + — typed nationally (“0171…”) it resolves against defaultCountry; typed with “+CC” the detected + country wins. Structurally unreadable drafts can’t save; suspicious ones (too short, unrecognized) save with + a ⚠ in the preview — model: {{ contactModel().phone ?? '∅' }}. +

+ +
+ + + + + + +
+ +

+ Try “0171 2345678” (✓), “017” (⚠ too short — still saves), “abc” (parse gate — Save stays blocked), or + “+33 1 42 68 53 00” (the flag flips to 🇫🇷). The model always holds E.164 or null. +

+ + @if (emittedEvents().length > 0) { +
+ @for (entry of emittedEvents(); track $index) { + {{ entry }} + } +
+ } +
+
+
+
diff --git a/projects/app/src/app/pages/phone-playground/phone-playground.scss b/projects/app/src/app/pages/phone-playground/phone-playground.scss new file mode 100644 index 0000000..e1686d8 --- /dev/null +++ b/projects/app/src/app/pages/phone-playground/phone-playground.scss @@ -0,0 +1 @@ +@use '../demo'; diff --git a/projects/app/src/app/pages/phone-playground/phone-playground.ts b/projects/app/src/app/pages/phone-playground/phone-playground.ts new file mode 100644 index 0000000..9eefa3d --- /dev/null +++ b/projects/app/src/app/pages/phone-playground/phone-playground.ts @@ -0,0 +1,74 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, +} from '@angular/core'; +import { FormField, form, required } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; + +// Phone entry point: the only place in the app that carries phone bytes. +import { AngularInlinePhone, createLibphonenumberCodec } from 'angular-inline-select/phone'; +import metadata from 'libphonenumber-js/metadata.min.json'; +import examples from 'libphonenumber-js/examples.mobile.json'; + +// One codec per app: full min-metadata here; a DACH-only app would pass a +// generated subset instead (a few kB). +const phoneCodec = createLibphonenumberCodec(metadata, examples); + +@Component({ + selector: 'app-phone-playground', + templateUrl: './phone-playground.html', + styleUrl: './phone-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + + // Forms + FormField, + + // Components + AngularInlinePhone, + ], +}) +export class PhonePlayground { + protected codec = phoneCodec; + + // --------------------------------------------------------------------------- + // Standalone [(value)] example — any parseable string in, E.164 out + // --------------------------------------------------------------------------- + protected hotline = signal('+493012345678'); + + // --------------------------------------------------------------------------- + // Signal form example: E.164 model + schema + live interpretation + // --------------------------------------------------------------------------- + protected fieldRequired = signal(true); + protected fieldReadonly = signal(false); + protected fieldDisabled = signal(false); + + protected displayFormat = signal<'national' | 'international'>('international'); + + protected contactModel = signal<{ phone: string | null }>({ phone: '+491712345678' }); + + protected contactForm = form(this.contactModel, (path) => { + required(path.phone, { when: () => this.fieldRequired() }); + }); + + protected phoneMissing = computed(() => + this.contactForm.phone().errors().some((error) => error.kind === 'required'), + ); + + // Event console: E.164-typed payloads, newest first. + protected emittedEvents = signal([]); + + protected logEmit(name: string, payload: unknown) { + this.emittedEvents.update((events) => + [`${name} → ${JSON.stringify(payload)}`, ...events].slice(0, 8), + ); + } +} diff --git a/tsconfig.json b/tsconfig.json index 3579499..d4047f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,10 @@ "compileOnSave": false, "compilerOptions": { "paths": { - "angular-inline-select": ["./dist/angular-inline-select"] + "angular-inline-select": ["./projects/angular-inline-select/src/public-api.ts"], + "angular-inline-select/phone": ["./projects/angular-inline-select/phone/src/public-api.ts"] }, + "resolveJsonModule": true, "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, From a9b4c8337a2d996c78b015355d3d28648eedad33 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Mon, 6 Jul 2026 15:24:33 +0200 Subject: [PATCH 04/48] feat(EditablePhone): slash menu for country picker --- ROADMAP.md | 122 ++++++++- .../phone/src/angular-inline-phone.html | 99 ++++++- .../phone/src/angular-inline-phone.spec.ts | 67 +++++ .../phone/src/angular-inline-phone.ts | 259 +++++++++++++++++- .../phone/src/libphonenumber-codec.spec.ts | 1 + .../phone/src/libphonenumber-codec.ts | 15 + .../phone/src/phone-codec.ts | 12 + .../angular-inline-text.html | 25 +- .../angular-inline-text.spec.ts | 28 ++ .../angular-inline-text.ts | 164 ++++++++++- .../lib/angular-inline-text/editable-menu.ts | 78 ++++++ .../src/lib/styles/_editable.scss | 28 ++ .../angular-inline-select/src/public-api.ts | 1 + .../phone-playground/phone-playground.html | 29 +- .../phone-playground/phone-playground.ts | 6 + 15 files changed, 915 insertions(+), 19 deletions(-) create mode 100644 projects/angular-inline-select/src/lib/angular-inline-text/editable-menu.ts diff --git a/ROADMAP.md b/ROADMAP.md index 436b264..a8fa1fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,23 @@ value ownership — value restoration is the form's job; the control only keeps its presentation state honest. Signal forms improves on its `ngDoCheck` error-state polling by delivering `touched`/`invalid` as inputs. +**Guardrail — the ProseMirror line.** `angular-inline-text` is a *value* +editor (flat string), never a *document* editor, and stays that way: + +1. The editable contains characters only — every adornment (affixes, flag, + preview, menu) renders OUTSIDE the contenteditable. +2. The draft is never transformed under the caret (as-you-type formatting is + permanently rejected — position mapping through transforms is the start + of hand-rolling a bad ProseMirror). +3. The moment a requirement needs a tree — marks (bold/links), atomic + in-text tokens/pills/mentions, semantic blocks, semantic undo, collab — + that feature does NOT grow here. It becomes a separate control behind + the same `FormValueControl` contract, with ProseMirror (or similar) + contained inside it by composition, exactly like libphonenumber is + contained in the phone codec. PM owns its DOM and its state lives + outside signals — bridging it is intl-tel-input flag-hell at 10×, a + price paid only when the problem is genuinely documents. + --- ## Shipped on this branch @@ -303,15 +320,102 @@ overrides it (parser detects). Confirmed by production: the old control ran `formatOnDisplay: false` for the same reason. -**P3 — as-you-type formatting (hard, separate).** libphonenumber's -`AsYouType` inserts separators while typing — which rewrites the draft under -the caret, the exact thing our architecture forbids. Needs caret-preserving -reformat math (map caret through inserted separators). Only attempt with a -dedicated spec suite; the control must stay correct without it. - -**P4 — country picker.** An interactive prefix (flag + dial code) opening a -country list in the panel. This is inline-select territory — a natural -trigger for extracting the `createEditSession()` primitives. Not before. +**P3 — as-you-type formatting: REJECTED, permanently.** Decided: rewriting +the draft under the caret is never acceptable, and the live interpretation +preview already delivers the visibility it promised. If anyone proposes +this again, the answer is the preview line. + +**P4 — slash command menu — SHIPPED.** A typed, keyboard-first menu, the +seed of the future inline-select. Implemented exactly as designed below. + +- **Core seam (`angular-inline-text`):** `menuTemplate` input + + `ng-template[editableMenu]` content sugar, dormant unless provided. The + control owns trigger detection (`detectSlashToken` — `/` at draft start or + after whitespace, no mid-word slashes), DOM-based navigation over the + consumer's `[role="option"]` elements, two-stage Escape, combobox ARIA + (editor becomes `role=combobox` with mirrored `aria-activedescendant`), + and the `apply(replacement, {replaceToken?})` callback (rewrites the draft + via the caret machinery, whole-draft by default). Context gives the + consumer `{ $implicit: query, activeId, apply }`. +- **`@angular/aria` finding (why we didn't use the directives):** `ngCombobox` + hard-checks `tagName === input|textarea`, so on our contenteditable it + degrades to a non-editable select; `ngListbox`/`ngOption` keyboard is + host-focus-bound and never fires while focus stays in the editor, and + `ngOption`'s `data-active` is driven by the listbox's own (never-active) + navigation. Driving them would mean forwarding synthetic events into a + focus-assuming widget — the intl-tel-input bridge trap at small scale. So + we implement the raw ARIA **combobox pattern** (which is all the directive + encodes) by hand, since we already own the editor keyboard. Consumers get + plain `[role="option"]` divs; they may still layer aria typeahead if they + want, our nav is DOM-based either way. +- **Phone country menu:** `AngularInlinePhone` provides the `editableMenu` + template; consumer-owned `@for` + `countryOptions(query)` search, control + owns nav. Selecting rewrites the draft to `'+49 '` → existing detection + flips the flag and preview. **i18n via `Intl.DisplayNames`** (`menuLocale` + input, browser default) — zero bundled country names, every locale. Search + matches the localized name **and** the English name **and** ISO **and** + dial code, so `/germany`, `/deutschland`, `/de`, `/49` all resolve to 🇩🇪 + in any menu locale. Browser-verified de↔en switching; secondary-entry-point + production build confirms `libphonenumber` is referenced only in the phone + bundle, never the core. +- **Later:** this menu is the core of `angular-inline-select` (filtered + option list in the panel) — the `createEditSession()` extraction trigger. + +**P4b — flag country picker (the primary / mobile gesture) — SHIPPED.** The +slash menu is a keyboard *insert* gesture (great for fresh entry); changing +the country of an *existing* number is a *transform* and needs the +established phone-input gesture: an interactive flag opening a searchable +list, preserving the national number. Both gestures share one option list. + +- **Interactive flag:** phone renders the flag prefix as a ` + {{ preview() }} + + + + {{ option.name }} + +{{ option.dialCode }} + + + + +
+ @for (option of countryOptions(query); track option.country) { +
+ +
+ } @empty { +
No country matches “{{ query }}”.
+ } +
+
+ + + + diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts index 106f7c8..489c757 100644 --- a/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts @@ -94,6 +94,16 @@ async function typeText(h: Harness, text: string) { if (!editor) throw new Error('elevated editor not found'); editor.textContent = text; + + // Place the caret at the end, as real typing would — the slash-menu trigger + // reads the caret position, so programmatic textContent alone isn't enough. + const selection = document.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + editor.dispatchEvent(new Event('input', { bubbles: true })); h.fixture.detectChanges(); } @@ -164,6 +174,63 @@ describe('AngularInlinePhone — [(value)] binding', () => { expect(h.host.sessions).toEqual([{ value: null, changed: true }]); }); + it('typing / on the idle display elevates and opens the menu in one gesture', async () => { + h.host.value.set(null); + h.fixture.detectChanges(); + + // A single `/` on the pristine display — should elevate AND open the menu, + // not land in edit mode with a lone slash and no menu. + const display = h.display(); + const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; + Object.defineProperty(event, 'inputType', { value: 'insertText' }); + Object.defineProperty(event, 'data', { value: '/' }); + display.dispatchEvent(event); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(h.inner().editing()).toBe(true); + expect(h.editor()?.textContent).toBe('/'); + expect(document.querySelector('.editable-menu [role="option"]')).not.toBeNull(); + }); + + it('the /country slash menu filters and inserts the dial code', async () => { + // `/49` matches Germany's calling code — locale-independent, unlike names + await typeText(h, '/49'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + const options = [...document.querySelectorAll('.editable-menu [role="option"]')]; + expect(options.length).toBeGreaterThan(0); + const germany = options.find((option) => option.textContent?.includes('+49')); + expect(germany).toBeTruthy(); + + (germany as HTMLElement).click(); + h.fixture.detectChanges(); + + // The draft becomes the dial-code prefix and the menu closes + expect(h.editor()?.textContent).toBe('+49 '); + expect(document.querySelector('.editable-menu')).toBeNull(); + }); + + it('picking a country while idle swaps the dial code and preserves the national number', () => { + // Committed as +49 30 49781234 (NSN 3049781234) + h.host.value.set('+493049781234'); + h.fixture.detectChanges(); + + // Pick Austria (+43) — the idle-commit path + (h.phone() as unknown as { pickCountry(o: { country: string; dialCode: string }): void }).pickCountry({ + country: 'AT', + dialCode: '43', + }); + h.fixture.detectChanges(); + + // National number kept, calling code swapped, committed immediately + expect(h.host.value()).toBe('+433049781234'); + expect(h.host.saved).toEqual(['+433049781234']); + expect(h.host.sessions).toEqual([{ value: '+433049781234', changed: true }]); + }); + it('the live preview interprets the draft without touching it', async () => { const hintText = () => document.querySelector('.editable-panel__message--hint')?.textContent?.trim(); diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.ts index a83e1e8..c640287 100644 --- a/projects/angular-inline-select/phone/src/angular-inline-phone.ts +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.ts @@ -9,7 +9,13 @@ import { linkedSignal, viewChild, contentChild, + afterNextRender, + ElementRef, + Injector, + inject, } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { @@ -55,9 +61,72 @@ export interface InlinePhoneSaved { */ @Component({ selector: 'angular-inline-phone', - imports: [AngularInlineText], + imports: [AngularInlineText, OverlayModule, NgTemplateOutlet], templateUrl: './angular-inline-phone.html', - styles: ':host { display: inline; }', + styles: ` + :host { display: inline; } + .country-name { flex: 1 1 auto; } + .country-dial { color: var(--mat-sys-on-surface-variant, #5f6368); font-variant-numeric: tabular-nums; } + .country-empty { + padding: calc(var(--mat-sys-inner-spacing, 16px) / 4) calc(var(--mat-sys-inner-spacing, 16px) / 2); + color: var(--mat-sys-on-surface-variant, #5f6368); + } + + .country-trigger { + font: inherit; + line-height: 1; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + border-radius: var(--mat-sys-corner-extra-small, 0.25rem); + } + .country-trigger:focus-visible { + outline: 2px solid var(--mat-sys-primary, #4285f4); + outline-offset: 2px; + } + + .country-picker { + display: flex; + flex-direction: column; + width: min(20rem, calc(100dvw - 16px)); + max-height: min(24rem, 60dvh); + box-sizing: border-box; + + background: var(--mat-sys-surface-container, #fff); + border: 1px solid var(--mat-sys-outline-variant, #c4c7c5); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: + 0 2px 6px hsl(0deg 0% 0% / 0.08), + 0 8px 24px hsl(0deg 0% 0% / 0.12); + overflow: hidden; + } + .country-picker__search { + font: inherit; + padding: 0.6rem 0.75rem; + border: 0; + border-bottom: 1px solid var(--mat-sys-outline-variant, #c4c7c5); + background: transparent; + color: inherit; + outline: none; + } + .country-picker__list { + overflow-y: auto; + padding: 4px; + } + .country-picker__list [role='option'] { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem; + border-radius: var(--mat-sys-corner-small, 0.4rem); + cursor: pointer; + } + .country-picker__list [role='option'][data-active='true'] { + background: var(--mat-sys-secondary-container, #d7e3ff); + color: var(--mat-sys-on-secondary-container, #001b3f); + } + `, host: { '[style.display]': 'hidden() ? "none" : null', }, @@ -87,6 +156,16 @@ export class AngularInlinePhone implements FormValueControl { /** The country-detection prefix. Off, or overridden by `editablePrefix` content. */ showFlag = input(true); + /** Enables the `/country` slash-menu (type `/german`, `/de`, `/49` → `+49 `). */ + showCountryMenu = input(true); + + /** + * Locale for the slash-menu's country names (`Intl.DisplayNames`). Undefined + * = the browser default. Only affects display and adds a matching basis; + * the English name, ISO code and dial code always match too. + */ + menuLocale = input(undefined); + /** Form Value Contract — forwarded into the inner control. */ errors = input([]); disabled = input(false); @@ -258,6 +337,182 @@ export class AngularInlinePhone implements FormValueControl { this.saved.emit({ value, changed: session.changed }); } + // --------------------------------------------------------------------------- + // Country slash-menu — the picker, keyboard-first and translation-free. + // --------------------------------------------------------------------------- + + /** Localized region names from the browser — no bundled i18n, every locale. */ + #regionNames = computed(() => this.#displayNames(this.menuLocale())); + + /** English names as a constant matching basis, so `/germany` always works. */ + #regionNamesEn = this.#displayNames('en'); + + #displayNames(locale: string | string[] | undefined) { + try { + return new Intl.DisplayNames(locale as unknown as string[], { type: 'region' }); + } catch { + return undefined; + } + } + + #nameOf(names: Intl.DisplayNames | undefined, country: PhoneCountry): string { + try { + return names?.of(country) ?? country; + } catch { + return country; + } + } + + /** The full country list, rebuilt when the codec or display locale changes. */ + #countries = computed(() => { + const codec = this.codec(); + const names = this.#regionNames(); + const list = codec.listCountries?.() ?? []; + + return list + .map((country) => { + const dialCode = codec.dialCodeOf?.(country) ?? ''; + return { + id: `ai-country-${country}`, + country, + name: this.#nameOf(names, country), + // Lower-cased match keys: localized name + English name + ISO code. + match: `${this.#nameOf(names, country)}\n${this.#nameOf(this.#regionNamesEn, country)}\n${country}`.toLowerCase(), + dialCode, + flag: countryFlagEmoji(country), + insert: `+${dialCode} `, + }; + }) + .filter((option) => option.dialCode) + .sort((a, b) => a.name.localeCompare(b.name)); + }); + + /** + * Options for the current query — the consumer-owned search. Matches the + * localized name, the English name, the ISO code, and the dial code, so + * `/deutschland`, `/germany`, `/de` and `/49` all resolve to 🇩🇪. Capped so + * a bare `/` stays a usable list. + */ + protected countryOptions(query: string) { + const q = query.trim().toLowerCase(); + const all = this.#countries(); + if (!q) return all.slice(0, 60); + + const digits = q.replace(/\D/g, ''); + return all + .filter( + (option) => + option.match.includes(q) || + (digits.length > 0 && option.dialCode.startsWith(digits)), + ) + .slice(0, 60); + } + + // --------------------------------------------------------------------------- + // Flag country picker — the primary, mobile-first, pointer gesture. Shares + // the option list with the slash menu; unlike it, the query lives in the + // picker's own search field (the draft is never touched) and picking + // preserves the national number. + // --------------------------------------------------------------------------- + #injector = inject(Injector); + + protected pickerSearch = viewChild>('pickerSearch'); + + protected pickerOpen = signal(false); + protected pickerOrigin = signal(null); + protected pickerQuery = signal(''); + protected pickerActiveIndex = signal(0); + + protected pickerOptions = computed(() => this.countryOptions(this.pickerQuery())); + protected pickerActiveId = computed(() => this.pickerOptions()[this.pickerActiveIndex()]?.id); + + protected pickerPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + ]; + + protected openPicker(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + this.pickerOrigin.set(event.currentTarget as Element); + this.pickerQuery.set(''); + this.pickerActiveIndex.set(0); + this.pickerOpen.set(true); + + afterNextRender(() => this.pickerSearch()?.nativeElement.focus(), { injector: this.#injector }); + } + + protected closePicker() { + this.pickerOpen.set(false); + } + + protected onPickerSearch(value: string) { + this.pickerQuery.set(value); + this.pickerActiveIndex.set(0); + } + + protected onPickerKeydown(event: KeyboardEvent) { + const options = this.pickerOptions(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.pickerActiveIndex.update((i) => Math.min(i + 1, options.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + this.pickerActiveIndex.update((i) => Math.max(i - 1, 0)); + break; + case 'Enter': { + event.preventDefault(); + const option = options[this.pickerActiveIndex()]; + if (option) this.pickCountry(option); + break; + } + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.closePicker(); + break; + } + } + + /** + * Applies a picked country. Preserves the national number by rebuilding + * `+`. While editing it updates the live draft; + * idle it commits immediately (the flag is a standalone quick-edit, like + * the clear bubble). + */ + protected pickCountry(option: { country: PhoneCountry; dialCode: string }) { + const base = this.canonical(); + const parsed = base ? this.codec().parse(base, this.defaultCountry()) : null; + const nsn = parsed?.ok ? parsed.nationalNumber : undefined; + + if (this.innerEditing()) { + // Editing: rewrite the live draft, keep the session open. + const draft = nsn + ? this.codec().format(`+${option.dialCode}${nsn}`, this.displayFormat(), option.country) + : `+${option.dialCode} `; + this.handleInnerValue(draft); + } else if (nsn) { + // Idle with a number: swap the calling code and commit immediately. + const e164 = `+${option.dialCode}${nsn}`; + if (e164 !== base) { + this.value.set(e164); + this.savedModelChange.emit(e164); + this.saved.emit({ value: e164, changed: true }); + } + } else { + // Idle and empty: nothing to swap — open the editor seeded with the + // calling code so the user can type the rest. + this.innerValue.set(`+${option.dialCode} `); + this.innerEditing.set(true); + } + + this.closePicker(); + } + /** Form Value Contract: focus — delegates to the inner control. */ focus(options?: FocusOptions) { this.inner().focus(options); diff --git a/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts b/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts index d85ba29..957c2ab 100644 --- a/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts @@ -15,6 +15,7 @@ describe('createLibphonenumberCodec', () => { e164: '+491712345678', country: 'DE', dialCode: '49', + nationalNumber: '1712345678', national: '0171 2345678', international: '+49 171 2345678', }); diff --git a/projects/angular-inline-select/phone/src/libphonenumber-codec.ts b/projects/angular-inline-select/phone/src/libphonenumber-codec.ts index 4b2ac80..f9c212c 100644 --- a/projects/angular-inline-select/phone/src/libphonenumber-codec.ts +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.ts @@ -3,6 +3,8 @@ import { validatePhoneNumberLength, formatIncompletePhoneNumber, getExampleNumber, + getCountries, + getCountryCallingCode, type MetadataJson, type Examples, type CountryCode, @@ -80,6 +82,7 @@ export function createLibphonenumberCodec(metadata: MetadataJson, examples?: Exa e164: phone.number, country: phone.country, dialCode: String(phone.countryCallingCode), + nationalNumber: String(phone.nationalNumber), national: phone.formatNational(), international: phone.formatInternational(), ...(warning ? { warning } : {}), @@ -106,5 +109,17 @@ export function createLibphonenumberCodec(metadata: MetadataJson, examples?: Exa return getExampleNumber(country as CountryCode, examples, metadata)?.formatNational(); }, + + listCountries(): PhoneCountry[] { + return getCountries(metadata); + }, + + dialCodeOf(country: PhoneCountry): string | undefined { + try { + return getCountryCallingCode(country as CountryCode, metadata); + } catch { + return undefined; + } + }, }; } diff --git a/projects/angular-inline-select/phone/src/phone-codec.ts b/projects/angular-inline-select/phone/src/phone-codec.ts index ee89af1..66f5ec9 100644 --- a/projects/angular-inline-select/phone/src/phone-codec.ts +++ b/projects/angular-inline-select/phone/src/phone-codec.ts @@ -29,6 +29,12 @@ export interface PhoneParseSuccess { country?: PhoneCountry; /** Country calling code without the `+`, e.g. `'49'`. */ dialCode?: string; + /** + * The national significant number — the digits after the calling code + * (`'3049781234'`). This is what a country swap preserves: rebuild + * `+` to change country without losing the number. + */ + nationalNumber: string; national: string; international: string; warning?: PhoneParseWarning; @@ -56,6 +62,12 @@ export interface PhoneCodec { /** A real example number in national format, for placeholder use. */ placeholderExample?(country: PhoneCountry, kind: PhoneNumberKind): string | undefined; + + /** All supported ISO country codes — powers the country slash-menu. */ + listCountries?(): PhoneCountry[]; + + /** Country calling code without `+`, e.g. `'49'` for `'DE'`. */ + dialCodeOf?(country: PhoneCountry): string | undefined; } /** diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index 74f2a93..d3b0ef4 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -51,7 +51,7 @@ [animate.enter]="'editable-panel-enter'" cdkTrapFocus [cdkTrapFocusAutoCapture]="false" - (keydown.escape)="cancel()" + (keydown.escape)="handleEscape($event)" (keydown.control.enter)="handleCtrlEnter()" >
@@ -62,7 +62,7 @@ } @if (suffixTpl(); as suffix) {
+ + @if (menuOpen()) { +
+ +
+ } +
+
+

The trio — day · start · length (unlinked, the T5 fixture)

+

+ Workshop on + + starting at + + for + + — one signal form, three temporal fields, start in military time (24 h via the + hc-h23 locale extension). Model: + {{ workshopModel().day ?? '∅' }} · {{ workshopModel().starts ?? '∅' }} · + {{ workshopModel().length ?? '∅' }}s. +

+ +

+ Deliberately NOT linked yet — this is the fixture T5's DateTimeRangeGroup grows on: date range + + time range + duration speaking to each other, the end time wearing a +1 day badge when it + crosses midnight (the airline arrival pattern). See ROADMAP-DATETIME. +

+
+ @if (emittedEvents().length > 0) {
diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index cfc9645..fbd4e66 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -65,6 +65,29 @@ export class TemporalPlayground { protected durationFormat = signal('h:mm'); protected estimate = signal(5400); + // --------------------------------------------------------------------------- + // The trio — one signal form, three temporal fields, deliberately UNLINKED: + // the T5 DateTimeRangeGroup fixture (day/start/end/duration speaking to + // each other; end times get a +1 day badge when they cross midnight). + // --------------------------------------------------------------------------- + protected workshopModel = signal<{ + /** ISO `'yyyy-MM-dd'`. */ + day: string | null; + /** `'HH:mm'` — shown in 24 h via the `hc-h23` locale extension. */ + starts: string | null; + /** Seconds. */ + length: number | null; + }>({ + day: '2026-07-21', + starts: '14:00', + length: 5400, + }); + + protected workshopForm = form(this.workshopModel); + + /** The page's locale toggle, pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.dateLocale()}-u-hc-h23`); + // Event console: newest first. protected emittedEvents = signal([]); From 76668fd37ea754ed4325e79f2564c90a1cb57260 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Mon, 6 Jul 2026 21:51:10 +0200 Subject: [PATCH 11/48] =?UTF-8?q?feat(demo):=20T5=20fixture=20is=20the=20q?= =?UTF-8?q?uartet=20=E2=80=94=20stay=20=C2=B7=20start=20=C2=B7=20end=20?= =?UTF-8?q?=C2=B7=20length?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playground card grows an end-time field and becomes THE group fixture, seeded overnight (21:00 → 06:00) so the +1 day badge case is built into the sandbox from day one. Roadmap fixture note updated (playground quartet primary, sign-in trio for the dialog-hosted angle). Co-Authored-By: Claude Fable 5 --- ROADMAP-DATETIME.md | 13 +++-- .../temporal-playground.html | 48 +++++++++++-------- .../temporal-playground.ts | 19 +++++--- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md index bcb1646..5763e5e 100644 --- a/ROADMAP-DATETIME.md +++ b/ROADMAP-DATETIME.md @@ -174,11 +174,14 @@ derives `errorState = (field invalid || local invalid) && touched`. Plan: ## T5 — Range & linked fields ("they speak to each other") -**Sandbox setup exists:** the demo's sign-in dialog carries the UNLINKED -trio — date of birth, a military-time field (24 h via the `en-u-hc-h23` -locale extension, zero codec changes) and a duration — as the fixture the -group directive will be developed against; it grows into the maximal -date-range + time-range + duration composition below. (The dialog is dynamically +**Sandbox fixtures exist:** the temporal playground carries the UNLINKED +quartet — stay · start · end · length in one signal form, seeded with an +overnight stay (21:00 → 06:00, the +1-badge case) — as THE fixture the +group directive will be developed against. The sign-in dialog additionally +hosts an unlinked trio (date of birth / military time via the +`en-u-hc-h23` locale extension / duration) for the dialog-hosted form +angle. Both grow into the maximal date-range + time-range + duration +composition below. (The dialog is dynamically imported: it carries the phone metadata AND the temporal entry point, so a static import would drag both into main — it did, until it didn't.) diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 019f2bf..d9b7ede 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -97,43 +97,51 @@

Duration — seconds in, clock out

-
-

The trio — day · start · length (unlinked, the T5 fixture)

+
+

The quartet — stay · start · end · length (unlinked, the T5 fixture)

- Workshop on + Stay on - starting at + from + to + for - — one signal form, three temporal fields, start in military time (24 h via the - hc-h23 locale extension). Model: + — one signal form, four temporal fields, times in military (24 h via the hc-h23 locale + extension). Model: {{ workshopModel().day ?? '∅' }} · {{ workshopModel().starts ?? '∅' }} · - {{ workshopModel().length ?? '∅' }}s{{ stayModel().day ?? '∅' }} · {{ stayModel().starts ?? '∅' }}–{{ stayModel().ends ?? '∅' }} · + {{ stayModel().length ?? '∅' }}s.

- Deliberately NOT linked yet — this is the fixture T5's DateTimeRangeGroup grows on: date range + - time range + duration speaking to each other, the end time wearing a +1 day badge when it - crosses midnight (the airline arrival pattern). See ROADMAP-DATETIME. + Deliberately NOT linked yet — this is the fixture T5's DateTimeRangeGroup grows on: end ≥ start + over the composed datetimes, duration = end − start, day edits shifting both sides. The seed is an OVERNIGHT + stay (21:00 → 06:00): once linked, the end field wears a +1 day badge (the airline arrival + pattern) instead of reading as an error. See ROADMAP-DATETIME.

diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index fbd4e66..5af6066 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -66,24 +66,29 @@ export class TemporalPlayground { protected estimate = signal(5400); // --------------------------------------------------------------------------- - // The trio — one signal form, three temporal fields, deliberately UNLINKED: - // the T5 DateTimeRangeGroup fixture (day/start/end/duration speaking to - // each other; end times get a +1 day badge when they cross midnight). + // The quartet — stay · start · end · length in one signal form, deliberately + // UNLINKED: the T5 DateTimeRangeGroup fixture (end >= start over composed + // datetimes, duration = end − start, day edits shift both sides). Seeded + // OVERNIGHT: the end is wall-clock-earlier than the start — exactly the + // case the +1 day badge on the end field will make legible. // --------------------------------------------------------------------------- - protected workshopModel = signal<{ + protected stayModel = signal<{ /** ISO `'yyyy-MM-dd'`. */ day: string | null; /** `'HH:mm'` — shown in 24 h via the `hc-h23` locale extension. */ starts: string | null; + /** `'HH:mm'` — the future +1 badge carrier. */ + ends: string | null; /** Seconds. */ length: number | null; }>({ day: '2026-07-21', - starts: '14:00', - length: 5400, + starts: '21:00', + ends: '06:00', + length: 32_400, }); - protected workshopForm = form(this.workshopModel); + protected stayForm = form(this.stayModel); /** The page's locale toggle, pinned to 24 h — military time survives `en`. */ protected militaryLocale = computed(() => `${this.dateLocale()}-u-hc-h23`); From a7c31619f061575b24b0788b219548e19c6ddd03 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Mon, 6 Jul 2026 22:07:05 +0200 Subject: [PATCH 12/48] =?UTF-8?q?feat(temporal):=20T5=20group=20core=20?= =?UTF-8?q?=E2=80=94=20DateTimeRangeGroup=20+=20the=20+n=20day=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directive+DI decision executed: [dateTimeRangeGroup] with rangeDay/rangeStart/rangeEnd/rangeLength role directives. Controls stay group-ignorant; roles attach via DI and subscribe to saved. Propagation on commit only (value writes never emit saved — no cascades): time commits recompute the length (end at-or-before start wraps next-day), length commits move the end, day commits shift the stay. endDayOffset (duration-authoritative, else wall-clock wrap) feeds the end control's new suffix badge through INLINE_TIME_DAY_OFFSET, provided by rangeEnd on the control's own element. Playground quartet linked and browser-verified (badge +1 → 0 → +2 across commits). 123 tests. Co-Authored-By: Claude Fable 5 --- ROADMAP-DATETIME.md | 23 +- .../angular-inline-time.html | 36 ++- .../angular-inline-time.ts | 37 ++- .../src/angular-inline-time/day-offset.ts | 15 ++ .../temporal/src/public-api.ts | 2 + .../src/range-group/range-group.spec.ts | 181 +++++++++++++++ .../temporal/src/range-group/range-group.ts | 210 ++++++++++++++++++ .../temporal-playground.html | 20 +- .../temporal-playground.ts | 19 +- 9 files changed, 516 insertions(+), 27 deletions(-) create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-time/day-offset.ts create mode 100644 projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts create mode 100644 projects/angular-inline-select/temporal/src/range-group/range-group.ts diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md index 5763e5e..a9ef238 100644 --- a/ROADMAP-DATETIME.md +++ b/ROADMAP-DATETIME.md @@ -172,7 +172,28 @@ derives `errorState = (field invalid || local invalid) && touched`. Plan: - Sandbox gets a minimal copy of the adapter to develop against; iusta keeps its own. -## T5 — Range & linked fields ("they speak to each other") +## T5 — Range & linked fields ("they speak to each other") — CORE SHIPPED + +**Shipped (the group core, directive+DI — decision taken):** +`DateTimeRangeGroup` (`[dateTimeRangeGroup]`) + role directives +`rangeDay`/`rangeStart`/`rangeEnd`/`rangeLength` in the temporal entry +point. Controls stay group-ignorant; roles attach them via DI and +subscribe to `saved`. Propagation on COMMIT only (writes go through +`value`, which never emits `saved` — no cascades): start/end commits +recompute the length (end at-or-before start wraps next-day, +24 h); +length commits MOVE the end; day commits shift the stay untouched. The +`+n` badge: `endDayOffset` (duration-authoritative when present — +`21:00 + 30 h` = `+2` — else wall-clock wrap) feeds the end control via +the `INLINE_TIME_DAY_OFFSET` token, which `rangeEnd` provides on the +control's own element; the time control renders it as a suffix badge +(aria: "plus one day"), coexisting with the 🕐 affordance. Playground +quartet is linked and browser-verified. 123 tests. + +**Still open here:** Tab-advance start → end, ISO-datetime paste +decomposition, calendar drag/Ctrl+click (needs T2), the ranged +two-field date UI, the maximal end-day field, and `end >= start` +violation ERRORS (the quartet can't violate it — propagation keeps it +consistent by construction; errors become real with an end-day field). **Sandbox fixtures exist:** the temporal playground carries the UNLINKED quartet — stay · start · end · length in one signal form, seeded with an diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index 17328f7..59c23d8 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -1,17 +1,29 @@ {{ preview() }} - - - + + + @if (dayOffset() > 0) { + +{{ dayOffset() }} + } + @if (consumerSuffixTpl(); as consumer) { + + } @else if (showNativePicker()) { + + } + + @if (consumerSuffixTpl(); as consumer) { + + } @else if (showCalendar()) { + + } + + + + + diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index 9a8be48..cd1a1b9 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -387,3 +387,105 @@ describe('AngularInlineDate shape-echo', () => { expect(h.host.sessions).toEqual([{ value: { start: db('2026-12-24') }, changed: true }]); }); }); + +// ============================================================================= +// T2 — the calendar overlay (open-on-edit, draft mirror, pick paths) +// ============================================================================= + +describe('AngularInlineDate calendar (T2)', () => { + const calendar = () => document.querySelector('angular-inline-calendar'); + const grid = () => calendar()?.querySelector('.cal__grid') as HTMLElement | null; + const activeCell = () => calendar()?.querySelector('[data-active]'); + + it('opens on edit-session start WITHOUT stealing focus and mirrors the draft', async () => { + const h = setup(); + await typeText(h, '24.12.2026'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(calendar()).not.toBeNull(); + // The caret stays in the field — the grid never takes focus on open. + expect(calendar()!.contains(document.activeElement)).toBe(false); + // The grid mirrors the parseable draft per keystroke. + expect(activeCell()?.getAttribute('data-day')).toBe('2026-12-24'); + expect(calendar()!.querySelector('.cal__label')?.textContent).toContain('December'); + }); + + it('an unparseable draft leaves the last valid day standing', async () => { + const h = setup(); + await typeText(h, '24.12.2026'); + await typeText(h, 'garbage'); + h.fixture.detectChanges(); + + expect(activeCell()?.getAttribute('data-day')).toBe('2026-12-24'); + }); + + it('a pick while editing rewrites the live draft and closes the popup', async () => { + const h = setup(); + await typeText(h, '12.5.2026'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + const cell = calendar()!.querySelector('[data-day="2026-05-15"]') as HTMLElement; + cell.click(); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(h.host.field().value()).toBe(db('2026-05-15')); // live channel followed + expect(calendar()).toBeNull(); // popup collapsed + expect(h.editor()).not.toBeNull(); // session still open + }); + + it('keyboard navigation crosses month edges (the transition dance)', async () => { + const h = setup(); + await typeText(h, '31.5.2026'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + grid()!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }), + ); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(activeCell()?.getAttribute('data-day')).toBe('2026-06-01'); + expect(calendar()!.querySelector('.cal__label')?.textContent).toContain('June'); + }); + + it('Escape in the grid collapses the popup and keeps the session open', async () => { + const h = setup(); + await typeText(h, '12.5.2026'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + grid()!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + ); + h.fixture.detectChanges(); + + expect(calendar()).toBeNull(); + expect(h.editor()).not.toBeNull(); + }); + + it('idle: the 📅 affix opens the grid and a pick COMMITS immediately', async () => { + const h = setup(); + + const trigger = h.fixture.nativeElement.querySelector('.date-trigger') as HTMLElement; + trigger.click(); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(calendar()).not.toBeNull(); + expect(activeCell()?.getAttribute('data-day')).toBe('2026-05-12'); // the committed day + + (calendar()!.querySelector('[data-day="2026-05-20"]') as HTMLElement).click(); + h.fixture.detectChanges(); + + expect(h.host.saved).toEqual([db('2026-05-20')]); + expect(h.host.sessions).toEqual([{ value: db('2026-05-20'), changed: true }]); + expect(calendar()).toBeNull(); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 8d4152b..4dd0497 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -1,15 +1,22 @@ import { Component, + ElementRef, + Injector, + afterNextRender, inject, TemplateRef, + effect, input, model, output, computed, linkedSignal, + signal, viewChild, contentChild, } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { @@ -34,6 +41,7 @@ import { } from './date-codec'; import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; +import { AngularInlineCalendar } from './inline-calendar'; /** Payload of the `saved` output: one emission per settled edit session. */ export interface InlineDateSaved { @@ -60,16 +68,37 @@ export interface InlineDateSaved { */ @Component({ selector: 'angular-inline-date', - imports: [AngularInlineText], + imports: [AngularInlineText, AngularInlineCalendar, OverlayModule, NgTemplateOutlet], templateUrl: './angular-inline-date.html', styles: ` :host { display: inline; } .date-command__label { flex: 1 1 auto; text-transform: capitalize; } .date-command__value { color: var(--mat-sys-on-surface-variant, #5f6368); font-variant-numeric: tabular-nums; } .date-command__empty { padding: 4px 8px; color: var(--mat-sys-on-surface-variant, #5f6368); } + .date-trigger { + font: inherit; + line-height: 1; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + border-radius: var(--mat-sys-corner-extra-small, 0.25rem); + } + .date-trigger:focus-visible { + outline: 2px solid var(--mat-sys-primary, #4285f4); + outline-offset: 2px; + } + .date-calendar { + background: var(--mat-sys-surface-container, #fff); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.15), + 0 8px 24px rgba(0, 0, 0, 0.12); + } `, host: { '[style.display]': 'hidden() ? "none" : null', + '(keydown)': 'handleHostKeydown($event)', }, }) export class AngularInlineDate implements FormValueControl { @@ -111,6 +140,9 @@ export class AngularInlineDate implements FormValueControl { /** Enables the `/today`-style slash menu. */ showDateMenu = input(true); + /** The 📅 calendar affordance: suffix trigger + the open-on-edit popup. */ + showCalendar = input(true); + /** Reference clock — injectable for tests; a fresh `Date` per read otherwise. */ now = input<() => Date>(() => new Date()); @@ -122,7 +154,14 @@ export class AngularInlineDate implements FormValueControl { private contentSuffix = contentChild(EditableSuffix); protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); - protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); + protected consumerSuffixTpl = computed( + () => this.suffixTemplate() ?? this.contentSuffix()?.templateRef, + ); + + /** Whether the suffix slot has anything to render (consumer affix or 📅). */ + protected suffixActive = computed( + () => this.consumerSuffixTpl() !== undefined || this.showCalendar(), + ); /** @@ -251,6 +290,99 @@ export class AngularInlineDate implements FormValueControl { return end === null || end === start ? { start: day, end: day } : { start: day, end }; } + // --------------------------------------------------------------------------- + // T2 — the calendar overlay: the typed draft stays primary, the grid is + // the pointer affordance. Opens on edit-session start WITHOUT stealing + // focus (the caret stays in the field); while open it is a live MIRROR + // of the draft — a parseable draft moves the month and marks the day per + // keystroke, draft → grid only, until a pick flows back. + // --------------------------------------------------------------------------- + #injector = inject(Injector); + #host = inject(ElementRef); + + protected calendar = viewChild(AngularInlineCalendar); + + protected calendarOpen = signal(false); + protected calendarOrigin = computed(() => this.#host.nativeElement); + + protected calendarPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + ]; + + /** The grid's pending day: the parsed draft, else the committed start. */ + protected pendingDay = computed(() => { + const draft = this.parsedDraft(); + return typeof draft === 'string' ? draft : this.internalRange().start; + }); + + /** Open-on-edit (decided in T2): the session starting opens the popup. */ + #openOnEdit = effect(() => { + if (!this.showCalendar()) return; + this.calendarOpen.set(this.editing()); + }); + + /** The 📅 affix: the idle pointer path (a pick commits immediately). */ + protected toggleCalendar(event: Event) { + event.preventDefault(); + event.stopPropagation(); + + if (this.calendarOpen()) { + this.closeCalendar(); + return; + } + + this.calendarOpen.set(true); + // Explicitly invoked — the grid may take focus (unlike open-on-edit). + afterNextRender(() => this.calendar()?.focusGrid(), { injector: this.#injector }); + } + + protected closeCalendar() { + this.calendarOpen.set(false); + } + + /** + * ArrowDown in the field hands focus to the grid (the combobox-datepicker + * shape) — unless the slash menu already consumed the key. + */ + protected handleHostKeydown(event: KeyboardEvent) { + if (event.key !== 'ArrowDown' || event.defaultPrevented) return; + if (!this.calendarOpen() || !this.editing()) return; + + event.preventDefault(); + this.calendar()?.focusGrid(); + } + + /** + * A pick flows back: while editing it rewrites the live draft (field + * refocused synchronously BEFORE the popup collapses); idle it commits + * immediately (the flag-picker convention). + */ + protected pickDate(day: IsoDate) { + if (this.editing()) { + this.inner().focus(); + this.handleInnerValue(day); + this.calendarOpen.set(false); + return; + } + + const value = this.#daysToDbShape(this.#mergeDay(day), this.shape()); + if (!dateValuesEqual(value, this.value())) { + this.value.set(value); + this.savedModelChange.emit(value); + this.saved.emit({ value, changed: true }); + } + + this.calendarOpen.set(false); + this.inner().focus(); + } + + /** Escape in the grid: refocus the field FIRST, then collapse the popup. */ + protected escapeCalendar() { + this.inner().focus(); + this.calendarOpen.set(false); + } + /** Live channel: readable drafts flow into the model as DB entries, in the bound shape. */ protected handleInnerValue(raw: string) { this.innerValue.set(raw); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts new file mode 100644 index 0000000..82eb5bb --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts @@ -0,0 +1,368 @@ +import { + Component, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + input, + linkedSignal, + output, + signal, +} from '@angular/core'; + +import { toIsoDate, formatIsoDate, type IsoDate } from './date-codec'; + +interface CalendarDay { + iso: IsoDate; + day: number; + outside: boolean; + today: boolean; +} + +function parts(iso: IsoDate): [number, number, number] { + const [year, month, day] = iso.split('-').map(Number); + return [year, month, day]; +} + +function shiftDay(iso: IsoDate, days: number): IsoDate { + const [year, month, day] = parts(iso); + return toIsoDate(new Date(year, month - 1, day + days)); +} + +function shiftMonth(iso: IsoDate, months: number): IsoDate { + const [year, month, day] = parts(iso); + // Clamp to the target month's length (Jan 31 + 1 month = Feb 28/29). + const lastDay = new Date(year, month - 1 + months + 1, 0).getDate(); + return toIsoDate(new Date(year, month - 1 + months, Math.min(day, lastDay))); +} + +/** Locale-correct first day of week: JS convention (0 = Sunday). */ +function firstDayOfWeek(locale: string | string[] | undefined): number { + try { + const tag = Array.isArray(locale) ? locale[0] : locale; + const intlLocale = new Intl.Locale(tag ?? navigator.language) as Intl.Locale & { + getWeekInfo?: () => { firstDay: number }; + weekInfo?: { firstDay: number }; + }; + const info = intlLocale.getWeekInfo?.() ?? intlLocale.weekInfo; + + return (info?.firstDay ?? 1) % 7; // Intl: 1=Mon…7=Sun → JS: 0=Sun + } catch { + return 1; + } +} + +/** + * The calendar grid — the pointer affordance behind the date control's 📅 + * affix and its open-on-edit popup. HAND-ROLLED APG grid pattern (roving + * tabindex) rather than `@angular/aria` Grid: the popup spends most of its + * life as an UNFOCUSED mirror of the typed draft, and the month-transition + * focus dance is exactly where the aria pattern needs internals-poking — + * the same reasoning that hand-rolled the slash menu's combobox pattern. + * + * Keyboard (W3C APG date grid): arrows ±1 day / ±1 week ACROSS month + * edges, PageUp/PageDown ±1 month (Shift or Ctrl: ±12), Home/End to the + * month bounds, Enter/Space picks, Escape hands control back to the field. + * Localization is pure `Intl` (weekday names, month label, first day of + * week) — zero bundled translations, the phone lesson; iusta's Luxon + * adapter stays at ITS boundary. + */ +@Component({ + selector: 'angular-inline-calendar', + template: ` +
+ +
{{ monthLabel() }}
+ +
+ +
+
+ @for (name of weekdayNames(); track $index) { + {{ name }} + } +
+ @for (week of weeks(); track $index) { +
+ @for (cell of week; track cell.iso) { + + } +
+ } +
+ `, + styles: ` + :host { + display: block; + padding: 8px; + user-select: none; + } + .cal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 4px 6px; + } + .cal__label { + font: var(--mat-sys-title-small, 500 0.875rem/1.25 system-ui); + text-transform: capitalize; + } + .cal__nav { + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 4px 8px; + border-radius: var(--mat-sys-corner-small, 0.5rem); + color: var(--mat-sys-on-surface-variant, #5f6368); + } + .cal__nav:hover { background: var(--mat-sys-surface-container-highest, #eee); } + .cal__weekdays, .cal__week { + display: grid; + grid-template-columns: repeat(7, 2.1rem); + } + .cal__weekday { + text-align: center; + font: var(--mat-sys-label-small, 500 0.6875rem/1.6 system-ui); + color: var(--mat-sys-on-surface-variant, #5f6368); + padding-block: 2px; + } + .cal__day { + height: 2.1rem; + border: 0; + background: transparent; + border-radius: 50%; + cursor: pointer; + font: var(--mat-sys-body-small, 0.8125rem/1 system-ui); + color: var(--mat-sys-on-surface, #1f1f1f); + } + .cal__day:hover { background: var(--mat-sys-surface-container-highest, #eee); } + .cal__day[data-outside] { color: var(--mat-sys-outline, #999); } + .cal__day[data-today] { outline: 1px solid var(--mat-sys-outline, #999); outline-offset: -1px; } + .cal__day[data-active] { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: -2px; } + .cal__day[data-selected] { + background: var(--mat-sys-primary, #4285f4); + color: var(--mat-sys-on-primary, #fff); + } + .cal__day:focus-visible { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: 1px; } + `, +}) +export class AngularInlineCalendar { + #injector = inject(Injector); + + /** The pending day — the field's parsed draft, mirrored per keystroke. */ + activeDay = input(null); + + /** The committed day (rendered filled). */ + selectedDay = input(null); + + locale = input(undefined); + + /** Reference clock — the today marker and the empty-field fallback month. */ + now = input<() => Date>(() => new Date()); + + picked = output(); + escaped = output(); + + protected gridRef = inject>(ElementRef); + protected gridFocused = false; + + /** + * The active cell: FOLLOWS the draft mirror (`activeDay`), overridden by + * grid navigation; an unparseable draft (null source) keeps the last + * valid day standing. + */ + protected active = linkedSignal({ + source: this.activeDay, + computation: (day, previous) => day ?? previous?.value ?? toIsoDate(this.now()()), + }); + + protected weeks = computed(() => { + const [year, month] = parts(this.active()); + const first = firstDayOfWeek(this.locale()); + const today = toIsoDate(this.now()()); + + const firstOfMonth = new Date(year, month - 1, 1); + const lead = (firstOfMonth.getDay() - first + 7) % 7; + + const weeks: CalendarDay[][] = []; + const cursor = new Date(year, month - 1, 1 - lead); + for (let week = 0; week < 6; week++) { + const days: CalendarDay[] = []; + for (let day = 0; day < 7; day++) { + const iso = toIsoDate(cursor); + days.push({ + iso, + day: cursor.getDate(), + outside: cursor.getMonth() !== month - 1, + today: iso === today, + }); + cursor.setDate(cursor.getDate() + 1); + } + weeks.push(days); + } + + return weeks; + }); + + protected monthLabel = computed(() => { + const [year, month] = parts(this.active()); + try { + return new Intl.DateTimeFormat(this.locale(), { month: 'long', year: 'numeric' }).format( + new Date(year, month - 1, 1), + ); + } catch { + return `${year}-${String(month).padStart(2, '0')}`; + } + }); + + protected weekdayNames = computed(() => { + const first = firstDayOfWeek(this.locale()); + const format = (day: number) => { + try { + // 2023-01-01 was a Sunday — a stable anchor for weekday names. + return new Intl.DateTimeFormat(this.locale(), { weekday: 'narrow' }).format( + new Date(2023, 0, 1 + day), + ); + } catch { + return 'SMTWTFS'[day]; + } + }; + + return Array.from({ length: 7 }, (_, index) => format((first + index) % 7)); + }); + + protected dayAria(iso: IsoDate): string { + return formatIsoDate(iso, this.locale(), { dateStyle: 'full' }); + } + + /** Moves focus into the grid (the field's ArrowDown handoff). */ + focusGrid() { + this.#focusActiveCell(); + } + + protected moveMonths(months: number) { + this.active.set(shiftMonth(this.active(), months)); + this.#restoreFocusAfterRender(); + } + + #moveDays(days: number) { + this.active.set(shiftDay(this.active(), days)); + this.#restoreFocusAfterRender(); + } + + protected handleKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowLeft': + event.preventDefault(); + this.#moveDays(-1); + break; + case 'ArrowRight': + event.preventDefault(); + this.#moveDays(1); + break; + case 'ArrowUp': + event.preventDefault(); + this.#moveDays(-7); + break; + case 'ArrowDown': + event.preventDefault(); + this.#moveDays(7); + break; + case 'PageUp': + event.preventDefault(); + this.moveMonths(event.shiftKey || event.ctrlKey ? -12 : -1); + break; + case 'PageDown': + event.preventDefault(); + this.moveMonths(event.shiftKey || event.ctrlKey ? 12 : 1); + break; + case 'Home': { + event.preventDefault(); + const [year, month] = parts(this.active()); + this.active.set(toIsoDate(new Date(year, month - 1, 1))); + this.#restoreFocusAfterRender(); + break; + } + case 'End': { + event.preventDefault(); + const [year, month] = parts(this.active()); + this.active.set(toIsoDate(new Date(year, month, 0))); + this.#restoreFocusAfterRender(); + break; + } + case 'Enter': + case ' ': + event.preventDefault(); + this.picked.emit(this.active()); + break; + case 'Escape': + event.preventDefault(); + event.stopPropagation(); + this.escaped.emit(); + break; + } + } + + /** + * The month-transition dance: navigation may re-render the whole grid, + * destroying the focused cell — re-focus the active one after render, + * but only when the grid actually held focus (never steal it from the + * field while mirroring the draft). + */ + #restoreFocusAfterRender() { + if (!this.gridFocused) return; + + afterNextRender(() => this.#focusActiveCell(), { injector: this.#injector }); + } + + #focusActiveCell() { + const cell = this.gridRef.nativeElement.querySelector( + `[data-day="${this.active()}"]`, + ); + cell?.focus(); + } +} diff --git a/projects/angular-inline-select/temporal/src/public-api.ts b/projects/angular-inline-select/temporal/src/public-api.ts index 44ab3b3..79c04a4 100644 --- a/projects/angular-inline-select/temporal/src/public-api.ts +++ b/projects/angular-inline-select/temporal/src/public-api.ts @@ -9,6 +9,7 @@ export * from './datetime/db-entry'; export * from './leaf-state'; export * from './angular-inline-date/angular-inline-date'; export * from './angular-inline-date/date-codec'; +export * from './angular-inline-date/inline-calendar'; export * from './angular-inline-time/angular-inline-time'; export * from './angular-inline-time/time-codec'; export * from './angular-inline-time/day-offset'; From 97b9ce3ec757e99933580c9dccad0c2e486a985b Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 07:07:05 +0200 Subject: [PATCH 21/48] docs(temporal): T2b in-panel grid, the round-trip typing law, per-side range clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX corrections specced: (1) T2b — the calendar moves INTO the editing panel via a generic panelTemplate seam on angular-inline-text (the menuTemplate sibling), slim chrome without Accept/Discard (a pick COMMITS; typing commits via Enter/Ctrl+Enter), the CDK overlay goes. (2) The round-trip typing law: parse(format(v)) === v per locale — month-name and day-period parsing via Intl reverse lookup; fixes the self-inflicted wart where the session seeds the draft with a display string our own parser rejects. (3) Range clear ownership: each side owns its clear, half-open ranges are legitimate, a missing endpoint nulls the derived duration — pulls the T5 two-field ranged UI forward. Co-Authored-By: Claude Fable 5 --- ROADMAP-DATETIME.md | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md index cc75fe5..28d6a6d 100644 --- a/ROADMAP-DATETIME.md +++ b/ROADMAP-DATETIME.md @@ -151,6 +151,44 @@ BEFORE the popup collapses; a pick while editing REWRITES the live draft (session stays open), idle it COMMITS immediately. `showCalendar` input(true) opts out. +### T2b — the grid moves INTO the panel (DECIDED, next up) + +The overlay-under-the-field was the flag-picker reflex, but the flag +picker opens from IDLE — this grid lives inside an EDITING SESSION, so +it belongs where the slash menu lives: in the panel ("no second overlay, +no positioning math" — that decision already exists). Plan: +- `angular-inline-text` grows a generic `panelTemplate` input — the + `menuTemplate` sibling (capability in the core, dormant unless fed), + rendered between the editor line and the footer. The date control + feeds the calendar into it; the CDK overlay integration is DELETED. +- **Slim chrome**: a `showActions` input (default true); the date + control drops the Accept/Discard footer buttons — the user either + MOUSE-CLICKS a day (= choose = the pick COMMITS the session) or types + and commits with Enter/Ctrl+Enter as usual. Escape is naturally + two-stage: grid → editor, editor → discard. +- Grid gets a compact density (thinner cells); the panel takes a + min-width when a panel template is present (7 columns ≈ 14rem). +- The 📅 idle affix simplifies: it just OPENS the session (panel + grid + are one surface); since picks commit, idle-pick-commits-immediately + survives with less machinery. ArrowDown handoff unchanged. + +## The round-trip typing law (DECIDED — applies to every codec) + +**`parse(format(value))` must equal `value`, per locale.** Whatever the +display shows, the user must be able to type back — TODAY this is +violated by our own draft seeding: a session opens with the DISPLAY +string as the draft ('Dec 24, 2026') and the parser rejects it, forcing +a full numeric retype to change one character. +- Date: month-name parsing via `Intl` REVERSE lookup — build the + locale's month table (long + short) by formatting 12 dates, match it + AND the English names (the slash-menu matching lesson), strip weekday + tokens the same way. `'jun 07, 2024'`, `'7. Juni 2024'`, `'24.12.'` + and ISO all parse. Zero bundled translations. +- Time: day-period parsing — `'9:30 AM'` parses under `en` (extract the + locale's dayPeriod strings via `formatToParts`, shift the hour). +- Duration: already close; joins the per-locale spec matrix that pins + the law for all three codecs. + ## T2 — original spec (executed above) The typed draft stays primary; the calendar is the pointer affordance — @@ -349,7 +387,15 @@ into five inline fields. Everything below scales to that shape. The particular UX, verbatim requirements: - **Two separate editing fields** for start and end — never one combined - range input. + range input. PRIORITY PULLED FORWARD by the clear-ownership decision: + **each side owns its clear** — clearing the start emits + `{ start: null, end }`, clearing the end `{ start, end: null }`; the + other side is NEVER nuked. Half-open ranges are legitimate states + (display: `'Jul 21 – …'` / `'… – Jul 24'`); the interim single-field + range UI structurally cannot express per-side clearing, which is the + strongest argument for building the two-field UI next. Group + refinement: a missing endpoint NULLS the derived duration + (underivable) — never leave a stale one standing. - **Tab advances start → end when the draft is valid**: typing a parseable date(time) into the start field and pressing Tab commits it and moves the session to the end field in one gesture (keyboard flow mirrors the From 28544c9334d0e69e5a5f714cd88f0f745238a8a9 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 07:18:03 +0200 Subject: [PATCH 22/48] =?UTF-8?q?feat(temporal):=20the=20round-trip=20typi?= =?UTF-8?q?ng=20law=20=E2=80=94=20parse(format(v))=20=3D=3D=3D=20v=20per?= =?UTF-8?q?=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date codec: named-month parsing via Intl REVERSE lookup (locale + English, long + short, cached per locale), weekday tokens stripped the same way — 'Dec 24, 2026', 'Thursday, December 24, 2026', '24. Dezember 2026', 'jun 07, 2024' all parse; the session no longer seeds a draft its own parser rejects. Time codec: day-period parsing via formatToParts ('9:30 AM' → 09:30, '12:00 AM' → 00:00; universal am/pm spellings always accepted; overflow+meridiem is nonsense and gates). locale wired through every control parse site. Property tests pin parse(format(v)) === v for en/de across both codecs. 144 tests. Co-Authored-By: Claude Fable 5 --- .../angular-inline-date.spec.ts | 19 ++++ .../angular-inline-date.ts | 6 +- .../src/angular-inline-date/date-codec.ts | 99 ++++++++++++++++++- .../angular-inline-time.spec.ts | 17 ++++ .../angular-inline-time.ts | 6 +- .../src/angular-inline-time/time-codec.ts | 84 +++++++++++++--- 6 files changed, 212 insertions(+), 19 deletions(-) diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index cd1a1b9..e096dbc 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -51,6 +51,25 @@ describe('date codec', () => { expect(parseDateInput('soon', NOW)).toBeUndefined(); }); + it('the round-trip law: whatever the display formats, the parser accepts', () => { + // The display's own outputs (medium + full), localized and English. + expect(parseDateInput('Dec 24, 2026', NOW, 'en')).toBe('2026-12-24'); + expect(parseDateInput('Thursday, December 24, 2026', NOW, 'en')).toBe('2026-12-24'); + expect(parseDateInput('24. Dezember 2026', NOW, 'de')).toBe('2026-12-24'); + expect(parseDateInput('jun 07, 2024', NOW, 'en')).toBe('2024-06-07'); + expect(parseDateInput('7 june', NOW, 'en')).toBe('2026-06-07'); // year from now + expect(parseDateInput('december 24', NOW, 'de')).toBe('2026-12-24'); // English always matches + + // Property: parse(format(day)) === day, per locale. + for (const locale of ['en', 'de']) { + for (const day of ['2026-01-31', '2026-07-04', '2024-02-29']) { + expect(parseDateInput(formatIsoDate(day, locale), NOW, locale)).toBe(day); + } + } + + expect(parseDateInput('notamonth 12', NOW, 'en')).toBeUndefined(); + }); + it('formats ISO dates through Intl', () => { expect(formatIsoDate('2026-05-12', 'en')).toBe('May 12, 2026'); expect(formatIsoDate(null)).toBe(''); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 4dd0497..10eaded 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -242,7 +242,7 @@ export class AngularInlineDate implements FormValueControl { }); /** The current draft's ISO reading (`null` empty, `undefined` unreadable). */ - readonly parsedDraft = computed(() => parseDateInput(this.innerValue(), this.now()())); + readonly parsedDraft = computed(() => parseDateInput(this.innerValue(), this.now()(), this.locale())); /** The parse gate: whether the current draft fails the codec. Public for consumers. */ readonly parseFailed = computed(() => this.parsedDraft() === undefined); @@ -387,7 +387,7 @@ export class AngularInlineDate implements FormValueControl { protected handleInnerValue(raw: string) { this.innerValue.set(raw); - const day = parseDateInput(raw, this.now()()); + const day = parseDateInput(raw, this.now()(), this.locale()); if (day === undefined) return; const echoed = this.#daysToDbShape(this.#mergeDay(day), this.shape()); @@ -396,7 +396,7 @@ export class AngularInlineDate implements FormValueControl { /** Retype the settled session: local days inside, DB entries in the echoed shape outside. */ protected handleInnerSaved(session: InlineTextSaved) { - const day = parseDateInput(session.value, this.now()()); + const day = parseDateInput(session.value, this.now()(), this.locale()); const value = day === undefined ? this.value() diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts index 095ebf4..2fe3e81 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts @@ -89,14 +89,106 @@ function isoIfValid(year: number, month: number, day: number): IsoDate | undefin return roundTrips ? toIsoDate(date) : undefined; } +// ----------------------------------------------------------------------------- +// The round-trip typing law: `parse(format(value))` must equal `value` — +// whatever the display shows, the user can type back. Month and weekday +// names come from an `Intl` REVERSE lookup (locale + English, long + +// short forms) — zero bundled translations, the slash-menu lesson. +// ----------------------------------------------------------------------------- + +const normalizeToken = (token: string) => token.toLowerCase().replace(/[.,]+$/, ''); + +const nameTableCache = new Map; weekdays: Set }>(); + +function nameTable(locale: string | string[] | undefined) { + const key = JSON.stringify(locale ?? ''); + const cached = nameTableCache.get(key); + if (cached) return cached; + + const months = new Map(); + const weekdays = new Set(); + + for (const tag of [locale, 'en'] as const) { + for (const style of ['long', 'short'] as const) { + try { + const monthFormat = new Intl.DateTimeFormat(tag, { month: style }); + for (let month = 0; month < 12; month++) { + const name = normalizeToken(monthFormat.format(new Date(2024, month, 1))); + if (!months.has(name)) months.set(name, month + 1); + } + + const weekdayFormat = new Intl.DateTimeFormat(tag, { weekday: style }); + for (let day = 1; day <= 7; day++) { + weekdays.add(normalizeToken(weekdayFormat.format(new Date(2024, 0, day)))); + } + } catch { + // Unknown locale tag — the English pass still fills the table. + } + } + } + + const table = { months, weekdays }; + nameTableCache.set(key, table); + return table; +} + +/** `'Dec 24, 2026'`, `'24. Dezember 2026'`, `'Thursday, December 24, 2026'` … */ +function parseNamedDate( + raw: string, + now: Date, + locale: string | string[] | undefined, +): IsoDate | undefined { + const { months, weekdays } = nameTable(locale); + + const tokens = raw + .split(/[\s,]+/) + .map(normalizeToken) + .filter((token) => token.length > 0 && !weekdays.has(token)); + + let month: number | undefined; + let day: number | undefined; + let year: number | undefined; + + for (const token of tokens) { + if (months.has(token)) { + if (month !== undefined) return undefined; + month = months.get(token); + continue; + } + + if (!/^\d{1,4}$/.test(token)) return undefined; + const value = Number(token); + + if (token.length === 4) { + if (year !== undefined) return undefined; + year = value; + } else if (day === undefined) { + day = value; + } else if (year === undefined) { + year = value < 100 ? 2000 + value : value; + } else { + return undefined; + } + } + + if (month === undefined || day === undefined) return undefined; + return isoIfValid(year ?? now.getFullYear(), month, day); +} + /** * Parses a date draft into an ISO date. `''` → `null` (empty), text that is * not a calendar date → `undefined` (raises the parse gate). * * Accepted shapes: `'12.5.2026'`, `'12.5.26'` (→ 20xx), `'12.5.'` / `'12.5'` - * (current year from `now`), `'2026-05-12'`, `'12/5/2026'`. + * (current year from `now`), `'2026-05-12'`, `'12/5/2026'` — and, per the + * round-trip law, everything the display formats: `'Dec 24, 2026'`, + * `'24. Dezember 2026'`, weekday prefixes stripped. */ -export function parseDateInput(raw: string, now: Date = new Date()): IsoDate | null | undefined { +export function parseDateInput( + raw: string, + now: Date = new Date(), + locale?: string | string[], +): IsoDate | null | undefined { const trimmed = raw.trim(); if (trimmed === '') return null; @@ -119,6 +211,9 @@ export function parseDateInput(raw: string, now: Date = new Date()): IsoDate | n return isoIfValid(year, month, day); } + // Named months (the display's own format, localized + English). + if (/[\p{L}]/u.test(trimmed)) return parseNamedDate(trimmed, now, locale); + return undefined; } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts index de12d50..169bded 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -49,6 +49,23 @@ describe('time codec', () => { expect(parseTime('24:30')).toBeUndefined(); }); + it('the round-trip law: the display 12 h formats parse back', () => { + expect(parseTime('9:30 AM', 'en')).toBe('09:30'); + expect(parseTime('9:30 PM', 'en')).toBe('21:30'); + expect(parseTime('12:00 AM', 'en')).toBe('00:00'); + expect(parseTime('12:30 PM', 'en')).toBe('12:30'); + expect(parseTime('9 pm')).toBe('21:00'); // universal spellings, no locale + expect(parseTime('13:00 PM', 'en')).toBeUndefined(); // nonsense hour with meridiem + expect(parseTimeDraft('24:30 PM', 'en')).toBeUndefined(); // overflow + AM/PM is nonsense + + // Property: parse(format(time)) === time, per locale. + for (const locale of ['en', 'de']) { + for (const time of ['00:00', '09:30', '12:00', '21:05']) { + expect(parseTime(formatWallClock(time, locale), locale)).toBe(time); + } + } + }); + it('formats through Intl per locale', () => { expect(formatWallClock('09:30', 'en')).toBe('9:30 AM'); expect(formatWallClock('21:05', 'de')).toBe('21:05'); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index caeb745..5f88ce4 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -243,7 +243,7 @@ export class AngularInlineTime implements FormValueControl { }); /** The current draft's canonical reading (`null` empty, `undefined` unreadable). */ - readonly parsedDraft = computed(() => parseTimeDraft(this.innerValue())); + readonly parsedDraft = computed(() => parseTimeDraft(this.innerValue(), this.locale())); /** The parse gate: whether the current draft fails the codec. Public for consumers. */ readonly parseFailed = computed(() => this.parsedDraft() === undefined); @@ -274,7 +274,7 @@ export class AngularInlineTime implements FormValueControl { protected handleInnerValue(raw: string) { this.innerValue.set(raw); - const draft = parseTimeDraft(raw); + const draft = parseTimeDraft(raw, this.locale()); if (draft === undefined) return; const value = this.#toValue(draft); @@ -283,7 +283,7 @@ export class AngularInlineTime implements FormValueControl { /** Retype the settled session: local strings inside, DB entries outside. */ protected handleInnerSaved(session: InlineTextSaved) { - const draft = parseTimeDraft(session.value); + const draft = parseTimeDraft(session.value, this.locale()); const value = draft === undefined ? this.value() : this.#toValue(draft); const dayOverflow = draft === null || draft === undefined ? 0 : draft.days; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts index a7cc82d..9a34c84 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts @@ -31,32 +31,91 @@ function draftIfValid(hours: number, minutes: number): TimeDraft | undefined { return time === undefined ? undefined : { time, days: Math.floor(hours / 24) }; } +// The round-trip typing law: the display's day-period markers must parse +// back. The locale's own strings come from `Intl.formatToParts`; the +// universal am/pm spellings are always accepted. +const dayPeriodCache = new Map; pm: Set }>(); + +function dayPeriods(locale: string | string[] | undefined) { + const key = JSON.stringify(locale ?? ''); + const cached = dayPeriodCache.get(key); + if (cached) return cached; + + const am = new Set(['am', 'a.m.']); + const pm = new Set(['pm', 'p.m.']); + try { + const format = new Intl.DateTimeFormat(locale, { hour: 'numeric', hour12: true }); + const period = (hour: number) => + format + .formatToParts(new Date(2024, 0, 1, hour)) + .find((part) => part.type === 'dayPeriod')?.value.toLowerCase(); + + const localAm = period(9); + const localPm = period(21); + if (localAm) am.add(localAm); + if (localPm) pm.add(localPm); + } catch { + // Universal spellings remain. + } + + const table = { am, pm }; + dayPeriodCache.set(key, table); + return table; +} + /** * Parses a time draft into `{ time, days }`. `''` → `null`, non-times → * `undefined` (raises the parse gate). * * Accepted shapes: `'9'` → 09:00, `'21'` → 21:00, `'930'`/`'0930'` → 09:30, - * `'2105'` → 21:05, `'9:30'`, `'09.30'` — and OVERFLOW hours declaring the - * day over-count by hand: `'24:30'`/`'2430'` → next day 00:30, - * `'240:30'` → +10 days 00:30. Bare 1–2 digit hours stay strict (`'99'` is - * a typo, not four days). + * `'2105'` → 21:05, `'9:30'`, `'09.30'` — OVERFLOW hours declaring the day + * over-count by hand (`'24:30'`/`'2430'` → next day 00:30, `'240:30'` → + * +10 days 00:30; bare 1–2 digit hours stay strict, `'99'` is a typo) — + * and, per the round-trip law, the display's own day-period formats: + * `'9:30 AM'`, `'12:00 AM'` → 00:00, `'9 PM'` → 21:00. */ -export function parseTimeDraft(raw: string): TimeDraft | null | undefined { - const trimmed = raw.trim(); +export function parseTimeDraft( + raw: string, + locale?: string | string[], +): TimeDraft | null | undefined { + let trimmed = raw.trim(); if (trimmed === '') return null; + // Trailing day-period marker (the display's 12 h formats). + let meridiem: 'am' | 'pm' | undefined; + const periodMatch = /^(.*?)\s*(\S+\.?)$/.exec(trimmed); + if (periodMatch && /[\p{L}.]/u.test(periodMatch[2])) { + const token = periodMatch[2].toLowerCase(); + const { am, pm } = dayPeriods(locale); + if (am.has(token)) meridiem = 'am'; + else if (pm.has(token)) meridiem = 'pm'; + + if (meridiem !== undefined) trimmed = periodMatch[1].trim(); + } + + const applyMeridiem = (draft: TimeDraft | undefined): TimeDraft | undefined => { + if (draft === undefined || meridiem === undefined) return draft; + if (draft.days > 0) return undefined; // overflow + AM/PM is nonsense + + const [hours, minutes] = draft.time.split(':').map(Number); + if (hours > 12 || hours === 0) return undefined; + + const shifted = meridiem === 'pm' ? (hours % 12) + 12 : hours % 12; + return { time: `${String(shifted).padStart(2, '0')}:${draft.time.slice(-2)}`, days: 0 }; + }; + // Separated: H:mm / H.mm — hours may overflow into days (up to 3 digits). let match = /^(\d{1,3})[:.](\d{2})$/.exec(trimmed); - if (match) return draftIfValid(Number(match[1]), Number(match[2])); + if (match) return applyMeridiem(draftIfValid(Number(match[1]), Number(match[2]))); // Compact digits: H / HH / Hmm / HHmm if (/^\d{1,4}$/.test(trimmed)) { if (trimmed.length <= 2) { const time = timeIfValid(Number(trimmed), 0); - return time === undefined ? undefined : { time, days: 0 }; + return applyMeridiem(time === undefined ? undefined : { time, days: 0 }); } - return draftIfValid(Number(trimmed.slice(0, -2)), Number(trimmed.slice(-2))); + return applyMeridiem(draftIfValid(Number(trimmed.slice(0, -2)), Number(trimmed.slice(-2)))); } return undefined; @@ -67,8 +126,11 @@ export function parseTimeDraft(raw: string): TimeDraft | null | undefined { * parse gate — overflow drafts are UNDEFINED here (callers that can't * carry the day over-count must reject them). */ -export function parseTime(raw: string): WallClockTime | null | undefined { - const draft = parseTimeDraft(raw); +export function parseTime( + raw: string, + locale?: string | string[], +): WallClockTime | null | undefined { + const draft = parseTimeDraft(raw, locale); if (draft === null || draft === undefined) return draft; return draft.days === 0 ? draft.time : undefined; From fd26b92ca47013631c37df33c0c801d91508a6a7 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 07:32:00 +0200 Subject: [PATCH 23/48] =?UTF-8?q?feat(temporal):=20T2b=20=E2=80=94=20the?= =?UTF-8?q?=20calendar=20lives=20IN=20the=20panel,=20slim=20chrome,=20pick?= =?UTF-8?q?=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit angular-inline-text grows the generic panelTemplate slot (the menuTemplate sibling — capability core, activation per-consumer) and a showActions input; accept() goes public (the documented per-field submit) so a panel widget can settle the session. The date control drops the CDK overlay entirely: the grid renders between the editor line and the footer, Save/Discard are gone (a pick refocuses the field, rewrites the INNER draft synchronously — accept must not read the pre-pick draft — and COMMITS; typing commits via Enter as usual), Escape in the grid is stage one of two, the 📅 affix opens the session idle and toggles the grid in-session. 145 tests; browser-verified: grid in .editable-panel__body, no action buttons, typed 'Dec 24, 2026' (the display's own format) parsed and mirrored, pick committed. Co-Authored-By: Claude Fable 5 --- ROADMAP-DATETIME.md | 4 +- .../angular-inline-text.html | 55 +++++++---- .../angular-inline-text.ts | 22 ++++- .../angular-inline-date.html | 38 +++----- .../angular-inline-date.spec.ts | 31 ++++-- .../angular-inline-date.ts | 95 ++++++------------- 6 files changed, 124 insertions(+), 121 deletions(-) diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md index 28d6a6d..349cd65 100644 --- a/ROADMAP-DATETIME.md +++ b/ROADMAP-DATETIME.md @@ -151,7 +151,7 @@ BEFORE the popup collapses; a pick while editing REWRITES the live draft (session stays open), idle it COMMITS immediately. `showCalendar` input(true) opts out. -### T2b — the grid moves INTO the panel (DECIDED, next up) +### T2b — the grid moves INTO the panel — SHIPPED (145 tests) The overlay-under-the-field was the flag-picker reflex, but the flag picker opens from IDLE — this grid lives inside an EDITING SESSION, so @@ -172,7 +172,7 @@ no positioning math" — that decision already exists). Plan: are one surface); since picks commit, idle-pick-commits-immediately survives with less machinery. ArrowDown handoff unchanged. -## The round-trip typing law (DECIDED — applies to every codec) +## The round-trip typing law — SHIPPED (applies to every codec) **`parse(format(value))` must equal `value`, per locale.** Whatever the display shows, the user must be able to type back — TODAY this is diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index a3cde4d..2b8aaca 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -106,6 +106,17 @@
} + + @if (panelTemplate(); as panel) { +
+ +
+ } + + + +
+ } diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index f3e4c40..debc041 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -273,6 +273,21 @@ export class AngularInlineText implements FormValueControl { */ inputMode = input(undefined); + /** + * Panel body template — a widget rendered INSIDE the panel between the + * editor line and the footer (where the slash menu lives): the date + * control's calendar grid, a future color swatch, … Dormant unless + * provided; the capability is core, activation is per-consumer. + */ + panelTemplate = input | undefined>(undefined); + + /** + * Whether the panel renders the Save/Discard footer actions. Consumers + * whose panel widget IS the commit surface (a calendar pick commits) + * switch them off for slimmer chrome — keyboard commits stay untouched. + */ + showActions = input(true); + /** * Slash-command menu template — dormant unless provided. The consumer owns * the options and the search (an `@for` filtered by the live query); the @@ -675,7 +690,12 @@ export class AngularInlineText implements FormValueControl { // --------------------------------------------------------------------------- accepted = false; - protected accept() { + /** + * The per-field submit (our one honest deviation from a normal form) — + * PUBLIC so composed controls whose panel widget is the commit surface + * (a calendar pick) can settle the session programmatically. + */ + accept() { const { value, changed } = this.normalization(); if (!changed) { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 18a7fd8..0da6ae5 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -38,7 +38,7 @@ type="button" class="date-trigger" aria-label="Open calendar" - [attr.aria-expanded]="calendarOpen()" + [attr.aria-expanded]="editing() && calendarActive()" (mousedown)="$event.preventDefault()" (click)="toggleCalendar($event)" > @@ -70,6 +70,8 @@ [suffixTemplate]="suffixActive() ? suffixTpl : undefined" [hintTemplate]="preview() ? previewTpl : undefined" [menuTemplate]="showDateMenu() ? dateMenu : undefined" + [panelTemplate]="calendarActive() ? calendarPanel : undefined" + [showActions]="!showCalendar()" (touch)="touch.emit()" (saved)="handleInnerSaved($event)" > @@ -77,27 +79,17 @@ - - + + diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index e096dbc..a308dbb 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -439,7 +439,7 @@ describe('AngularInlineDate calendar (T2)', () => { expect(activeCell()?.getAttribute('data-day')).toBe('2026-12-24'); }); - it('a pick while editing rewrites the live draft and closes the popup', async () => { + it('a pick IS the choice: it rewrites the draft and COMMITS the session', async () => { const h = setup(); await typeText(h, '12.5.2026'); await h.fixture.whenStable(); @@ -451,9 +451,19 @@ describe('AngularInlineDate calendar (T2)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - expect(h.host.field().value()).toBe(db('2026-05-15')); // live channel followed - expect(calendar()).toBeNull(); // popup collapsed - expect(h.editor()).not.toBeNull(); // session still open + expect(h.host.saved).toEqual([db('2026-05-15')]); // committed, no Save button needed + expect(h.host.field().value()).toBe(db('2026-05-15')); + expect(h.editor()).toBeNull(); // session settled + expect(calendar()).toBeNull(); // panel (and grid) gone with it + }); + + it('the slim chrome: no Save/Discard buttons while the calendar is active', async () => { + const h = setup(); + await typeText(h, '12.5.2026'); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(document.querySelector('.editable-panel__actions')).toBeNull(); }); it('keyboard navigation crosses month edges (the transition dance)', async () => { @@ -473,7 +483,7 @@ describe('AngularInlineDate calendar (T2)', () => { expect(calendar()!.querySelector('.cal__label')?.textContent).toContain('June'); }); - it('Escape in the grid collapses the popup and keeps the session open', async () => { + it('Escape in the grid hands control back to the field (stage one of two)', async () => { const h = setup(); await typeText(h, '12.5.2026'); await h.fixture.whenStable(); @@ -484,11 +494,11 @@ describe('AngularInlineDate calendar (T2)', () => { ); h.fixture.detectChanges(); - expect(calendar()).toBeNull(); - expect(h.editor()).not.toBeNull(); + expect(h.editor()).not.toBeNull(); // session still open + expect(calendar()).not.toBeNull(); // the grid stays — it is part of the panel }); - it('idle: the 📅 affix opens the grid and a pick COMMITS immediately', async () => { + it('idle: the 📅 affix opens the SESSION (one surface) and a pick commits', async () => { const h = setup(); const trigger = h.fixture.nativeElement.querySelector('.date-trigger') as HTMLElement; @@ -497,11 +507,14 @@ describe('AngularInlineDate calendar (T2)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - expect(calendar()).not.toBeNull(); + expect(h.editor()).not.toBeNull(); // the affix opens the session + expect(calendar()).not.toBeNull(); // panel + grid are one surface expect(activeCell()?.getAttribute('data-day')).toBe('2026-05-12'); // the committed day (calendar()!.querySelector('[data-day="2026-05-20"]') as HTMLElement).click(); h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); expect(h.host.saved).toEqual([db('2026-05-20')]); expect(h.host.sessions).toEqual([{ value: db('2026-05-20'), changed: true }]); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 10eaded..4fd85ec 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -1,22 +1,16 @@ import { Component, - ElementRef, - Injector, - afterNextRender, inject, TemplateRef, - effect, input, model, output, computed, linkedSignal, - signal, viewChild, contentChild, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; -import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { @@ -68,7 +62,7 @@ export interface InlineDateSaved { */ @Component({ selector: 'angular-inline-date', - imports: [AngularInlineText, AngularInlineCalendar, OverlayModule, NgTemplateOutlet], + imports: [AngularInlineText, AngularInlineCalendar, NgTemplateOutlet], templateUrl: './angular-inline-date.html', styles: ` :host { display: inline; } @@ -88,13 +82,6 @@ export interface InlineDateSaved { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: 2px; } - .date-calendar { - background: var(--mat-sys-surface-container, #fff); - border-radius: var(--mat-sys-corner-medium, 0.75rem); - box-shadow: - 0 2px 6px rgba(0, 0, 0, 0.15), - 0 8px 24px rgba(0, 0, 0, 0.12); - } `, host: { '[style.display]': 'hidden() ? "none" : null', @@ -291,24 +278,22 @@ export class AngularInlineDate implements FormValueControl { } // --------------------------------------------------------------------------- - // T2 — the calendar overlay: the typed draft stays primary, the grid is - // the pointer affordance. Opens on edit-session start WITHOUT stealing - // focus (the caret stays in the field); while open it is a live MIRROR - // of the draft — a parseable draft moves the month and marks the day per - // keystroke, draft → grid only, until a pick flows back. + // T2b — the calendar lives IN the panel (where the slash menu lives): no + // second overlay, no positioning math. The typed draft stays primary and + // the grid is a live MIRROR of it — a parseable draft moves the month + // and marks the day per keystroke, draft → grid only, until a pick flows + // back. Slim chrome: no Save/Discard buttons — a pick COMMITS, typing + // commits via Enter as usual. // --------------------------------------------------------------------------- - #injector = inject(Injector); - #host = inject(ElementRef); - protected calendar = viewChild(AngularInlineCalendar); - protected calendarOpen = signal(false); - protected calendarOrigin = computed(() => this.#host.nativeElement); + /** In-session grid visibility — the 📅 affix toggles it; resets per session. */ + protected calendarVisible = linkedSignal({ + source: this.editing, + computation: () => true, + }); - protected calendarPositions: ConnectedPosition[] = [ - { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, - { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, - ]; + protected calendarActive = computed(() => this.showCalendar() && this.calendarVisible()); /** The grid's pending day: the parsed draft, else the committed start. */ protected pendingDay = computed(() => { @@ -316,29 +301,20 @@ export class AngularInlineDate implements FormValueControl { return typeof draft === 'string' ? draft : this.internalRange().start; }); - /** Open-on-edit (decided in T2): the session starting opens the popup. */ - #openOnEdit = effect(() => { - if (!this.showCalendar()) return; - this.calendarOpen.set(this.editing()); - }); - - /** The 📅 affix: the idle pointer path (a pick commits immediately). */ + /** + * The 📅 affix: idle it OPENS the session (panel + grid are one + * surface); in-session it toggles the grid. + */ protected toggleCalendar(event: Event) { event.preventDefault(); event.stopPropagation(); - if (this.calendarOpen()) { - this.closeCalendar(); + if (!this.editing()) { + this.editing.set(true); return; } - this.calendarOpen.set(true); - // Explicitly invoked — the grid may take focus (unlike open-on-edit). - afterNextRender(() => this.calendar()?.focusGrid(), { injector: this.#injector }); - } - - protected closeCalendar() { - this.calendarOpen.set(false); + this.calendarVisible.update((visible) => !visible); } /** @@ -347,40 +323,29 @@ export class AngularInlineDate implements FormValueControl { */ protected handleHostKeydown(event: KeyboardEvent) { if (event.key !== 'ArrowDown' || event.defaultPrevented) return; - if (!this.calendarOpen() || !this.editing()) return; + if (!this.editing() || !this.calendarActive()) return; event.preventDefault(); this.calendar()?.focusGrid(); } /** - * A pick flows back: while editing it rewrites the live draft (field - * refocused synchronously BEFORE the popup collapses); idle it commits - * immediately (the flag-picker convention). + * A pick IS the choice: refocus the field synchronously, rewrite the + * draft, and COMMIT the session (the panel has no Save button — mouse + * users click a day, keyboard users type and press Enter). */ protected pickDate(day: IsoDate) { - if (this.editing()) { - this.inner().focus(); - this.handleInnerValue(day); - this.calendarOpen.set(false); - return; - } - - const value = this.#daysToDbShape(this.#mergeDay(day), this.shape()); - if (!dateValuesEqual(value, this.value())) { - this.value.set(value); - this.savedModelChange.emit(value); - this.saved.emit({ value, changed: true }); - } - - this.calendarOpen.set(false); this.inner().focus(); + this.handleInnerValue(day); + // Write the INNER draft synchronously — accept() must not read the + // pre-pick draft while change detection still owes it the new value. + this.inner().value.set(day); + this.inner().accept(); } - /** Escape in the grid: refocus the field FIRST, then collapse the popup. */ + /** Escape in the grid hands control back to the field (stage one of two). */ protected escapeCalendar() { this.inner().focus(); - this.calendarOpen.set(false); } /** Live channel: readable drafts flow into the model as DB entries, in the bound shape. */ From 8063fb689e19293c83aabb3fa540ba972efd4211 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 08:41:52 +0200 Subject: [PATCH 24/48] =?UTF-8?q?feat(temporal)!:=20Luxon=20is=20the=20eng?= =?UTF-8?q?ine=20=E2=80=94=20db-entry=20core,=20calendar=20and=20date=20ma?= =?UTF-8?q?th=20on=20DateTime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the Luxon-free decision: iusta's core/datetime is Luxon end to end and T6's timezone story needs a real tz engine. db-entry.ts rewritten on DateTime (fromDateTime = toUTC().toISO(), iusta's toDBEntry verbatim) with the toDateTime/fromDateTime consumer bridge; the calendar day/month math (Luxon clamps months natively) and date-codec validity move over too. Every signature unchanged — the boundary-codec design paid off: all 145 tests pass untouched. luxon is an optional peer dep (required by /temporal, documented), contained like libphonenumber: prod-verified only in the lazy temporal chunk, never in main. Co-Authored-By: Claude Fable 5 --- ROADMAP-DATETIME.md | 7 +- package-lock.json | 18 ++++ package.json | 2 + projects/angular-inline-select/package.json | 6 +- .../src/angular-inline-date/date-codec.ts | 15 ++-- .../angular-inline-date/inline-calendar.ts | 33 +++---- .../temporal/src/datetime/db-entry.ts | 86 +++++++++---------- 7 files changed, 95 insertions(+), 72 deletions(-) diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md index 349cd65..ba5262e 100644 --- a/ROADMAP-DATETIME.md +++ b/ROADMAP-DATETIME.md @@ -62,7 +62,12 @@ Rules that make it deterministic: dt.toUTC().toISO()`; the time-entry table binds `FieldTree` = `{date, range: {start, end}, duration}`), and the sandbox now mirrors it — `temporal/src/datetime/ -db-entry.ts` is the dictating core (Luxon-free `Date` math): +db-entry.ts` is the dictating core, built ON LUXON (decided 2026-07-07: +Luxon is the house engine — iusta's core/datetime is Luxon end to end +and T6's timezone story needs a real tz engine; it ships as an optional +peer dep contained in the temporal entry point, exactly like +libphonenumber in /phone — prod-verified absent from main). Values stay +plain strings; `toDateTime`/`fromDateTime` are the consumer bridge: | Control | `value` / `savedModelChange` | Display | | --- | --- | --- | diff --git a/package-lock.json b/package-lock.json index a6b6847..98940fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", "libphonenumber-js": "^1.13.8", + "luxon": "^3.7.2", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -25,6 +26,7 @@ "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.3", + "@types/luxon": "^3.7.2", "@typescript-eslint/parser": "^8.62.1", "angular-eslint": "^22.0.0", "jsdom": "^28.0.0", @@ -4640,6 +4642,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -7670,6 +7679,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/package.json b/package.json index 55e9bf3..e1b6988 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", "libphonenumber-js": "^1.13.8", + "luxon": "^3.7.2", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -28,6 +29,7 @@ "@angular/build": "^22.0.4", "@angular/cli": "^22.0.4", "@angular/compiler-cli": "^22.0.3", + "@types/luxon": "^3.7.2", "@typescript-eslint/parser": "^8.62.1", "angular-eslint": "^22.0.0", "jsdom": "^28.0.0", diff --git a/projects/angular-inline-select/package.json b/projects/angular-inline-select/package.json index 20430cc..fa36175 100644 --- a/projects/angular-inline-select/package.json +++ b/projects/angular-inline-select/package.json @@ -7,11 +7,15 @@ "@angular/forms": "^22.0.3", "@angular/cdk": "^22.0.2", "@angular/material": "^22.0.2", - "libphonenumber-js": "^1.13.0" + "libphonenumber-js": "^1.13.0", + "luxon": "^3.0.0" }, "peerDependenciesMeta": { "libphonenumber-js": { "optional": true + }, + "luxon": { + "optional": true } }, "dependencies": { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts index 2fe3e81..3d1d231 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts @@ -5,6 +5,8 @@ * localize through `Intl` at zero bundle bytes. */ +import { DateTime } from 'luxon'; + /** `'yyyy-MM-dd'`. */ export type IsoDate = string; @@ -73,20 +75,13 @@ export function dateValuesEqual(a: InlineDateValue, b: InlineDateValue): boolean return a.start === b.start && a.end === b.end; } -const pad = (value: number) => String(value).padStart(2, '0'); - export function toIsoDate(date: Date): IsoDate { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + return DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'); } function isoIfValid(year: number, month: number, day: number): IsoDate | undefined { - if (month < 1 || month > 12 || day < 1 || day > 31) return undefined; - - const date = new Date(year, month - 1, day); - const roundTrips = - date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day; - - return roundTrips ? toIsoDate(date) : undefined; + const date = DateTime.fromObject({ year, month, day }); + return date.isValid ? date.toFormat('yyyy-MM-dd') : undefined; } // ----------------------------------------------------------------------------- diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts index 82eb5bb..9f2d2bd 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts @@ -11,6 +11,8 @@ import { signal, } from '@angular/core'; +import { DateTime } from 'luxon'; + import { toIsoDate, formatIsoDate, type IsoDate } from './date-codec'; interface CalendarDay { @@ -20,21 +22,20 @@ interface CalendarDay { today: boolean; } +const ISO_DAY = 'yyyy-MM-dd'; + function parts(iso: IsoDate): [number, number, number] { const [year, month, day] = iso.split('-').map(Number); return [year, month, day]; } function shiftDay(iso: IsoDate, days: number): IsoDate { - const [year, month, day] = parts(iso); - return toIsoDate(new Date(year, month - 1, day + days)); + return DateTime.fromISO(iso).plus({ days }).toFormat(ISO_DAY); } +// Luxon clamps month arithmetic natively (Jan 31 + 1 month = Feb 28/29). function shiftMonth(iso: IsoDate, months: number): IsoDate { - const [year, month, day] = parts(iso); - // Clamp to the target month's length (Jan 31 + 1 month = Feb 28/29). - const lastDay = new Date(year, month - 1 + months + 1, 0).getDate(); - return toIsoDate(new Date(year, month - 1 + months, Math.min(day, lastDay))); + return DateTime.fromISO(iso).plus({ months }).toFormat(ISO_DAY); } /** Locale-correct first day of week: JS convention (0 = Sunday). */ @@ -221,26 +222,26 @@ export class AngularInlineCalendar { }); protected weeks = computed(() => { - const [year, month] = parts(this.active()); + const [, month] = parts(this.active()); const first = firstDayOfWeek(this.locale()); const today = toIsoDate(this.now()()); - const firstOfMonth = new Date(year, month - 1, 1); - const lead = (firstOfMonth.getDay() - first + 7) % 7; + const firstOfMonth = DateTime.fromISO(this.active()).startOf('month'); + // Luxon weekday: 1=Mon…7=Sun → JS convention (0=Sun) for the lead math. + const lead = ((firstOfMonth.weekday % 7) - first + 7) % 7; const weeks: CalendarDay[][] = []; - const cursor = new Date(year, month - 1, 1 - lead); + let cursor = firstOfMonth.minus({ days: lead }); for (let week = 0; week < 6; week++) { const days: CalendarDay[] = []; for (let day = 0; day < 7; day++) { - const iso = toIsoDate(cursor); days.push({ - iso, - day: cursor.getDate(), - outside: cursor.getMonth() !== month - 1, - today: iso === today, + iso: cursor.toFormat(ISO_DAY), + day: cursor.day, + outside: cursor.month !== month, + today: cursor.toFormat(ISO_DAY) === today, }); - cursor.setDate(cursor.getDate() + 1); + cursor = cursor.plus({ days: 1 }); } weeks.push(days); } diff --git a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts index 05aef76..b3fe7e2 100644 --- a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts @@ -1,7 +1,11 @@ +import { DateTime } from 'luxon'; + /** * The DB-entry core — the sandbox mirror of iusta's `core/datetime` - * (`toDBEntry(dt) = dt.toUTC().toISO()`), Luxon-free. It dictates the ONE - * model format every temporal control speaks: + * (`toDBEntry(dt) = dt.toUTC().toISO()`), built ON LUXON (decided: iusta's + * datetime house engine is Luxon end to end, and T6's server-side timezone + * story needs a real tz engine). It dictates the ONE model format every + * temporal control speaks: * * value / savedModelChange = UTC ISO datetime string ('…Z') | null * display = localized local-time strings @@ -9,53 +13,57 @@ * * The difference between what the user sees and what is behind the back: * controls keep their local day/'HH:mm' machinery internally and convert - * at the value boundary through these functions only. + * at the value boundary through these functions only. Luxon itself is + * CONTAINED here (and consumed via the `toDateTime`/`fromDateTime` + * bridge) — values stay plain strings, and the engine ships only with the + * temporal entry point, exactly like libphonenumber ships only with + * `/phone`. */ /** `'2026-07-20T19:00:00.000Z'` — `datetime.toUTC().toISO()`, SQL-friendly. */ export type DbDateTime = string; -/** Parses a DB entry (or any ISO 8601 the platform accepts) into a local `Date`. */ -export function parseDbEntry(value: DbDateTime | null): Date | null { +/** The Luxon bridge, inbound: a DB entry (or any ISO 8601) as a local-zone `DateTime`. */ +export function toDateTime(value: DbDateTime | null): DateTime | null { if (value === null || value === '') return null; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; + const parsed = DateTime.fromISO(value); + return parsed.isValid ? parsed : null; } -/** The wire format: UTC ISO with `Z` — what iusta's `toDBEntry` produces. */ -export function toDbEntry(date: Date): DbDateTime { - return date.toISOString(); +/** The Luxon bridge, outbound: iusta's `toDBEntry`, verbatim. */ +export function fromDateTime(dateTime: DateTime): DbDateTime { + return dateTime.toUTC().toISO()!; } -const pad = (value: number) => String(value).padStart(2, '0'); +/** Parses a DB entry into a local `Date` (consumer convenience). */ +export function parseDbEntry(value: DbDateTime | null): Date | null { + return toDateTime(value)?.toJSDate() ?? null; +} + +/** The wire format from a JS `Date` — `toDBEntry(DateTime.fromJSDate(date))`. */ +export function toDbEntry(date: Date): DbDateTime { + return fromDateTime(DateTime.fromJSDate(date)); +} /** The LOCAL calendar day of a DB entry: `'yyyy-MM-dd'`. */ export function localDayOf(value: DbDateTime | null): string | null { - const date = parseDbEntry(value); - if (date === null) return null; - - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + return toDateTime(value)?.toFormat('yyyy-MM-dd') ?? null; } /** The LOCAL wall-clock time of a DB entry: `'HH:mm'`. */ export function localTimeOf(value: DbDateTime | null): string | null { - const date = parseDbEntry(value); - if (date === null) return null; - - return `${pad(date.getHours())}:${pad(date.getMinutes())}`; + return toDateTime(value)?.toFormat('HH:mm') ?? null; } /** Local midnight of a `'yyyy-MM-dd'` day, as a DB entry (`startOf('day')`). */ export function dayToDbEntry(day: string): DbDateTime { - const [year, month, date] = day.split('-').map(Number); - return toDbEntry(new Date(year, month - 1, date)); + return fromDateTime(DateTime.fromISO(day).startOf('day')); } /** Local end-of-day of a `'yyyy-MM-dd'` day, as a DB entry (`endOf('day')`). */ export function dayEndToDbEntry(day: string): DbDateTime { - const [year, month, date] = day.split('-').map(Number); - return toDbEntry(new Date(year, month - 1, date, 23, 59, 59, 999)); + return fromDateTime(DateTime.fromISO(day).endOf('day')); } /** @@ -64,27 +72,23 @@ export function dayEndToDbEntry(day: string): DbDateTime { * preserved time). */ export function composeDbEntry(day: string, time: string): DbDateTime { - const [year, month, date] = day.split('-').map(Number); - const [hours, minutes] = time.split(':').map(Number); - - return toDbEntry(new Date(year, month - 1, date, hours, minutes)); + const [hour, minute] = time.split(':').map(Number); + return fromDateTime(DateTime.fromISO(day).set({ hour, minute, second: 0, millisecond: 0 })); } /** Shifts a DB entry by whole seconds (`shiftFromDuration`'s primitive). */ export function shiftDbEntry(value: DbDateTime, seconds: number): DbDateTime { - const date = parseDbEntry(value); - if (date === null) return value; - - return toDbEntry(new Date(date.getTime() + seconds * 1000)); + const dateTime = toDateTime(value); + return dateTime === null ? value : fromDateTime(dateTime.plus({ seconds })); } /** Whole seconds between two DB entries (`induceFromTimeRange`'s primitive). */ export function diffDbEntrySeconds(start: DbDateTime, end: DbDateTime): number | null { - const from = parseDbEntry(start); - const to = parseDbEntry(end); + const from = toDateTime(start); + const to = toDateTime(end); if (from === null || to === null) return null; - return Math.round((to.getTime() - from.getTime()) / 1000); + return Math.round(to.diff(from, 'seconds').seconds); } /** @@ -92,14 +96,11 @@ export function diffDbEntrySeconds(start: DbDateTime, end: DbDateTime): number | * `+n` over-count, now intrinsic to the values. */ export function localDayDiff(start: DbDateTime, end: DbDateTime): number | null { - const from = parseDbEntry(start); - const to = parseDbEntry(end); + const from = toDateTime(start); + const to = toDateTime(end); if (from === null || to === null) return null; - const dayStart = new Date(from.getFullYear(), from.getMonth(), from.getDate()); - const dayEnd = new Date(to.getFullYear(), to.getMonth(), to.getDate()); - - return Math.round((dayEnd.getTime() - dayStart.getTime()) / 86_400_000); + return Math.round(to.startOf('day').diff(from.startOf('day'), 'days').days); } /** Moves `value` onto the local day of `day`, preserving its wall-clock time. */ @@ -110,8 +111,5 @@ export function moveDbEntryToDay(value: DbDateTime, day: string): DbDateTime { /** Shifts a `'yyyy-MM-dd'` LOCAL day by whole calendar days. */ export function addLocalDays(day: string, days: number): string { - const [year, month, date] = day.split('-').map(Number); - const shifted = new Date(year, month - 1, date + days); - - return `${shifted.getFullYear()}-${pad(shifted.getMonth() + 1)}-${pad(shifted.getDate())}`; + return DateTime.fromISO(day).plus({ days }).toFormat('yyyy-MM-dd'); } From 8d7efb548ec0fa32b391ed0bff0f46fc26ae5511 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 15:38:13 +0200 Subject: [PATCH 25/48] feat(temporal): finish iusta accomodation --- ROADMAP-DATETIME.md | 552 ----------- angular.json | 7 +- projects/angular-inline-select/package.json | 3 + .../angular-inline-text.html | 55 +- .../angular-inline-text.ts | 23 +- .../temporal-mat/ng-package.json | 5 + .../src/mat-form-field-adapter.spec.ts | 153 +++ .../src/mat-form-field-adapter.ts | 226 +++++ .../temporal-mat/src/public-api.ts | 10 + .../angular-inline-date.html | 206 ++-- .../angular-inline-date.spec.ts | 536 ++++++---- .../angular-inline-date.ts | 925 +++++++++++++++--- .../src/angular-inline-date/date-codec.ts | 101 +- .../angular-inline-date/inline-calendar.ts | 142 ++- .../angular-inline-duration.html | 91 +- .../angular-inline-duration.spec.ts | 101 +- .../angular-inline-duration.ts | 450 ++++++++- .../angular-inline-time.html | 136 ++- .../angular-inline-time.spec.ts | 267 +++-- .../angular-inline-time.ts | 639 ++++++++++-- .../temporal/src/datetime/db-entry.ts | 109 ++- .../temporal/src/datetime/zone.ts | 24 + .../temporal/src/public-api.ts | 1 + .../src/range-group/range-group.spec.ts | 147 ++- .../temporal/src/range-group/range-group.ts | 163 ++- .../angular-inline-select/tsconfig.lib.json | 7 +- .../angular-inline-select/tsconfig.spec.json | 4 +- .../temporal-playground.html | 168 +++- .../temporal-playground.scss | 13 + .../temporal-playground.ts | 27 + tsconfig.json | 5 +- 31 files changed, 3771 insertions(+), 1525 deletions(-) delete mode 100644 ROADMAP-DATETIME.md create mode 100644 projects/angular-inline-select/temporal-mat/ng-package.json create mode 100644 projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts create mode 100644 projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts create mode 100644 projects/angular-inline-select/temporal-mat/src/public-api.ts create mode 100644 projects/angular-inline-select/temporal/src/datetime/zone.ts diff --git a/ROADMAP-DATETIME.md b/ROADMAP-DATETIME.md deleted file mode 100644 index ba5262e..0000000 --- a/ROADMAP-DATETIME.md +++ /dev/null @@ -1,552 +0,0 @@ -# ROADMAP — the Temporal family (date · time · duration · datetime-range) - -The program for `angular-inline-date`, `angular-inline-time`, -`angular-inline-duration` and the coordinated range group — sandboxed here, -migrating to iusta's `m-editable-date-v2` / `m-editable-time` / -`m-editable-time-duration` / `m-editable-date-time` afterwards. Extends the -main [ROADMAP.md](ROADMAP.md); all its guardrails apply (codec composition -over the text core, no draft reformatting, adornments outside the editable, -the ProseMirror line). - -## DECIDED (fast-track) - -1. **Signal-forms first.** These controls are `FormValueControl`s exactly - like text/number/phone; mat-form-field hosting is an ADAPTATION applied - afterwards (T4), and ONLY the three temporal controls get it — the - existing editables never need the adapter. -2. **Own tree-shakable home.** Date, time and duration (and the future - range group) move to a secondary entry point — - `angular-inline-select/temporal` — the same mechanism as `/phone` - (own `ng-package.json`, never exported from the core barrel). New T0. -3. **`value` only — `start`/`end` inputs are dead.** No separate range - inputs (iusta date-v2's `start`/`end` do not migrate). Range-ness lives - in the VALUE SHAPE. - -## The polymorphic date value (the "hard" one, solved by shape-echo) - -```ts -type InlineDateValue = - | string // 'yyyy-MM-dd' → SINGLE date field - | { start: string | null; end?: string | null } // → RANGED (two fields) - | null; -``` - -**One canonical internal model, always:** `{ start, end }`. The external -shape is inferred on the way in and ECHOED on the way out — a codec, like -everything else here: - -| bound value | internal | UI mode | emits | -| --- | --- | --- | --- | -| `'2026-05-12'` | `{start: s, end: s}` | single field | `string` | -| `{ start }` | `{start, end: start}` | ranged | `{ start }` | -| `{ start, end }` | as-is | ranged | `{ start, end }` | -| `null` | `{null, null}` | **last seen shape** | last seen shape | - -Rules that make it deterministic: -- The consumer's binding shape IS the mode declaration. The control echoes - the shape it received and NEVER invents another one (a string-bound field - stays a single date picker; drag/Ctrl+click range gestures exist only in - object shapes). -- `{ start }` means the single-day range `[start, start]` ("end is also - start"). Emitting preserves the one-key shape until the consumer's data - actually has a distinct end. -- `null` is the only shape-ambiguous case: a `#lastShape` signal remembers - the previous non-null shape; a `ranged = input(false)` provides the - cold-start default before any value has been seen. -- Same principle later for datetime/time ranges (T5/T6). - -## Canonical primitives — REVISED: the DB-entry contract (2026-07-06) - -**Supersedes the timezone-free `'yyyy-MM-dd'`/`'HH:mm'` decision.** iusta's -`core/datetime` dictates ONE model format everywhere (`toDBEntry(dt) = -dt.toUTC().toISO()`; the time-entry table binds -`FieldTree` = `{date, range: {start, end}, -duration}`), and the sandbox now mirrors it — `temporal/src/datetime/ -db-entry.ts` is the dictating core, built ON LUXON (decided 2026-07-07: -Luxon is the house engine — iusta's core/datetime is Luxon end to end -and T6's timezone story needs a real tz engine; it ships as an optional -peer dep contained in the temporal entry point, exactly like -libphonenumber in /phone — prod-verified absent from main). Values stay -plain strings; `toDateTime`/`fromDateTime` are the consumer bridge: - -| Control | `value` / `savedModelChange` | Display | -| --- | --- | --- | -| date | UTC ISO DB entry of local `startOf('day')` (ranges: end = `endOf('day')`); shape-echo intact | localized local calendar day | -| time | UTC ISO DB entry — the instant CARRIES its day (typed `'HH:mm'` re-anchors on the value's own day; `now`'s day when empty) | local wall-clock via `Intl` | -| duration | seconds `number \| null` | `h:mm` etc. | - -**Overflow hours declare the over-count by hand:** the time codec's -`parseTimeDraft` reads hours beyond 23 as typed day overflow — `'24:30'` -(or `'2430'`) = next day 00:30, `'240:30'` = +10 days 00:30 — previewed -live (`✓ 00:30 +10 days`), composed onto the anchor day (+n), and carried -to the group via `InlineTimeSaved.dayOverflow` so an END overflow anchors -on the START's day. Bare 1–2-digit hours stay strict (`'99'` is a typo). -The anchor day is FROZEN while a session is open (linkedSignal freeze) — -the live channel writes overflow into the value, and a drifting anchor -would double-apply it. - -The difference between what the user sees and what is behind the back: -controls keep local day/`'HH:mm'` machinery internally and convert ONLY at -the value boundary. Overnight is intrinsic (an end instant on the next day -IS the +1; the badge derives via `localDayDiff`). Group propagation is -plain datetime arithmetic — the sandbox mirror of `shiftFromDuration` / -`induceFromTimeRange`; a typed END is wall-clock intent (re-anchored to -the start's day, rolled forward while at-or-before), a START commit never -re-anchors (multi-day ends survive), a DAY commit shifts both instants -preserving wall-clock + over-count. Demo: the quartet card is a -display-vs-model TABLE. Main iusta consumers to target: the time-entry -table (primary), deadline & task add-dialogs (mat-form-field, T4). - -## T0 — `angular-inline-select/temporal` entry point — SHIPPED - -Moved `angular-inline-date`/`-time`/`-duration` out of the core `src/lib` -into the `temporal/` secondary entry point (the `/phone` recipe: own -`ng-package.json`, tsconfig path, spec include globs in `angular.json` + -`tsconfig.spec/lib.json`, core barrel temporal-free; controls import the -text core via the package name). Prod-build verified: all temporal markers -live exclusively in the lazy temporal-playground chunk. - -**Shape-echo shipped, value codec only.** `InlineDateValue` + -`inferDateShape`/`toInternalRange`/`echoDateShape` in the date codec; -the control carries `#lastShape` (a `linkedSignal` over `value`), the -`ranged` cold-start input, and echoes every commit in the bound shape. -The UI stays a SINGLE field until T5: a distinct-end range displays via -`Intl.DateTimeFormat.formatRange`, and the interim edit rule is — -single-day ranges move whole with the typed day, a distinct `end` -survives a start edit, clearing empties both sides. No validity munging -(`start <= end` stays T5's job). The two-field ranged UI + calendar -gestures remain in T5. - ---- - -## T1 — `angular-inline-time` — SHIPPED (MVP) - -The third codec sibling. Typed drafts (`'9'` → 09:00, `'930'`, `'9:30'`, -`'21:05'`), sexagesimal gate, live preview via `Intl` (`✓ 9:30 AM` under -`en`, `✓ 09:30` under `de`). **Native OS picker as the affordance**: a 🕐 -suffix affix drives a visually-hidden `` — `showPicker()` -where supported (Chrome/Edge/Android), falling back to focusing the input -(iOS opens its wheels on focus). While editing, a pick replaces the draft; -idle, it commits immediately (the flag-picker decision). `step` forwards to -the native input's granularity. - -## T2 — Calendar overlay picker — SHIPPED (142 tests) - -Shipped as specced below with TWO deliberate deviations, both documented -precedents of this codebase: -- **Hand-rolled APG grid, not `@angular/aria` Grid** (the budgeted - fallback): the popup spends most of its life as an UNFOCUSED mirror of - the typed draft, and the month-transition focus dance is exactly where - the aria pattern needs `_pattern.gridBehavior` internals-poking — the - slash-menu reasoning again. `AngularInlineCalendar` owns the full APG - keyboard map (arrows across month edges, PageUp/Down ±1 month, - Shift/Ctrl ±12, Home/End month bounds, Enter/Space, Escape) with roving - tabindex; focus restores post-render ONLY when the grid held it. -- **`Intl` instead of `DateAdapter`** (the phone lesson, zero bundled - bytes): month label, weekday names, first-day-of-week via - `Intl.Locale.getWeekInfo()` (Monday fallback). iusta's Luxon adapter - stays at ITS boundary. - -The integration (the phone flag-picker pattern): 📅 suffix affix + CDK -overlay; **open-on-edit without stealing focus** — the grid mirrors the -parseable draft per keystroke (month + pending cell), unparseable drafts -leave the last valid day standing; ArrowDown hands focus to the grid -unless the slash menu consumed it; Escape and picks refocus the field -BEFORE the popup collapses; a pick while editing REWRITES the live draft -(session stays open), idle it COMMITS immediately. `showCalendar` -input(true) opts out. - -### T2b — the grid moves INTO the panel — SHIPPED (145 tests) - -The overlay-under-the-field was the flag-picker reflex, but the flag -picker opens from IDLE — this grid lives inside an EDITING SESSION, so -it belongs where the slash menu lives: in the panel ("no second overlay, -no positioning math" — that decision already exists). Plan: -- `angular-inline-text` grows a generic `panelTemplate` input — the - `menuTemplate` sibling (capability in the core, dormant unless fed), - rendered between the editor line and the footer. The date control - feeds the calendar into it; the CDK overlay integration is DELETED. -- **Slim chrome**: a `showActions` input (default true); the date - control drops the Accept/Discard footer buttons — the user either - MOUSE-CLICKS a day (= choose = the pick COMMITS the session) or types - and commits with Enter/Ctrl+Enter as usual. Escape is naturally - two-stage: grid → editor, editor → discard. -- Grid gets a compact density (thinner cells); the panel takes a - min-width when a panel template is present (7 columns ≈ 14rem). -- The 📅 idle affix simplifies: it just OPENS the session (panel + grid - are one surface); since picks commit, idle-pick-commits-immediately - survives with less machinery. ArrowDown handoff unchanged. - -## The round-trip typing law — SHIPPED (applies to every codec) - -**`parse(format(value))` must equal `value`, per locale.** Whatever the -display shows, the user must be able to type back — TODAY this is -violated by our own draft seeding: a session opens with the DISPLAY -string as the draft ('Dec 24, 2026') and the parser rejects it, forcing -a full numeric retype to change one character. -- Date: month-name parsing via `Intl` REVERSE lookup — build the - locale's month table (long + short) by formatting 12 dates, match it - AND the English names (the slash-menu matching lesson), strip weekday - tokens the same way. `'jun 07, 2024'`, `'7. Juni 2024'`, `'24.12.'` - and ISO all parse. Zero bundled translations. -- Time: day-period parsing — `'9:30 AM'` parses under `en` (extract the - locale's dayPeriod strings via `formatToParts`, shift the hour). -- Duration: already close; joins the per-locale spec matrix that pins - the law for all three codecs. - -## T2 — original spec (executed above) - -The typed draft stays primary; the calendar is the pointer affordance — -trigger = 📅 suffix affix opening a CDK overlay (the phone flag-picker -pattern; `pickDate(iso)` = the `pickCountry` analogue). - -**Open-on-edit + typed-draft sync (decided).** The calendar opens when the -editing session starts (elevation/focus), not only via the affix click — -but it must NOT steal focus: the caret stays in the field and the user just -keeps typing (this is precisely the combobox-datepicker shape of Google's -reference example — popup open, focus in the input, ArrowDown enters the -grid). While the popup is open, the grid is a live *mirror of the draft*: -a parseable draft moves the displayed month and marks that day as the -pending selection per keystroke (the calendar analogue of phone's live -interpretation preview); an unparseable draft leaves the last valid -selection standing. The draft is never rewritten by the sync — data flows -draft → grid only, until an actual pick flows back. - -**Build the grid on `@angular/aria` Grid + Material's `DateAdapter`.** -Unlike the slash menu (where aria didn't fit because focus stays in the -editor), the calendar popup legitimately TAKES focus — ArrowDown moves from -the field into the grid — so `Grid`/`GridRow`/`GridCell`/`GridCellWidget` -(+ `Combobox` where it helps) are the right primitives, per Google's own -combobox-datepicker example. `DateAdapter` + `MAT_DATE_FORMATS` give -first-day-of-week, localized day/date names and parsing (sandbox: -`provideNativeDateAdapter()`; iusta: its Luxon adapter at the boundary). - -**Budgeted shenanigans** (all present in Google's reference example — plan -for them, don't discover them): -- W3C APG boundary-crossing is manual anyway: Arrow keys across month - edges, PageUp/PageDown ±1 month (±12 with Ctrl), Home/End to month - bounds — the Grid pattern doesn't do calendar semantics for you. -- Month transitions destroy the focused cell: park focus on the grid - container, RESET the pattern's internal state - (`gridBehavior.focusBehavior.activeCell/activeCoords`), mark the target - cell (`data-focus-target`), then restore focus post-render via a - `viewChildren` + `effect` loop with a microtask cleanup to dodge circular - signal writes. -- Escape must synchronously refocus the trigger BEFORE collapsing the - popup, or focus drops to `` and the overlay misbehaves. -- Selecting a date: refocus the field synchronously before closing. - -## T3 — Native time picker polish - -Refine T1's native input: `step`/min/max attributes, the -`showPicker()` support matrix (Safari desktop lacks it — verify the focus -fallback), and whether the mat-form-field mode (T4) should render the native -input directly instead of the inline panel. - -## T4 — Mat-form-field hosting (all three controls) - -Requirement: date, time and duration must be usable INSIDE -`` (dialogs, dense forms) — while staying inline-first -elsewhere. - -**Adopt iusta's proven adapter pattern instead of inventing one.** iusta -already ships `MatFormFieldAdapterContract` (a signal contract: `isEmpty`, -`_focused`, `_disabled`, `_placeholder`, `isValid`, `shouldLabelFloat`, -`id`, `targetedControl`, `onContainerClick`, `controlType`) plus a -`MatFormFieldAdapter` directive that provides `MatFormFieldControl`, -bridges signals → the `stateChanges` Subject Material still wants, and -derives `errorState = (field invalid || local invalid) && touched`. Plan: -- The inline controls implement the contract (most members already exist - under our names — `isEmpty`, `editing`≈focused, `parseFailed`≈!isValid). -- **Presentation mode switch**: inside a mat-form-field there is no idle - display and no elevated panel — the control renders its editing surface - in place (detect via `MAT_FORM_FIELD` injection, iusta's - `isInMatFormField` precedent). This is the real work: the session - machinery (draft/commit/revert) stays, the overlay chrome goes. -- Sandbox gets a minimal copy of the adapter to develop against; iusta - keeps its own. - -## T5 — Range & linked fields ("they speak to each other") — CORE SHIPPED - -**Shipped (the group core, directive+DI — decision taken):** -`DateTimeRangeGroup` (`[dateTimeRangeGroup]`) + role directives -`rangeDay`/`rangeStart`/`rangeEnd`/`rangeLength` in the temporal entry -point. Controls stay group-ignorant; roles attach them via DI and -subscribe to `saved`. Propagation on COMMIT only (writes go through -`value`, which never emits `saved` — no cascades): start/end commits -recompute the length (end at-or-before start wraps next-day, +24 h); -length commits MOVE the end; day commits shift the stay untouched. The -`+n` badge: `endDayOffset` (duration-authoritative when present — -`21:00 + 30 h` = `+2` — else wall-clock wrap) feeds the end control via -the `INLINE_TIME_DAY_OFFSET` token, which `rangeEnd` provides on the -control's own element; the time control renders it as a suffix badge -(aria: "plus one day"), coexisting with the 🕐 affordance. Playground -quartet is linked and browser-verified. 126 tests. - -**The group speaks three composed values** (outputs, fired after commit -propagation, each stream only when ITS value changed — baselined against -the seed via a one-shot effect): `dateRangeChange` = both ends in ISO -with the over-count applied to the end DATE (`2026-07-21` at `+2` → -`{ start: '2026-07-21', end: '2026-07-23' }`), `timeRangeChange` = both -wall-clock endpoints, `durationChange` = seconds. This IS the decomposed -datetime-range value the maximal form and the iusta wrappers will -consume — the composition direction of T5's decomposition requirement. - -**Still open here:** Tab-advance start → end, ISO-datetime paste -decomposition, calendar drag/Ctrl+click (needs T2), the ranged -two-field date UI, the maximal end-day field, and `end >= start` -violation ERRORS (the quartet can't violate it — propagation keeps it -consistent by construction; errors become real with an end-day field). - -### T5b — the group IS the form control — SHIPPED - -Verified viable at the framework level first: Angular core's custom-control -scan (`initializeCustomControlStatus`) checks ALL directives on the -`[formField]` node for a `value` model — a DIRECTIVE group is a -first-class custom control, no component wrapper needed. Implementation -(136 tests, browser-verified): -- `value = model` + contract inputs - (`errors`/`disabled`/`readonly`/`touched`/`invalid`) + `touch` + - ONE `savedModelChange` per settled commit (the composed value). -- TWO boundary effects only, both equality-guarded, one-directional, - converging in a single pass: inbound (form value → leaf surfaces; - skipped when unbound AND null — a null on an unbound group is silence, - not a clear, so legacy per-leaf setups stay untouched) and outbound - (leaves' live values → group value). Commits write synchronously in - the commit handler; the mirror then finds them equal. -- Contract flows DOWN by PULL: role directives provide - `INLINE_TEMPORAL_LEAF_STATE` per leaf element (the day-offset pattern); - leaves merge it with their own inputs via computeds — zero effects, - standalone controls never see it. Range errors route to the END leaf. -- Duration is shape-echoed (`#durationInShape` linkedSignal): a - `{start, end}` binding never grows a duration key; the length leaf - still displays the derived value. -- Mixed mode THROWS at registration ([formField] on a leaf inside a - form-bound group); `[(value)]`-bound leaves inside a bound group are - undetectable and unsupported by contract. - -### T5b original design notes (executed above) - -Binding four sub-fields (day/start/end/length) is a normalization smell: -the domain value is `{start, end, duration}` (one PATCH, one DB shape; -`day` is a RENDERING of start's date part, `duration` is derived). The -Material precedent (`MatDateRangeInput`: the composite registers with -mat-form-field, inner inputs are surfaces) split the VALUE only because -Reactive Forms made object values awkward — signal forms' -`FormValueControl` doesn't, and our shape-echo date control already -carries object values. - -**Design — the directive-stays hybrid** (keeps the time-entry table's -split-across-columns layout, which a composed component can't do): -- `DateTimeRangeGroup` itself implements - `FormValueControl`; - `TemporalRangeValue = { start, end, duration? } | null` (DB entries + - seconds), SHAPE-ECHOED: a `{start, end}` binding keeps duration - internal-only; duration is always computed inside and can never - disagree with the range. -- Field-bound group ⇒ leaves are UNBOUND surfaces: the group already - writes their `value` models and collects their commits (role-directive - DI registration = the mat-form-field registration analogue); it - additionally forwards `touched`/`disabled`/`errors` down (ordering - violations target the END leaf — mat split intact). ONE - `savedModelChange` emits the composed `{start, end, duration}`. -- A leaf with its own `[formField]` inside a field-bound group THROWS at - registration (mixed mode is a bug). -- Standalone leaves keep their own contract unchanged (deadline `dueAt` - stays a lone date field). -- Stepping stone to T4: the group implementing the form contract is what - `MatFormFieldAdapterContract` wants to adapt for the deadline/task - add-dialogs ("one labeled Deadline field"). - -**Sandbox fixtures exist:** the temporal playground carries the UNLINKED -quartet — stay · start · end · length in one signal form, seeded with an -overnight stay (21:00 → 06:00, the +1-badge case) — as THE fixture the -group directive will be developed against. The sign-in dialog additionally -hosts an unlinked trio (date of birth / military time via the -`en-u-hc-h23` locale extension / duration) for the dialog-hosted form -angle. Both grow into the maximal date-range + time-range + duration -composition below. (The dialog is dynamically -imported: it carries the phone metadata AND the temporal entry point, so -a static import would drag both into main — it did, until it didn't.) - -**The destination (decided): the maximal group is date range + time range -+ duration.** The trio is only the reduced form — the full composition a -consumer can wire up is start day | end day | start time | end time | -duration, all speaking to each other: one range of datetimes decomposed -into five inline fields. Everything below scales to that shape. - -- **Day-overflow badge on the end time.** When the composed end datetime - lands on a later calendar day than the start (22:00 → 06:00 = next - day), the end-time field renders a `+1`-style badge — the airline - arrival-time pattern (`+2`, `+n` for multi-day). It is an ADORNMENT: - a suffix affix outside the contenteditable (caret-proof, - parser-invisible, described via aria), DERIVED by the group from the - date + time fields — never part of the draft and never encoded in the - `'HH:mm'` value. When no date fields participate (a pure time range), - the badge still applies with wall-clock semantics: an end at or before - the start reads as next-day. -- The badge REFRAMES the ordering invariant: `end >= start` applies to - the composed DATETIMES, not to the time fields in isolation — a - wall-clock end earlier than the start is legal exactly when the day - offset covers it, and the badge is what makes that legibly so. - -The particular UX, verbatim requirements: -- **Two separate editing fields** for start and end — never one combined - range input. PRIORITY PULLED FORWARD by the clear-ownership decision: - **each side owns its clear** — clearing the start emits - `{ start: null, end }`, clearing the end `{ start, end: null }`; the - other side is NEVER nuked. Half-open ranges are legitimate states - (display: `'Jul 21 – …'` / `'… – Jul 24'`); the interim single-field - range UI structurally cannot express per-side clearing, which is the - strongest argument for building the two-field UI next. Group - refinement: a missing endpoint NULLS the derived duration - (underivable) — never leave a stale one standing. -- **Tab advances start → end when the draft is valid**: typing a parseable - date(time) into the start field and pressing Tab commits it and moves the - session to the end field in one gesture (keyboard flow mirrors the - natural fill order; Shift+Tab returns). An unparseable draft keeps the - normal parse-gate behavior — Tab doesn't skip past an error. Combined - with T2's open-on-edit, the whole range is enterable without leaving the - keyboard: focus start → calendar mirrors typing → Tab → type end → Enter. -- **Press-hold-drag** on the calendar paints a range (port the pointer - logic of iusta's `DateRangeDragAndRelease`, which does exactly this over - MatCalendar, onto the T2 grid cells: mousedown anchors, mousemove paints - `data-in-range`, mouseup commits). -- **Ctrl+click** sets start, then end. -- **Decomposition**: pasting a full ISO datetime into either field yields - day + start time + end time + duration across the group. - -**Architecture (revised by the value decision):** the ranged date control -is SELF-CONTAINED — when the value shape is an object, the ONE control -renders two editing fields (start | end) internally and carries the whole -range in its single `value`. No group needed for date-only ranges. The -`DateTimeRangeGroup` directive survives with a narrower job: linking -SEPARATE controls (a date control + time controls + a duration control) -when a consumer composes them — still via DI, still owning the invariants: -- `end >= start` over the COMPOSED datetimes (violations = errors on the - offending field, mat split; a wall-clock-earlier end covered by the day - badge is NOT a violation); -- the day-overflow badge on the end-time field (derived, see above); -- `duration = end − start`; editing duration moves `end`; -- day edits shift both sides preserving wall-clock times; -- a full ISO datetime pasted anywhere decomposes into the group; -- both calendar overlays render the SAME range state; drag/Ctrl+click - write through the group. - -Open decision: group as directive+DI (lean, fields stay reusable — the -lean choice) vs a composed `angular-inline-datetime-range` component -(easier to drop in, less flexible). Leaning directive+DI. - -## T6 — Datetime + timezones - -`m-editable-date-time` parity: ISO 8601 with offset, iusta's -`ServerSideDatetimeConfiguration` as the source of truth for display -timezone. Deliberately last — T5's group must exist first. - -## Migration mapping (for the iusta phase, later) - -| iusta | value today | sandbox control | conversion at wrapper | -| --- | --- | --- | --- | -| m-editable-time | `TimeValue` = string | inline-time | direct | -| m-editable-time (ranged) | `TimeValue` object | T5 group | to/from `LocalFormValue` | -| m-editable-time-duration | seconds | inline-duration | direct | -| m-editable-date-v2 | Luxon `{start, end}` | inline-date / T5 group | Luxon ↔ ISO | -| m-editable-date-time | Luxon + server tz config | T6 | TBD | - -## Field notes for the next session — traps & tribal knowledge - -Everything below bit us once already or is one step from doing so. Both -repos: sandbox (`~/Documents/private-repo/angular-inline-select`) and iusta -(`~/Documents/iusta-repo/iusta-core-frontend`). - -**Environment** -- Default shell node is v14 — EVERY build/test needs - `export PATH="$HOME/.nvm/versions/node/v24.15.0/bin:$PATH"`. The preview - `launch.json` is already pinned to the node 24 binary. -- iusta `npm install` requires `--legacy-peer-deps` (ng-bootstrap peer - conflict). - -**Repo divergence (the biggest trap)** -- The two repos deliberately DIFFER in structure now: the sandbox keeps - layered `angular-inline-number/phone/...` components; iusta FLATTENED - them — its vendored `core/editables/inline/` holds ONLY the text core + - directives + phone codec/adapter, and `m-editable-number-v2` / - `m-editable-telephone-number` ARE the full implementations. When - migrating temporal, absorb into `m-` components the same way — do NOT - recreate the wrapper layer. -- Re-syncing the iusta text core from the sandbox: copy, flatten import - paths, run `eslint --fix` (prettier configs differ), and beware the iusta - specs reach inner controls via `debugElement.children[i]` DEPTH — layer - changes silently break those selectors. -- iusta's `phone-codec-loader` uses the `@Service()` decorator (exists in - this Angular 22; compiles fine — don't "fix" it to `@Injectable`). - -**Build/test mechanics** -- `ng test --include` takes SPEC-ONLY globs (`**/*.spec.ts`). A bare `**` - matches `.html`/`.scss` and explodes as "No loader configured" — that - error means bad glob, not broken code. -- Secondary entry-point specs need their own include in the test target - (`"../phone/src/**/*.spec.ts"`-style, globbed from sourceRoot) — repeat - for `temporal/` in T0, in BOTH repos' angular.json where applicable. -- THE BARREL RULE: never export a heavy adapter (libphonenumber-codec, - future date adapters) from a barrel — it silently drags the engine into - eager bundles. Verify after prod builds: engine markers (`TOO_SHORT`, - `nonGeographic`) must appear only in chunks absent from `index.html`. - Note markers can shift with minification — grep several. -- iusta's eslint `component-selector` prefix list contains `'['` which - CRASHES the rule for any selector not matching an earlier prefix - (pre-existing bug). Vendored dirs need the scoped rule-off override that - `core/editables/inline/**` already has. - -**Component-pattern invariants (violating these caused real bugs)** -- `linkedSignal` freeze pattern (`previous`, `innerValue`): freeze on - `editing()`, NEVER on field `dirty` (sticky — never thaws); pin-read - before elevating. New temporal controls must copy this exactly. -- The editing bridge must be a PUBLIC `editing = model(false)` on every - composed control (private `innerEditing` broke `[(showForm)]` on number). -- `contentChild` cannot sit on an ES-private `#field` (NG1053) — use TS - `private`. -- Content queries don't pierce re-projection: TemplateRef INPUTS are the - composition channel, contentChild is only direct-use sugar. -- Session-open resets (`#saveAttempted`) must live in the editing-edge - effect, not only in `elevate()` — external `editing.set(true)` paths - (pickers seeding drafts) bypass `elevate()`. -- `strictTemplates` vs the polymorphic `InlineDateValue`: expect friction - binding a union-typed `model()`; the precedent is number's - `model` — widen the model, keep outbound writes - narrow. - -**@angular/aria reality check** -- `ngCombobox` hard-checks `tagName === input|textarea` — dead on - contenteditable. `ngListbox` keyboard only fires with host focus — dead - for focus-stays-in-editor patterns. Both fine where the popup OWNS focus - (the T2 calendar). The Grid month-transition workaround pokes - `_pattern.gridBehavior` internals — re-verify on every @angular/aria - version bump; keep the hand-rolled-grid fallback in mind. - -**Testing/preview quirks** -- Overlay sessions close BETWEEN separate `preview_eval` calls — script - multi-step browser flows inside ONE eval. -- After programmatic `editor.textContent = x`, the caret sits at offset 0 — - slash-menu/caret-dependent tests must place the selection at the end - manually (helper exists in the phone/date specs). -- `showPicker()` needs a user gesture + secure context; Safari desktop - lacks it — the focus() fallback is the path there. - -**Deferred debts (don't lose these)** -- iusta: config-flag-inputs still uses old `m-editable-number` (1 site; - needs consumer schema refactor). P4 cleanup: legacy `.iusta-editable` - sass block + EditableWrapper/OverlayControl/EditableCore orphan audit. - Manual QA of dataset-overview/case/customer-details still pending. -- Sandbox: Safari/iOS manual pass (plaintext-only fallback, IME, caret). - -## Open questions - -1. `@angular/aria` Grid maturity — Google's own example pokes - `_pattern.gridBehavior` internals for month transitions; if that API - shifts, budget a hand-rolled roving-tabindex grid as fallback. -2. Range across months (drag near an edge → auto-advance month?) — decide - during T5. -3. Time seconds precision (`'HH:mm:ss'`) — needed anywhere in iusta? -4. Should duration join the T5 group as a *field* (editable) or a *derived - display* only? Leaning: both, consumer's choice. diff --git a/angular.json b/angular.json index 565f8e5..46f9b8c 100644 --- a/angular.json +++ b/angular.json @@ -94,7 +94,12 @@ "builder": "@angular/build:unit-test", "options": { "tsConfig": "projects/angular-inline-select/tsconfig.spec.json", - "include": ["**/*.spec.ts", "../phone/src/**/*.spec.ts", "../temporal/src/**/*.spec.ts"] + "include": [ + "**/*.spec.ts", + "../phone/src/**/*.spec.ts", + "../temporal/src/**/*.spec.ts", + "../temporal-mat/src/**/*.spec.ts" + ] } } } diff --git a/projects/angular-inline-select/package.json b/projects/angular-inline-select/package.json index fa36175..ce73019 100644 --- a/projects/angular-inline-select/package.json +++ b/projects/angular-inline-select/package.json @@ -16,6 +16,9 @@ }, "luxon": { "optional": true + }, + "@angular/material": { + "optional": true } }, "dependencies": { diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index 2b8aaca..a3cde4d 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -106,17 +106,6 @@ } - - @if (panelTemplate(); as panel) { -
- -
- } - - } + + +
diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index debc041..17caef9 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -273,21 +273,6 @@ export class AngularInlineText implements FormValueControl { */ inputMode = input(undefined); - /** - * Panel body template — a widget rendered INSIDE the panel between the - * editor line and the footer (where the slash menu lives): the date - * control's calendar grid, a future color swatch, … Dormant unless - * provided; the capability is core, activation is per-consumer. - */ - panelTemplate = input | undefined>(undefined); - - /** - * Whether the panel renders the Save/Discard footer actions. Consumers - * whose panel widget IS the commit surface (a calendar pick commits) - * switch them off for slimmer chrome — keyboard commits stay untouched. - */ - showActions = input(true); - /** * Slash-command menu template — dormant unless provided. The consumer owns * the options and the search (an `@for` filtered by the live query); the @@ -690,12 +675,8 @@ export class AngularInlineText implements FormValueControl { // --------------------------------------------------------------------------- accepted = false; - /** - * The per-field submit (our one honest deviation from a normal form) — - * PUBLIC so composed controls whose panel widget is the commit surface - * (a calendar pick) can settle the session programmatically. - */ - accept() { + /** The per-field submit (our one honest deviation from a normal form). */ + protected accept() { const { value, changed } = this.normalization(); if (!changed) { diff --git a/projects/angular-inline-select/temporal-mat/ng-package.json b/projects/angular-inline-select/temporal-mat/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts new file mode 100644 index 0000000..f1ee75d --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts @@ -0,0 +1,153 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form, required } from '@angular/forms/signals'; +import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field'; + +import { AngularInlineTime } from 'angular-inline-select/temporal'; +import { composeDbEntry } from 'angular-inline-select/temporal'; +import { InlineMatFormField } from './mat-form-field-adapter'; + +const at = (time: string) => composeDbEntry('2026-07-21', time); + +@Component({ + imports: [MatFormFieldModule, AngularInlineTime, InlineMatFormField, FormField], + template: ` + + Starts + + 24-hour time + + `, +}) +class MatHost { + model = signal(at('09:30')); + // Required, so an emptied + touched field turns the adapter's errorState. + field = form(this.model, (path) => required(path)); +} + +interface Harness { + fixture: ComponentFixture; + host: MatHost; + adapter: InlineMatFormField; + input: () => HTMLInputElement; + controlHost: () => HTMLElement; +} + +function setup(): Harness { + const fixture = TestBed.createComponent(MatHost); + fixture.detectChanges(); + + const controlHost = () => + fixture.nativeElement.querySelector('angular-inline-time') as HTMLElement; + + return { + fixture, + host: fixture.componentInstance, + adapter: fixture.debugElement + .query((el) => el.name === 'angular-inline-time')! + .injector.get(MatFormFieldControl) as InlineMatFormField, + input: () => fixture.nativeElement.querySelector('.inline-time__input') as HTMLInputElement, + controlHost, + }; +} + +describe('InlineMatFormField (the temporal-mat adapter)', () => { + let h: Harness; + + beforeEach(() => { + h = setup(); + }); + + it('registers as the form-field control: type class on the root, value rendered', () => { + const root = h.fixture.nativeElement.querySelector('mat-form-field') as HTMLElement; + expect(root.classList).toContain('mat-mdc-form-field-type-inline-temporal'); + expect(h.input().value).toBe('09:30'); + expect(h.fixture.nativeElement.textContent).toContain('Starts'); + }); + + it('derives empty/float state from the control signals — mat-ignorantly', () => { + expect(h.adapter.empty).toBe(false); + expect(h.adapter.shouldLabelFloat).toBe(true); + + h.host.model.set(null); + h.fixture.detectChanges(); + + expect(h.adapter.empty).toBe(true); + expect(h.adapter.shouldLabelFloat).toBe(false); // unfocused + empty + }); + + it('applies the generic BARE-CHROME classes: no own underline, placeholder deferred to the label', () => { + expect(h.controlHost().classList).toContain('inline-field-bare'); + // Value present → label floats → placeholder may show. + expect(h.controlHost().classList).not.toContain('inline-field-bare--hide-placeholder'); + + h.host.model.set(null); + h.fixture.detectChanges(); + expect(h.controlHost().classList).toContain('inline-field-bare--hide-placeholder'); + }); + + it('errorState mirrors the control errorsVisible verdict and pokes stateChanges', () => { + let pokes = 0; + const subscription = h.adapter.stateChanges.subscribe(() => pokes++); + + expect(h.adapter.errorState).toBe(false); + + // Required + emptied + touched → the field says errors show. + h.host.model.set(null); + h.host.field().markAsTouched(); + h.fixture.detectChanges(); + + expect(h.adapter.errorState).toBe(true); + expect(pokes).toBeGreaterThan(0); + subscription.unsubscribe(); + }); + + it('a chrome click focuses when idle, then TOGGLES the panel — never close-and-reopen', async () => { + const container = h.fixture.nativeElement.querySelector('mat-form-field') as HTMLElement; + const chromeClick = () => { + const event = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(event, 'target', { value: container }); + h.adapter.onContainerClick(event); + h.fixture.detectChanges(); + }; + + // Idle: the click focuses (and the session opens on focusin). + chromeClick(); + expect(document.activeElement).toBe(h.input()); + + // A visible panel needs something to say — type a draft. + h.input().value = '9'; + h.input().dispatchEvent(new Event('input', { bubbles: true })); + h.fixture.detectChanges(); + expect(document.querySelector('.inline-time__panel')).not.toBeNull(); + + // Focused: the chrome click TOGGLES — close, then reopen. + chromeClick(); + expect(document.querySelector('.inline-time__panel')).toBeNull(); + chromeClick(); + expect(document.querySelector('.inline-time__panel')).not.toBeNull(); + + h.input().blur(); + await new Promise((resolve) => setTimeout(resolve)); + h.fixture.detectChanges(); + }); + + it('container CHROME mousedowns are prevented — the session must not blur away', () => { + const container = h.fixture.nativeElement.querySelector('mat-form-field') as HTMLElement; + + // Chrome (outside the control host): prevented, focus survives. + const chrome = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + container.dispatchEvent(chrome); + expect(chrome.defaultPrevented).toBe(true); + + // The control's own input: untouched — the caret needs it. + const own = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + h.input().dispatchEvent(own); + expect(own.defaultPrevented).toBe(false); + }); + + it('describes the input with the form-field hint ids', () => { + // Material calls setDescribedByIds with the mat-hint id after render. + expect(h.input().getAttribute('aria-describedby')).toContain('mat-mdc-hint'); + }); +}); diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts new file mode 100644 index 0000000..8749468 --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts @@ -0,0 +1,226 @@ +import { + DestroyRef, + Directive, + ElementRef, + Injector, + afterNextRender, + computed, + effect, + inject, + untracked, + type OnDestroy, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { _IdGenerator } from '@angular/cdk/a11y'; +import { MatFormFieldControl } from '@angular/material/form-field'; + +import { + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, +} from 'angular-inline-select/temporal'; + +/** + * The signal surface the adapter leans on — nothing beyond what the + * temporal controls ALREADY expose as `FormValueControl`s plus their public + * presentational verdicts. Structural on purpose: the controls implement no + * adapter interface, import nothing from this entry point, and stay + * entirely mat-ignorant (the deliberate inversion of iusta's adapter, where + * the control itself injects the form field and branches on it). + */ +type InlineTemporalControl = AngularInlineDate | AngularInlineTime | AngularInlineDuration; + +/** + * Hosts an inline temporal control inside ``: + * + * ```html + * + * Deadline + * + * + * ``` + * + * ALL Material knowledge lives here — the directive provides + * `MatFormFieldControl`, derives every member from the control's public + * signals, and bridges them into the `stateChanges` Subject Material still + * wants. The control's own chrome rests via the generic BARE-CHROME host + * classes (a container seam, not a mat one — dense table cells can use the + * same classes). + */ +@Directive({ + selector: + 'angular-inline-date[inlineMatFormField], angular-inline-time[inlineMatFormField], angular-inline-duration[inlineMatFormField]', + providers: [{ provide: MatFormFieldControl, useExisting: InlineMatFormField }], + host: { + class: 'inline-field-bare', + '[class.inline-field-bare--hide-placeholder]': '!shouldLabelFloat', + '[attr.id]': 'id', + }, +}) +export class InlineMatFormField implements MatFormFieldControl, OnDestroy { + readonly #control: InlineTemporalControl; + readonly #element = inject>(ElementRef); + + /** Signals → the Subject Material still wants (it runs its own CD off this). */ + readonly stateChanges = new Subject(); + + readonly id = inject(_IdGenerator).getId('inline-mat-field-'); + + /** Signal forms, not Reactive Forms — Material reads `errorState` instead. */ + readonly ngControl = null; + + /** `mat-form-field-type-inline-temporal` lands on the form-field root. */ + readonly controlType = 'inline-temporal'; + + /** The host is a wrapper, not a native input — no `label[for]` wiring. */ + readonly disableAutomaticLabeling = true; + + #describedBy: string[] = []; + + /** + * Panel state SNAPSHOTTED at the chrome mousedown: by the time the + * click's `onContainerClick` runs, the CDK outside-click dispatcher + * (document capture) has ALREADY dismissed an open panel — reading live + * state there would re-open it, the exact close-reopen flicker this + * adapter exists to prevent. + */ + #panelWasOpen = false; + + constructor() { + const control = + inject(AngularInlineDate, { optional: true, self: true }) ?? + inject(AngularInlineTime, { optional: true, self: true }) ?? + inject(AngularInlineDuration, { optional: true, self: true }); + if (control === null) { + throw new Error( + 'inlineMatFormField must sit on an angular-inline-date/-time/-duration element.', + ); + } + this.#control = control; + + // Container CHROME must not steal focus: a mousedown on the box's + // padding/label/outline would blur the input, settle the session and + // close the panel — and the click's `onContainerClick` would then + // refocus and REOPEN it (the close-reopen flicker). Preventing the + // chrome mousedown keeps the session alive, so the click below can be + // an honest TOGGLE. The control's own surfaces (inside our host) keep + // their native behavior. + const injector = inject(Injector); + const destroyRef = inject(DestroyRef); + afterNextRender( + () => { + const host = this.#element.nativeElement; + const container = host.closest('mat-form-field'); + if (container === null) return; + + const guard = (event: Event) => { + if (host.contains(event.target as Node)) return; + this.#panelWasOpen = this.#control.panelVisible(); + event.preventDefault(); + }; + container.addEventListener('mousedown', guard); + destroyRef.onDestroy(() => container.removeEventListener('mousedown', guard)); + }, + { injector }, + ); + + // One equality-guarded snapshot of everything Material renders from; + // any change pokes stateChanges exactly once (the iusta bridge idea, + // minus the control coupling). + const snapshot = computed(() => ({ + value: this.#control.value(), + focused: this.#control.editing(), + empty: this.#control.isEmpty(), + required: this.#control.required(), + disabled: this.#control.effectiveDisabled(), + errorState: this.#control.errorsVisible(), + placeholder: this.#placeholder(), + })); + effect(() => { + snapshot(); + untracked(() => this.stateChanges.next()); + }); + } + + get value(): unknown { + return this.#control.value(); + } + + /** Date resolves its own default (the locale pattern) — read the verdict, not the input. */ + #placeholder(): string { + return this.#control instanceof AngularInlineDate + ? this.#control.effectivePlaceholder() + : this.#control.placeholder(); + } + + get placeholder(): string { + return this.#placeholder(); + } + + get focused(): boolean { + return this.#control.editing(); + } + + get empty(): boolean { + return this.#control.isEmpty(); + } + + get shouldLabelFloat(): boolean { + return this.focused || !this.empty; + } + + get required(): boolean { + return this.#control.required(); + } + + get disabled(): boolean { + return this.#control.effectiveDisabled(); + } + + get errorState(): boolean { + return this.#control.errorsVisible(); + } + + get describedByIds(): string[] { + return [...this.#describedBy]; + } + + /** Hint/error ids land on the input surfaces (the host is a wrapper). */ + setDescribedByIds(ids: string[]): void { + this.#describedBy = ids; + const inputs = + this.#element.nativeElement.querySelectorAll('input[type="text"]'); + for (const input of inputs) { + if (ids.length > 0) input.setAttribute('aria-describedby', ids.join(' ')); + else input.removeAttribute('aria-describedby'); + } + } + + /** + * The container click is the 📅-icon gesture writ large: unfocused it + * opens (focus starts the session, the panel follows), focused it + * TOGGLES the panel. Clicks landing on the control's own surfaces are + * ignored here — the control already handled them. + */ + onContainerClick(event: MouseEvent): void { + if (this.#element.nativeElement.contains(event.target as Node)) return; + + const wasOpen = this.#panelWasOpen; + this.#panelWasOpen = false; + + if (!this.focused) { + this.#control.focus(); + return; + } + + // A panel that was open at mousedown has ALREADY been closed by the + // overlay's own outside-click — that WAS the toggle's close half. + if (wasOpen) return; + + this.#control.togglePanel(); + } + + ngOnDestroy(): void { + this.stateChanges.complete(); + } +} diff --git a/projects/angular-inline-select/temporal-mat/src/public-api.ts b/projects/angular-inline-select/temporal-mat/src/public-api.ts new file mode 100644 index 0000000..1c781c2 --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/src/public-api.ts @@ -0,0 +1,10 @@ +/* + * Public API Surface of angular-inline-select/temporal-mat + * + * Secondary entry point: THE ONLY mat-aware code in the package. Apps that + * never host temporal controls inside carry zero Material + * bytes from us, and @angular/material stays an optional peer — the same + * containment as libphonenumber in /phone and Luxon in /temporal. + */ + +export * from './mat-form-field-adapter'; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 0da6ae5..5560b31 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -1,95 +1,149 @@ - -{{ preview() }} - - -
- @for (command of commandOptions(query); track command.id) { -
- {{ command.label }} - {{ command.iso }} -
- } @empty { -
- } -
-
+ + @if (prefixTpl(); as tpl) { + + } - - - @if (consumerSuffixTpl(); as consumer) { - + + + @if (twoFields()) { + + + } + + @if (consumerSuffixTpl(); as tpl) { + } @else if (showCalendar()) { } - + + + {{ revertNotice() }} + - - - +
+ @if (preview(); as reading) { +
{{ reading }}
+ } - - - + @if (showCalendar()) { + + } + + @if (quickPickList().length > 0) { +
+ @for (command of quickPickList(); track command.id) { + + } +
+ } + +
+ +
+
diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index a308dbb..5968c64 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -13,10 +13,10 @@ import { toInternalRange, echoDateShape, dateValuesEqual, + localeDatePlaceholder, type InlineDateValue, } from './date-codec'; -import { dayToDbEntry, dayEndToDbEntry } from '../datetime/db-entry'; -import { AngularInlineText } from 'angular-inline-select'; +import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; // The value contract: UTC ISO DB entries (local startOf/endOf day) behind, // localized calendar days in front. Expectations compose through the same @@ -44,6 +44,15 @@ describe('date codec', () => { expect(parseDateInput('12.5', NOW)).toBe('2026-05-12'); }); + it('a FULL ISO datetime decomposes to its LOCAL day (the paste gesture)', () => { + expect(parseDateInput('2026-05-12T08:00', NOW)).toBe('2026-05-12'); + expect(parseDateInput('2026-05-12 08:00', NOW)).toBe('2026-05-12'); + // Zoned instants read in the LOCAL zone — expectation composed, TZ-independent. + expect(parseDateInput('2026-05-12T21:00:00.000Z', NOW)).toBe( + localDayOf('2026-05-12T21:00:00.000Z'), + ); + }); + it('empty is null; impossible calendar dates and garbage are undefined', () => { expect(parseDateInput('', NOW)).toBeNull(); expect(parseDateInput('31.2.2026', NOW)).toBeUndefined(); @@ -75,6 +84,14 @@ describe('date codec', () => { expect(formatIsoDate(null)).toBe(''); }); + it('derives the placeholder pattern from the locale — fixed size, no tables', () => { + expect(localeDatePlaceholder('de')).toBe('dd.mm.yyyy'); + expect(localeDatePlaceholder('en')).toBe('mm/dd/yyyy'); + expect(localeDatePlaceholder('en-GB')).toBe('dd/mm/yyyy'); + // An unknown tag throws inside Intl — the ISO fallback stands. + expect(localeDatePlaceholder('no-such-tag-!!')).toBe('yyyy-mm-dd'); + }); + it('builds relative + weekday commands with localized and English matching', () => { const commands = buildDateCommands(NOW, 'de'); @@ -156,7 +173,7 @@ describe('date shape-echo codec', () => { }); // ============================================================================= -// Component +// Component — the input rehost: real inputs, gesture-tiered sessions // ============================================================================= @Component({ @@ -180,344 +197,433 @@ class DateFormHost { sessions: InlineDateSaved[] = []; } -interface Harness { +@Component({ + imports: [AngularInlineDate], + template: ` + + `, +}) +class DateShapeHost { + value = signal(null); + ranged = signal(false); + placeholder = signal(undefined); + now = () => NOW; + + saved: InlineDateValue[] = []; + sessions: InlineDateSaved[] = []; +} + +interface Harness { fixture: ComponentFixture; host: T; - display: () => HTMLElement; - editor: () => HTMLElement | null; - inner: () => AngularInlineText; + inputs: () => HTMLInputElement[]; + start: () => HTMLInputElement; + end: () => HTMLInputElement | undefined; + panel: () => HTMLElement | null; } function setupHost(type: Type): Harness { const fixture = TestBed.createComponent(type); fixture.detectChanges(); + const inputs = () => + [...fixture.nativeElement.querySelectorAll('.inline-date__input')] as HTMLInputElement[]; + return { fixture, host: fixture.componentInstance, - display: () => fixture.nativeElement.querySelector('.editable-text__display') as HTMLElement, - editor: () => document.querySelector('.editable-text__editor') as HTMLElement | null, - inner: () => - fixture.debugElement.children[0].children[0].componentInstance as AngularInlineText, + inputs, + start: () => inputs()[0], + end: () => inputs()[1], + panel: () => document.querySelector('.inline-date__panel') as HTMLElement | null, }; } -const setup = () => setupHost(DateFormHost); - -async function typeText(h: Harness, text: string) { - const display = h.display(); - - const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; - Object.defineProperty(event, 'inputType', { value: 'insertText' }); - Object.defineProperty(event, 'data', { value: 'x' }); - - display.dispatchEvent(event); +/** Focus settlement runs a macrotask behind (`setTimeout(0)`) — flush it. */ +async function settle(h: Harness) { h.fixture.detectChanges(); - await h.fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); h.fixture.detectChanges(); +} - const editor = h.editor(); - if (!editor) throw new Error('elevated editor not found'); - - editor.textContent = text; - - // Caret at the end, as real typing would leave it (the slash menu reads it) - const selection = document.getSelection(); - const range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection?.removeAllRanges(); - selection?.addRange(range); +function focusInput(h: Harness, input: HTMLInputElement) { + input.focus(); + h.fixture.detectChanges(); +} - editor.dispatchEvent(new Event('input', { bubbles: true })); +function type(h: Harness, input: HTMLInputElement, text: string) { + focusInput(h, input); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); h.fixture.detectChanges(); } -function accept(h: Harness) { - (h.inner() as unknown as { accept(): void }).accept(); +function press(h: Harness, input: HTMLInputElement, key: string) { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); h.fixture.detectChanges(); } -describe('AngularInlineDate', () => { - let h: Harness; +async function blurAway(h: Harness) { + (document.activeElement as HTMLElement | null)?.blur(); + await settle(h); +} + +function gridCell(day: string): HTMLElement | null { + return document.querySelector(`.inline-date__panel [data-day="${day}"]`); +} + +describe('AngularInlineDate (input rehost)', () => { + let h: Harness; beforeEach(() => { - h = setup(); + h = setupHost(DateFormHost); }); - it('renders the committed ISO date localized', () => { - expect(h.display().textContent).toBe('May 12, 2026'); + afterEach(async () => { + await blurAway(h); + }); + + it('renders the committed date in ONE real input (string shape)', () => { + expect(h.inputs().length).toBe(1); + expect(h.start().value).toBe('May 12, 2026'); + }); + + it('focus opens the panel WITHOUT stealing focus; the grid mirrors the draft', async () => { + focusInput(h, h.start()); + + expect(h.panel()).not.toBeNull(); + expect(document.activeElement).toBe(h.start()); + + type(h, h.start(), '24.12.2026'); + expect(gridCell('2026-12-24')?.getAttribute('data-active')).toBe('true'); + + // An unparseable draft leaves the last valid day standing. + type(h, h.start(), '24.12.2026x'); + expect(gridCell('2026-12-24')?.getAttribute('data-active')).toBe('true'); }); - it('commits typed drafts as ISO with a full-reading preview', async () => { - await typeText(h, '24.12.2026'); + it('Enter commits the typed draft with a full-reading preview, and closes the panel', async () => { + type(h, h.start(), '24.12.2026'); - const hint = document.querySelector('.editable-panel__message--hint'); - expect(hint?.textContent?.trim()).toBe('✓ Thursday, December 24, 2026'); + expect(document.querySelector('.inline-date__preview')?.textContent?.trim()).toBe( + '✓ Thursday, December 24, 2026', + ); - accept(h); + press(h, h.start(), 'Enter'); expect(h.host.saved).toEqual([db('2026-12-24')]); expect(h.host.sessions).toEqual([{ value: db('2026-12-24'), changed: true }]); - expect(h.display().textContent).toBe('Dec 24, 2026'); + expect(h.start().value).toBe('Dec 24, 2026'); + expect(h.panel()).toBeNull(); + // Focus stays — Enter never traps NOR moves it. + expect(document.activeElement).toBe(h.start()); }); - it('the parse gate blocks impossible dates', async () => { - await typeText(h, '31.2.2026'); - accept(h); + it('the parse gate blocks Enter on an unreadable draft', () => { + type(h, h.start(), '31.2.2026'); + press(h, h.start(), 'Enter'); expect(h.host.saved).toEqual([]); + expect(h.host.sessions).toEqual([]); expect(h.host.field().value()).toBe(db('2026-05-12')); + expect(h.start().getAttribute('aria-invalid')).toBe('true'); }); - it('the /tomorrow slash command inserts the resolved ISO date', async () => { - await typeText(h, '/tomo'); - await h.fixture.whenStable(); - h.fixture.detectChanges(); + it('blur with an unreadable draft SNAPS BACK to the baseline — never traps, never commits', async () => { + // A readable intermediate wrote live; the garbage suffix must still + // revert to the SESSION baseline, not the intermediate. + type(h, h.start(), '24.12.2026'); + expect(h.host.field().value()).toBe(db('2026-12-24')); // live channel + type(h, h.start(), '24.12.2026x'); + await blurAway(h); - const options = [...document.querySelectorAll('.editable-menu [role="option"]')]; - expect(options.length).toBe(1); - expect(options[0].textContent).toContain('2026-05-13'); + expect(h.host.field().value()).toBe(db('2026-05-12')); + expect(h.start().value).toBe('May 12, 2026'); + expect(h.host.saved).toEqual([]); + expect(h.host.sessions).toEqual([{ value: db('2026-05-12'), changed: false }]); + expect(h.panel()).toBeNull(); + }); - (options[0] as HTMLElement).click(); - h.fixture.detectChanges(); + it('blur with a readable draft COMMITS (navigation is never a validity checkpoint)', async () => { + type(h, h.start(), '24.12.2026'); + await blurAway(h); + + expect(h.host.saved).toEqual([db('2026-12-24')]); + expect(h.host.sessions).toEqual([{ value: db('2026-12-24'), changed: true }]); + }); + + it('Escape reverts to the session baseline and closes the panel', () => { + type(h, h.start(), '24.12.2026'); + press(h, h.start(), 'Escape'); + + expect(h.host.field().value()).toBe(db('2026-05-12')); + expect(h.start().value).toBe('May 12, 2026'); + expect(h.host.saved).toEqual([]); + expect(h.panel()).toBeNull(); + }); + + it('clearing the field commits null', async () => { + type(h, h.start(), ''); + press(h, h.start(), 'Enter'); - expect(h.editor()?.textContent).toBe('2026-05-13'); - // The preview now interprets the inserted date - expect(document.querySelector('.editable-panel__message--hint')?.textContent?.trim()).toBe( - '✓ Wednesday, May 13, 2026', + expect(h.host.field().value()).toBeNull(); + expect(h.host.saved).toEqual([null]); + }); + + it('ArrowDown hands focus to the grid; a pick COMMITS; grid Escape hands it back', async () => { + focusInput(h, h.start()); + press(h, h.start(), 'ArrowDown'); + + const active = document.activeElement as HTMLElement; + expect(active.getAttribute('data-day')).toBe('2026-05-12'); + + active.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), ); + h.fixture.detectChanges(); + expect(document.activeElement).toBe(h.start()); + + const cell = gridCell('2026-05-20')!; + cell.click(); + await settle(h); + + expect(h.host.saved).toEqual([db('2026-05-20')]); + expect(h.start().value).toBe('May 20, 2026'); + expect(h.panel()).toBeNull(); + expect(document.activeElement).toBe(h.start()); + }); + + it('a quick-pick chip commits its resolved date', async () => { + focusInput(h, h.start()); + + const chips = [...document.querySelectorAll('.inline-date__quick-pick')]; + expect(chips.length).toBe(3); + + (chips[2] as HTMLElement).click(); // tomorrow + await settle(h); + + expect(h.host.saved).toEqual([db('2026-05-13')]); }); }); // ============================================================================= -// Polymorphic value — the shape-echo (ROADMAP-DATETIME.md) +// The two-field range — shape-echo, Tab-advance, per-side clear // ============================================================================= @Component({ imports: [AngularInlineDate], template: ` - + `, }) -class DateShapeHost { - value = signal(null); - ranged = signal(false); +class ZonedDateHost { + value = signal(dayToDbEntry('2026-07-21', 'Asia/Tokyo')); now = () => NOW; - - sessions: InlineDateSaved[] = []; } -describe('AngularInlineDate shape-echo', () => { - async function commitDraft(h: Harness, text: string) { - await typeText(h, text); - accept(h); - } +describe('AngularInlineDate with a display zone (T6)', () => { + it('speaks the ZONE calendar day at the value boundary', () => { + const h = setupHost(ZonedDateHost); - it('a string binding stays a string: single in, single out', async () => { - const h = setupHost(DateShapeHost); - h.host.value.set(db('2026-05-12')); - h.fixture.detectChanges(); + // Tokyo's Jul 21 — whatever day the machine zone thinks this instant is. + expect(h.start().value).toBe('Jul 21, 2026'); - await commitDraft(h, '24.12.2026'); + type(h, h.start(), '24.12.2026'); + press(h, h.start(), 'Enter'); - expect(h.host.value()).toBe(db('2026-12-24')); + expect(h.host.value()).toBe(dayToDbEntry('2026-12-24', 'Asia/Tokyo')); + h.start().blur(); }); +}); - it('{ start } echoes one-key: the single-day range moves whole', async () => { - const h = setupHost(DateShapeHost); - h.host.value.set({ start: db('2026-05-12') }); - h.fixture.detectChanges(); - - expect(h.display().textContent).toBe('May 12, 2026'); +describe('AngularInlineDate two-field range', () => { + let h: Harness; - await commitDraft(h, '24.12.2026'); + beforeEach(() => { + h = setupHost(DateShapeHost); + }); - expect(h.host.value()).toEqual({ start: db('2026-12-24') }); + afterEach(async () => { + await blurAway(h); }); - it('{ start, end } equal moves both sides with the typed day', async () => { - const h = setupHost(DateShapeHost); - h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-12') }); + it('a string binding renders one field; object shapes render the pair', () => { + h.host.value.set(db('2026-05-12')); h.fixture.detectChanges(); + expect(h.inputs().length).toBe(1); - await commitDraft(h, '24.12.2026'); - - expect(h.host.value()).toEqual({ start: db('2026-12-24'), end: dbEnd('2026-12-24') }); + h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); + h.fixture.detectChanges(); + expect(h.inputs().length).toBe(2); + expect(h.start().value).toBe('May 12, 2026'); + expect(h.end()!.value).toBe('May 15, 2026'); }); - it('a distinct end survives a start edit; idle display shows the range', async () => { - const h = setupHost(DateShapeHost); - h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); + it('null + ranged=true cold-starts as the pair, both hinting the locale pattern', () => { + h.host.ranged.set(true); h.fixture.detectChanges(); - const idle = h.display().textContent ?? ''; - expect(idle).toContain('12'); - expect(idle).toContain('15'); + expect(h.inputs().length).toBe(2); + expect(h.start().placeholder).toBe('mm/dd/yyyy'); + expect(h.end()!.placeholder).toBe('mm/dd/yyyy'); + }); - await commitDraft(h, '13.5.2026'); + it('a half-open range switches the empty end side to the … placeholder', () => { + h.host.ranged.set(true); + h.host.value.set({ start: db('2026-05-12'), end: null }); + h.fixture.detectChanges(); - expect(h.host.value()).toEqual({ start: db('2026-05-13'), end: dbEnd('2026-05-15') }); + expect(h.end()!.placeholder).toBe('…'); }); - it('null + ranged=false cold-starts as a single date', async () => { - const h = setupHost(DateShapeHost); - - await commitDraft(h, '24.12.2026'); + it('an explicit placeholder input overrides the locale pattern on both sides', () => { + h.host.ranged.set(true); + h.host.placeholder.set('when?'); + h.fixture.detectChanges(); - expect(h.host.value()).toBe(db('2026-12-24')); + expect(h.start().placeholder).toBe('when?'); + expect(h.end()!.placeholder).toBe('when?'); }); - it('null + ranged=true cold-starts in the range shape', async () => { - const h = setupHost(DateShapeHost); + it('Tab-advance: focus moving start → end settles the start (commit-valid)', async () => { h.host.ranged.set(true); h.fixture.detectChanges(); - await commitDraft(h, '24.12.2026'); + type(h, h.start(), '12.5.2026'); + focusInput(h, h.end()!); // what Tab does + await settle(h); + + expect(h.host.value()).toEqual({ start: db('2026-05-12'), end: null }); + expect(h.host.sessions).toEqual([ + { value: { start: db('2026-05-12'), end: null }, changed: true }, + ]); - expect(h.host.value()).toEqual({ start: db('2026-12-24'), end: dbEnd('2026-12-24') }); + type(h, h.end()!, '15.5.2026'); + await blurAway(h); + + expect(h.host.value()).toEqual({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); + expect(h.host.sessions.length).toBe(2); }); - it('null remembers the last seen shape: cleared one-key stays one-key', async () => { - const h = setupHost(DateShapeHost); - h.host.value.set({ start: db('2026-05-12') }); + it('each side owns its clear — the other side is NEVER nuked', async () => { + h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); h.fixture.detectChanges(); - await commitDraft(h, ''); - expect(h.host.value()).toEqual({ start: null }); + type(h, h.end()!, ''); + press(h, h.end()!, 'Enter'); + expect(h.host.value()).toEqual({ start: db('2026-05-12'), end: null }); - await commitDraft(h, '24.12.2026'); - expect(h.host.value()).toEqual({ start: db('2026-12-24') }); + type(h, h.start(), ''); + press(h, h.start(), 'Enter'); + expect(h.host.value()).toEqual({ start: null, end: null }); }); - it('the saved session carries the echoed shape', async () => { - const h = setupHost(DateShapeHost); + it('a start edit in the one-key { start } shape moves the single-day range whole', async () => { h.host.value.set({ start: db('2026-05-12') }); h.fixture.detectChanges(); - await commitDraft(h, '24.12.2026'); - - expect(h.host.sessions).toEqual([{ value: { start: db('2026-12-24') }, changed: true }]); - }); -}); - -// ============================================================================= -// T2 — the calendar overlay (open-on-edit, draft mirror, pick paths) -// ============================================================================= + type(h, h.start(), '20.5.2026'); + press(h, h.start(), 'Enter'); -describe('AngularInlineDate calendar (T2)', () => { - const calendar = () => document.querySelector('angular-inline-calendar'); - const grid = () => calendar()?.querySelector('.cal__grid') as HTMLElement | null; - const activeCell = () => calendar()?.querySelector('[data-active]'); + expect(h.host.value()).toEqual({ start: db('2026-05-20') }); - it('opens on edit-session start WITHOUT stealing focus and mirrors the draft', async () => { - const h = setup(); - await typeText(h, '24.12.2026'); - await h.fixture.whenStable(); - h.fixture.detectChanges(); + // Only an END edit creates a distinct end (and grows the key). + type(h, h.end()!, '25.5.2026'); + press(h, h.end()!, 'Enter'); - expect(calendar()).not.toBeNull(); - // The caret stays in the field — the grid never takes focus on open. - expect(calendar()!.contains(document.activeElement)).toBe(false); - // The grid mirrors the parseable draft per keystroke. - expect(activeCell()?.getAttribute('data-day')).toBe('2026-12-24'); - expect(calendar()!.querySelector('.cal__label')?.textContent).toContain('December'); + expect(h.host.value()).toEqual({ start: db('2026-05-20'), end: dbEnd('2026-05-25') }); }); - it('an unparseable draft leaves the last valid day standing', async () => { - const h = setup(); - await typeText(h, '24.12.2026'); - await typeText(h, 'garbage'); + it('null remembers the last seen shape: a cleared one-key field stays one-key', async () => { + h.host.value.set({ start: db('2026-05-12') }); h.fixture.detectChanges(); - expect(activeCell()?.getAttribute('data-day')).toBe('2026-12-24'); + type(h, h.start(), ''); + press(h, h.start(), 'Enter'); + + expect(h.host.value()).toEqual({ start: null }); + expect(h.inputs().length).toBe(2); }); - it('a pick IS the choice: it rewrites the draft and COMMITS the session', async () => { - const h = setup(); - await typeText(h, '12.5.2026'); - await h.fixture.whenStable(); + it('press-hold-drag paints the range live and commits it whole — ONE saved', async () => { + h.host.ranged.set(true); h.fixture.detectChanges(); - const cell = calendar()!.querySelector('[data-day="2026-05-15"]') as HTMLElement; - cell.click(); + focusInput(h, h.start()); + const cellA = gridCell('2026-05-06')!; + cellA.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); h.fixture.detectChanges(); - await h.fixture.whenStable(); + gridCell('2026-05-09')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); h.fixture.detectChanges(); - expect(h.host.saved).toEqual([db('2026-05-15')]); // committed, no Save button needed - expect(h.host.field().value()).toBe(db('2026-05-15')); - expect(h.editor()).toBeNull(); // session settled - expect(calendar()).toBeNull(); // panel (and grid) gone with it - }); + // The live preview paints between the endpoints while dragging. + expect(cellA.hasAttribute('data-range-start')).toBe(true); + expect(gridCell('2026-05-07')?.hasAttribute('data-in-range')).toBe(true); - it('the slim chrome: no Save/Discard buttons while the calendar is active', async () => { - const h = setup(); - await typeText(h, '12.5.2026'); - await h.fixture.whenStable(); - h.fixture.detectChanges(); + document.dispatchEvent(new MouseEvent('mouseup')); + await settle(h); - expect(document.querySelector('.editable-panel__actions')).toBeNull(); + expect(h.host.value()).toEqual({ start: db('2026-05-06'), end: dbEnd('2026-05-09') }); + expect(h.host.sessions).toEqual([ + { value: { start: db('2026-05-06'), end: dbEnd('2026-05-09') }, changed: true }, + ]); + expect(h.panel()).toBeNull(); }); - it('keyboard navigation crosses month edges (the transition dance)', async () => { - const h = setup(); - await typeText(h, '31.5.2026'); - await h.fixture.whenStable(); + it('a reversed drag sorts; Ctrl+click restarts the range half-open', async () => { + h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); h.fixture.detectChanges(); - grid()!.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }), + focusInput(h, h.start()); + // Ctrl+click: start = the day, the end CLEARS (a committed half-open range). + gridCell('2026-05-20')!.dispatchEvent( + new MouseEvent('click', { bubbles: true, ctrlKey: true }), ); - h.fixture.detectChanges(); - await h.fixture.whenStable(); - h.fixture.detectChanges(); - - expect(activeCell()?.getAttribute('data-day')).toBe('2026-06-01'); - expect(calendar()!.querySelector('.cal__label')?.textContent).toContain('June'); - }); + await settle(h); - it('Escape in the grid hands control back to the field (stage one of two)', async () => { - const h = setup(); - await typeText(h, '12.5.2026'); - await h.fixture.whenStable(); - h.fixture.detectChanges(); + expect(h.host.value()).toEqual({ start: db('2026-05-20'), end: null }); + expect(document.activeElement).toBe(h.end()!); + expect(h.panel()).not.toBeNull(); // stays open for the completing pick - grid()!.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }), + // A reversed drag (25 → 22) commits sorted. + gridCell('2026-05-25')!.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), ); - h.fixture.detectChanges(); + gridCell('2026-05-22')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + document.dispatchEvent(new MouseEvent('mouseup')); + await settle(h); - expect(h.editor()).not.toBeNull(); // session still open - expect(calendar()).not.toBeNull(); // the grid stays — it is part of the panel + expect(h.host.value()).toEqual({ start: db('2026-05-22'), end: dbEnd('2026-05-25') }); }); - it('idle: the 📅 affix opens the SESSION (one surface) and a pick commits', async () => { - const h = setup(); - - const trigger = h.fixture.nativeElement.querySelector('.date-trigger') as HTMLElement; - trigger.click(); - h.fixture.detectChanges(); - await h.fixture.whenStable(); + it('range picking: first pick fills the focused side and hands the session to the empty side', async () => { + h.host.ranged.set(true); h.fixture.detectChanges(); - expect(h.editor()).not.toBeNull(); // the affix opens the session - expect(calendar()).not.toBeNull(); // panel + grid are one surface - expect(activeCell()?.getAttribute('data-day')).toBe('2026-05-12'); // the committed day + focusInput(h, h.start()); + gridCell('2026-05-20')!.click(); + await settle(h); - (calendar()!.querySelector('[data-day="2026-05-20"]') as HTMLElement).click(); - h.fixture.detectChanges(); - await h.fixture.whenStable(); - h.fixture.detectChanges(); + expect(h.host.value()).toEqual({ start: db('2026-05-20'), end: null }); + expect(document.activeElement).toBe(h.end()!); + expect(h.panel()).not.toBeNull(); // the popup stays for the second pick - expect(h.host.saved).toEqual([db('2026-05-20')]); - expect(h.host.sessions).toEqual([{ value: db('2026-05-20'), changed: true }]); - expect(calendar()).toBeNull(); + gridCell('2026-05-12')!.click(); // BEFORE the start: the pair sorts + await settle(h); + + expect(h.host.value()).toEqual({ start: db('2026-05-12'), end: dbEnd('2026-05-20') }); + // BOTH inputs display the SORTED pair (the picked side re-baselines on + // its swapped day — a later blur must not un-sort it). + expect(h.start().value).toBe('May 12, 2026'); + expect(h.end()!.value).toBe('May 20, 2026'); + expect(h.panel()).toBeNull(); }); }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 4fd85ec..87c3838 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -1,33 +1,40 @@ import { Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + effect, inject, - TemplateRef, input, + linkedSignal, model, output, - computed, - linkedSignal, + signal, + untracked, viewChild, contentChild, + type Signal, + type TemplateRef, + type WritableSignal, } from '@angular/core'; -import { NgTemplateOutlet } from '@angular/common'; +import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; -import { - AngularInlineText, - EditablePrefix, - EditableSuffix, - type InlineTextSaved, -} from 'angular-inline-select'; +import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; import { parseDateInput, - formatInternalRange, + formatIsoDate, describeIsoDate, buildDateCommands, inferDateShape, toInternalRange, echoDateShape, dateValuesEqual, + localeDatePlaceholder, + type DateCommand, type IsoDate, type InlineDateValue, type DateValueShape, @@ -35,6 +42,7 @@ import { } from './date-codec'; import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; +import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; import { AngularInlineCalendar } from './inline-calendar'; /** Payload of the `saved` output: one emission per settled edit session. */ @@ -45,31 +53,160 @@ export interface InlineDateSaved { changed: boolean; } +type SideKey = 'start' | 'end'; + +/** + * Everything one field of the pair owns. A SESSION is a continuous stretch + * of focus on one side: it opens on focusin (capturing the baseline) and + * settles on Enter, Escape, or focus leaving the side. + */ +interface DateSide { + readonly key: SideKey; + /** This side's committed LOCAL day (the value boundary stays DB entries). */ + readonly committedDay: Signal; + /** Localized display of the committed day — what the input shows idle. */ + readonly display: Signal; + /** Whether a session is open on this side. */ + readonly open: WritableSignal; + /** + * The input's text: user-owned while a session is open (frozen linkedSignal + * — a value write mid-session never rewrites text under the caret), the + * committed display otherwise. + */ + readonly draft: WritableSignal; + /** The committed day at session start — what Escape and snap-back restore. */ + baselineDay: IsoDate | null; + /** + * Whether the USER touched the draft since the last settlement. An + * untouched session settles WHERE THE VALUE STANDS — re-deriving it from + * the draft would undo external writes (a group re-anchoring this leaf) + * with stale session state. + */ + dirty: boolean; + /** Enter was pressed on an unreadable draft — reveals the parse-gate error. */ + readonly saveAttempted: WritableSignal; +} + /** - * Inline date: a `FormValueControl` for calendar dates that COMPOSES the - * inline text control. Canonical value: a **UTC ISO DB entry** - * (`'2026-07-20T22:00:00.000Z'` — iusta's `toDBEntry` of the local - * `startOf('day')`); the DISPLAY is the localized local calendar day. + * Inline date on NATIVE INPUTS — the input rehost (see ROADMAP-DATETIME). + * A `FormValueControl` for calendar dates and date RANGES. Canonical value: + * UTC ISO DB entries (iusta's `toDBEntry` of the local `startOf('day')`; + * range ends `endOf('day')`), SHAPE-ECHOED — a string binds ONE field, an + * object binds the start–end pair. Display is the localized local day. + * + * The family feel is styling, not shared DOM: dashed underline idle, solid + * error color when invalid, `field-sizing: content` + a fixed-size + * placeholder so layout shift is impossible. * - * - Drafts are TYPED (`'12.5.'`, `'12.5.2026'`, `'2026-05-12'`) and never - * reformatted under the caret; the live interpretation preview shows the - * full reading on every keystroke (`✓ Tuesday, 12 May 2026`). - * - The slash menu is the quick-pick: `/today`, `/tomorrow`, `/yesterday` - * and the next seven weekdays — labels localized via `Intl` (zero bundled - * translations), matching the localized AND English names. - * - A calendar overlay picker is the natural next affordance (same pattern - * as the phone's flag picker) — deliberately left open for sandboxing. + * Session semantics are GESTURE-TIERED (the Notion/GCal convention): + * - Enter = explicit commit — an unreadable draft BLOCKS with the error. + * - Escape = explicit revert to the session baseline. + * - Tab / blur = navigation, never a validity checkpoint: a readable draft + * COMMITS and focus moves on; an unreadable draft SNAPS BACK to the + * baseline and focus moves anyway. Never trap, never persist a draft + * error — the idle solid underline is reserved for SCHEMA errors. + * + * The calendar opens on focus WITHOUT stealing it (the grid mirrors the + * typed draft per keystroke); ArrowDown hands focus to the grid; a pick + * COMMITS the focused side — and hands the session to the empty other side + * when picking a range. */ @Component({ selector: 'angular-inline-date', - imports: [AngularInlineText, AngularInlineCalendar, NgTemplateOutlet], + imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet, AngularInlineCalendar], templateUrl: './angular-inline-date.html', styles: ` - :host { display: inline; } - .date-command__label { flex: 1 1 auto; text-transform: capitalize; } - .date-command__value { color: var(--mat-sys-on-surface-variant, #5f6368); font-variant-numeric: tabular-nums; } - .date-command__empty { padding: 4px 8px; color: var(--mat-sys-on-surface-variant, #5f6368); } - .date-trigger { + :host { + display: inline; + } + + .inline-date { + display: inline-flex; + align-items: baseline; + gap: 0.25ch; + max-width: 100%; + } + + /* + The family look, on an input: dashed underline idle, solid while + focused, error color when the field says errors show. border-bottom + (not text-decoration — unreliable on inputs) + a small padding to + mimic the text control's underline offset. + */ + .inline-date__input { + font: inherit; + color: inherit; + background: transparent; + border: 0; + padding: 0 0 0.1em; + margin: 0; + outline: none; + min-width: 1ch; + max-width: 100%; + field-sizing: content; + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + border-bottom: 0.0625rem dashed + var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); + } + .inline-date__input:focus { + border-bottom-style: solid; + border-bottom-width: 0.125rem; + padding-bottom: calc(0.1em - 0.0625rem); + } + .inline-date__input::placeholder { + font-style: italic; + color: inherit; + opacity: var(--editable-text-placeholder-opacity, 0.3875); + } + .inline-date__input:disabled { + cursor: default; + border-bottom-color: var(--mat-sys-outline, #999); + } + + /* Idle error state — color only, the dashed style stays (family rule). */ + .inline-date--invalid .inline-date__input { + border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); + } + + /* + BARE CHROME — a generic seam, not a mat one: the HOSTING CONTAINER + declares it draws the chrome (underline, error color, label), so the + control's own underline rests. Applied as host classes by whoever + hosts us (the temporal-mat adapter, a dense table cell, …). + */ + :host(.inline-field-bare) .inline-date__input { + border-bottom: none; + padding-bottom: 0; + } + :host(.inline-field-bare--hide-placeholder) .inline-date__input::placeholder { + opacity: 0; + } + + /* Transient snap-back cue: a brief flash on the restored display. */ + .inline-date__input--reverted { + animation: inline-date-revert 0.6s ease-out; + } + @keyframes inline-date-revert { + 0% { + background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); + } + 100% { + background: transparent; + } + } + + .inline-date__separator { + user-select: none; + color: var(--mat-sys-on-surface-variant, #5f6368); + } + + .inline-date__affix { + white-space: nowrap; + user-select: none; + color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); + } + + .inline-date__trigger { font: inherit; line-height: 1; padding: 0; @@ -78,19 +215,82 @@ export interface InlineDateSaved { cursor: pointer; border-radius: var(--mat-sys-corner-extra-small, 0.25rem); } - .date-trigger:focus-visible { + .inline-date__trigger:focus-visible { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: 2px; } + + .inline-date__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + /* The panel: an elevated surface under the input pair (no field chrome). */ + .inline-date__panel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); + color: var(--mat-sys-on-surface, inherit); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: var( + --mat-sys-level2, + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 2px 6px 2px rgba(0, 0, 0, 0.15) + ); + } + + .inline-date__preview { + padding: 2px 8px 0; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-on-surface-variant, #5f6368); + font-variant-numeric: tabular-nums; + } + + .inline-date__quick-picks { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 0 8px 4px; + } + .inline-date__quick-pick { + font: var(--mat-sys-label-medium, 500 0.75rem/1.4 system-ui); + text-transform: capitalize; + padding: 2px 10px; + border: 1px solid var(--mat-sys-outline-variant, #ddd); + border-radius: var(--mat-sys-corner-full, 999px); + background: transparent; + color: var(--mat-sys-on-surface-variant, #5f6368); + cursor: pointer; + } + .inline-date__quick-pick:hover { + background: var(--mat-sys-surface-container-highest, #eee); + } + + .inline-date__errors:not([hidden]) { + padding: 0 8px 4px; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-error, #dc3545); + } + + @media (prefers-reduced-motion: reduce) { + .inline-date__input--reverted { + animation: none; + } + } `, host: { '[style.display]': 'hidden() ? "none" : null', - '(keydown)': 'handleHostKeydown($event)', }, }) export class AngularInlineDate implements FormValueControl { - /** The composed text control — all session machinery lives there. */ - protected inner = viewChild.required(AngularInlineText); + #document = inject(DOCUMENT); + #injector = inject(Injector); /** * The committed value channel — polymorphic UTC ISO DB entries (iusta's @@ -107,7 +307,7 @@ export class AngularInlineDate implements FormValueControl { */ ranged = input(false); - /** Form Value Contract — forwarded into the inner control. */ + /** Form Value Contract. */ errors = input([]); disabled = input(false); readonly = input(false); @@ -116,20 +316,56 @@ export class AngularInlineDate implements FormValueControl { invalid = input(false); hidden = input(false); - placeholder = input('date'); + /** + * Placeholder override. Unset, the field shows the LOCALE'S numeric date + * pattern (`'dd.mm.yyyy'` German, `'mm/dd/yyyy'` en-US) — fixed size per + * locale, so the placeholder-floored width never shifts. + */ + placeholder = input(undefined); + /** + * End-field placeholder override. Unset, a FULLY EMPTY range shows the + * locale pattern on both sides; once a start exists the end side switches + * to the half-open display (`'Jul 21 – …'`). + */ + endPlaceholder = input(undefined); + + /** Public: the resolved placeholder verdict (adapters render from this, not the input). */ + readonly effectivePlaceholder = computed( + () => this.placeholder() ?? localeDatePlaceholder(this.locale()), + ); + protected effectiveEndPlaceholder = computed(() => { + const explicit = this.endPlaceholder(); + if (explicit !== undefined) return explicit; + return this.internalRange().start === null ? this.effectivePlaceholder() : '…'; + }); - /** Accessible name for the field (contenteditable has no native label association). */ + /** Accessible base name; ranged fields append " start" / " end". */ ariaLabel = input(undefined); - /** Locale for display + command labels (`Intl`); browser default when omitted. */ + /** Locale for display + parsing (`Intl`); browser default when omitted. */ locale = input(undefined); - /** Enables the `/today`-style slash menu. */ - showDateMenu = input(true); + /** + * T6 — the DISPLAY ZONE (IANA id): which zone's calendar day the value + * boundary speaks. Falls back to the app-wide `INLINE_TEMPORAL_ZONE` + * provider, then the machine zone. Values stay UTC DB entries. + */ + zone = input(undefined); + + #zoneDefault = inject(INLINE_TEMPORAL_ZONE, { optional: true }); - /** The 📅 calendar affordance: suffix trigger + the open-on-edit popup. */ + readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); + + /** The calendar grid affordance (📅 trigger + open-on-focus popup). */ showCalendar = input(true); + /** + * Quick-pick commands rendered as chips in the panel. Defaults to + * yesterday/today/tomorrow — INJECTABLE so a consumer's copy can grow + * its own presets ("last 30 days") without touching the control. + */ + quickPicks = input(undefined); + /** Reference clock — injectable for tests; a fresh `Date` per read otherwise. */ now = input<() => Date>(() => new Date()); @@ -145,19 +381,14 @@ export class AngularInlineDate implements FormValueControl { () => this.suffixTemplate() ?? this.contentSuffix()?.templateRef, ); - /** Whether the suffix slot has anything to render (consumer affix or 📅). */ - protected suffixActive = computed( - () => this.consumerSuffixTpl() !== undefined || this.showCalendar(), - ); - - /** * Group-forwarded contract state (role-provided; absent standalone). * Merged by PULL — the leaf stays decoupled, no effects involved. */ #leafState = inject(INLINE_TEMPORAL_LEAF_STATE, { optional: true, self: true }); - protected effectiveDisabled = computed( + /** Public: the composed disabled verdict (own input + group-fed state). */ + readonly effectiveDisabled = computed( () => this.disabled() || (this.#leafState?.disabled() ?? false), ); protected effectiveReadonly = computed( @@ -170,16 +401,16 @@ export class AngularInlineDate implements FormValueControl { () => this.invalid() || (this.#leafState?.invalid() ?? false), ); - /** Form Value Contract: touch — forwarded from the inner control. */ + /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per accepted edit session, in the bound shape. */ + /** Hard commit event: fires once per changed settlement, in the bound shape. */ savedModelChange = output(); - /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ saved = output(); - /** Whether an edit session is open. Two-way bindable. */ + /** Whether an edit session is open (= focus is within). Two-way bindable. */ editing = model(false); /** @@ -197,191 +428,569 @@ export class AngularInlineDate implements FormValueControl { () => this.#lastShape() ?? (this.ranged() ? 'range' : 'single'), ); + /** Object shapes render the start–end input pair; a string renders one field. */ + protected twoFields = computed(() => this.shape() !== 'single'); + /** * One canonical internal model, always: `{ start, end }` as LOCAL * calendar DAYS — the user-facing side; DB entries live only at the * value boundary. */ readonly internalRange = computed(() => { + const zone = this.effectiveZone(); const { start, end } = toInternalRange(this.value()); - return { start: start === null ? null : localDayOf(start), end: end === null ? null : localDayOf(end) }; + return { + start: start === null ? null : localDayOf(start, zone), + end: end === null ? null : localDayOf(end, zone), + }; }); /** The value boundary, outbound: local days → DB entries in the echoed shape. */ #daysToDbShape(days: InternalDateRange, shape: DateValueShape): InlineDateValue { + const zone = this.effectiveZone(); const echoed = echoDateShape(days, shape); if (echoed === null) return null; - if (typeof echoed === 'string') return dayToDbEntry(echoed); + if (typeof echoed === 'string') return dayToDbEntry(echoed, zone); - const start = echoed.start === null ? null : dayToDbEntry(echoed.start); + const start = echoed.start === null ? null : dayToDbEntry(echoed.start, zone); if (!('end' in echoed)) return { start }; - return { start, end: echoed.end == null ? null : dayEndToDbEntry(echoed.end) }; + return { start, end: echoed.end == null ? null : dayEndToDbEntry(echoed.end, zone) }; } - /** - * The string channel feeding the inner control: the localized committed - * date (or range) while idle, the raw draft while a session is open. - */ - protected innerValue = linkedSignal({ - source: () => formatInternalRange(this.internalRange(), this.locale()), - computation: (source, prev) => (this.editing() ? (prev?.value ?? source) : source), - }); + // -- The two sides ----------------------------------------------------------- + + readonly #startSide = this.#makeSide('start'); + readonly #endSide = this.#makeSide('end'); + + #side(key: SideKey): DateSide { + return key === 'start' ? this.#startSide : this.#endSide; + } + + #makeSide(key: SideKey): DateSide { + const committedDay = computed(() => this.internalRange()[key]); + const display = computed(() => formatIsoDate(committedDay(), this.locale())); + const open = signal(false); + const draft = linkedSignal({ + source: display, + computation: (source, prev) => (open() ? (prev?.value ?? source) : source), + }); + + return { + key, + committedDay, + display, + open, + draft, + baselineDay: null, + dirty: false, + saveAttempted: signal(false), + }; + } + + protected startDraft = computed(() => this.#startSide.draft()); + protected endDraft = computed(() => this.#endSide.draft()); + + /** Which side holds focus — the side the grid, preview and picks serve. */ + protected focusTarget = signal(null); + + protected overlayOpen = signal(false); + + /** Public: whether the panel is showing (hosting containers coordinate on it). */ + readonly panelVisible = computed(() => this.overlayOpen()); + + protected startInput = viewChild>('startInput'); + protected endInput = viewChild>('endInput'); + protected calendar = viewChild(AngularInlineCalendar); + protected panelRef = viewChild>('panel'); /** The current draft's ISO reading (`null` empty, `undefined` unreadable). */ - readonly parsedDraft = computed(() => parseDateInput(this.innerValue(), this.now()(), this.locale())); + readonly parsedDraft = computed(() => { + const key = this.focusTarget() ?? 'start'; + return parseDateInput(this.#side(key).draft(), this.now()(), this.locale(), this.effectiveZone()); + }); - /** The parse gate: whether the current draft fails the codec. Public for consumers. */ + /** The parse gate: whether the focused draft fails the codec. Public for consumers. */ readonly parseFailed = computed(() => this.parsedDraft() === undefined); - /** Errors forwarded inward: contract + group-routed errors + the parse gate. */ - protected innerErrors = computed(() => { - const groupErrors = this.#leafState?.errors() ?? []; - const base = groupErrors.length ? [...this.errors(), ...groupErrors] : this.errors(); + #selfTouched = signal(false); + + protected isInvalid = computed( + () => + this.effectiveInvalid() || + this.errors().length > 0 || + (this.#leafState?.errors().length ?? 0) > 0, + ); + + /** + * The mat split: the consumer decides what errors say, the field when they + * show. Public — it is the field's presentational verdict, the thing a + * hosting container (a mat-form-field adapter) needs to mirror. + */ + readonly errorsVisible = computed( + () => this.isInvalid() && (this.effectiveTouched() || this.#selfTouched()), + ); + + /** Public: whether the field holds no value at all (both sides empty). */ + readonly isEmpty = computed(() => { + const { start, end } = this.internalRange(); + return start === null && end === null; + }); - return this.parseFailed() ? [...base, { kind: 'parse' }] : base; + /** The parse-gate reveal: Enter was attempted on an unreadable draft. */ + protected parseGateVisible = computed(() => { + const key = this.focusTarget(); + return key !== null && this.#side(key).saveAttempted() && this.parseFailed(); }); + protected errorSlotVisible = computed(() => this.errorsVisible() || this.parseGateVisible()); + /** Live interpretation preview: `✓ Tuesday, 12 May 2026` / `… raw`. */ protected preview = computed(() => { - const raw = this.innerValue().trim(); + const key = this.focusTarget() ?? 'start'; + const raw = this.#side(key).draft().trim(); if (!raw) return ''; - const iso = this.parsedDraft(); + const iso = parseDateInput(raw, this.now()(), this.locale(), this.effectiveZone()); if (iso === null || iso === undefined) return `… ${raw}`; return `✓ ${describeIsoDate(iso, this.locale())}`; }); - /** The slash-menu commands, rebuilt per read so "today" is always today. */ - protected dateCommands = computed(() => buildDateCommands(this.now()(), this.locale())); + /** The grid's pending day: the focused side's parsed draft, else its committed day. */ + protected pendingDay = computed(() => { + const key = this.focusTarget() ?? 'start'; + const draft = parseDateInput(this.#side(key).draft(), this.now()(), this.locale(), this.effectiveZone()); + if (typeof draft === 'string') return draft; + + return this.#side(key).committedDay() ?? this.internalRange().start; + }); + + protected selectedForGrid = computed(() => + this.focusTarget() === 'end' ? this.internalRange().end : this.internalRange().start, + ); + + /** Quick-pick chips: consumer-injected, else yesterday/today/tomorrow. */ + protected quickPickList = computed( + () => this.quickPicks() ?? buildDateCommands(this.now()(), this.locale(), this.effectiveZone()).slice(0, 3), + ); + + protected overlayPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + ]; + + /** Snap-back flash target + the aria-live announcement text. */ + protected revertFlash = signal(null); + protected revertNotice = signal(''); + + #focusCheckTimer: ReturnType | null = null; + #flashTimer: ReturnType | null = null; + + constructor() { + inject(DestroyRef).onDestroy(() => { + if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); + if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); + }); + + // The editing bridge: external `editing.set(true)` focuses the start + // input (focusin opens the session); `set(false)` settles and blurs. + // Internal focus flow writes the model, so states already agree there. + effect(() => { + const editing = this.editing(); + untracked(() => { + const focused = this.focusTarget(); + if (editing && focused === null) { + this.#focusSide('start'); + } else if (!editing && focused !== null) { + this.#settle(focused); + this.overlayOpen.set(false); + this.focusTarget.set(null); + this.#inputOf(focused)?.blur(); + } + }); + }); + } + + // -- Sizing (no layout shift: content-sized, placeholder-floored) ------------- + + protected sizeOf(key: SideKey): number { + const side = this.#side(key); + const placeholder = + key === 'end' ? this.effectiveEndPlaceholder() : this.effectivePlaceholder(); + return Math.max(1, (side.draft() || placeholder).length); + } + + protected ariaLabelOf(key: SideKey): string { + const base = this.ariaLabel() ?? 'Date'; + return this.twoFields() ? `${base} ${key}` : base; + } - protected commandOptions(query: string) { - const q = query.trim().toLowerCase(); - const all = this.dateCommands(); - if (!q) return all; + protected ariaInvalidOf(key: SideKey): boolean { + const side = this.#side(key); + return this.errorsVisible() || (side.open() && side.saveAttempted() && this.parseFailed()); + } + + // -- The live channel --------------------------------------------------------- + + /** Every keystroke: readable drafts flow into the model live, in the bound shape. */ + protected handleInput(key: SideKey, raw: string) { + const side = this.#side(key); + // A settled-but-still-focused field (Enter, outside click) restarts its + // session on the next keystroke. + if (!side.open()) { + side.baselineDay = side.committedDay(); + side.open.set(true); + } + + side.draft.set(raw); + side.dirty = true; + side.saveAttempted.set(false); + this.overlayOpen.set(true); - return all.filter((command) => command.match.includes(q)); + const day = parseDateInput(raw, this.now()(), this.locale(), this.effectiveZone()); + if (day !== undefined) this.#writeSideDay(key, day); } /** - * Interim single-field merge (until T5's two-field ranged UI): the typed - * day moves the whole range when it is single-day, and only `start` when - * a distinct `end` exists; clearing empties both sides. Never invents or - * drops a shape — that's the echo's job. + * Writes one side's local day into the value, echoed in the bound shape. + * In the one-key `{ start }` shape the end is a MIRROR, not data — a + * start edit moves the single-day range whole; only an END edit creates + * a distinct end (and grows the key, per the echo). */ - #mergeDay(day: IsoDate | null): InternalDateRange { - if (day === null) return { start: null, end: null }; + #writeSideDay(key: SideKey, day: IsoDate | null) { + const current = this.internalRange(); + const moveWhole = !this.twoFields() || (key === 'start' && this.shape() === 'start-only'); + const next: InternalDateRange = moveWhole + ? { start: day, end: day } + : key === 'start' + ? { start: day, end: current.end } + : { start: current.start, end: day }; + + const echoed = this.#daysToDbShape(next, this.shape()); + if (!dateValuesEqual(echoed, this.value())) this.value.set(echoed); + } - const { start, end } = this.internalRange(); - return end === null || end === start ? { start: day, end: day } : { start: day, end }; + // -- Focus flow ---------------------------------------------------------------- + + protected handleFocusIn(key: SideKey) { + const side = this.#side(key); + if (!side.open()) { + side.baselineDay = side.committedDay(); + side.dirty = false; + side.saveAttempted.set(false); + side.open.set(true); + } + + this.focusTarget.set(key); + if (!this.effectiveReadonly() && !this.effectiveDisabled()) this.overlayOpen.set(true); + this.editing.set(true); } - // --------------------------------------------------------------------------- - // T2b — the calendar lives IN the panel (where the slash menu lives): no - // second overlay, no positioning math. The typed draft stays primary and - // the grid is a live MIRROR of it — a parseable draft moves the month - // and marks the day per keystroke, draft → grid only, until a pick flows - // back. Slim chrome: no Save/Discard buttons — a pick COMMITS, typing - // commits via Enter as usual. - // --------------------------------------------------------------------------- - protected calendar = viewChild(AngularInlineCalendar); + /** + * Focusout settles ASYNCHRONOUSLY: where focus LANDS decides what happens + * (the other input = Tab-advance, the panel = same session, outside = + * settle + close), and that is only knowable a tick later. + */ + protected handleFocusOut() { + if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); + this.#focusCheckTimer = setTimeout(() => this.#onFocusSettled(), 0); + } - /** In-session grid visibility — the 📅 affix toggles it; resets per session. */ - protected calendarVisible = linkedSignal({ - source: this.editing, - computation: () => true, - }); + #onFocusSettled() { + this.#focusCheckTimer = null; + const active = this.#document.activeElement; + const inStart = active !== null && active === this.startInput()?.nativeElement; + const inEnd = active !== null && active === this.endInput()?.nativeElement; + const inPanel = (active !== null && this.panelRef()?.nativeElement.contains(active)) ?? false; + + // The grid is part of the focused side's session — panel focus settles + // nothing. A side that lost focus to anywhere else settles NOW: + // commit-if-readable, snap-back if not. Never trap, never block. + if (!inPanel) { + if (this.#startSide.open() && !inStart) this.#settle('start'); + if (this.#endSide.open() && !inEnd) this.#settle('end'); + } - protected calendarActive = computed(() => this.showCalendar() && this.calendarVisible()); + if (!inStart && !inEnd && !inPanel) { + this.overlayOpen.set(false); + this.focusTarget.set(null); + this.editing.set(false); + } else if (inStart) { + this.focusTarget.set('start'); + } else if (inEnd) { + this.focusTarget.set('end'); + } + } - /** The grid's pending day: the parsed draft, else the committed start. */ - protected pendingDay = computed(() => { - const draft = this.parsedDraft(); - return typeof draft === 'string' ? draft : this.internalRange().start; - }); + // -- Settlement (ONE per session — commit, snap-back, Escape, clear) ----------- /** - * The 📅 affix: idle it OPENS the session (panel + grid are one - * surface); in-session it toggles the grid. + * Settles a side's session. Resolution order: an explicit `resolve` day + * (calendar pick), `revert` (Escape), else the draft — where an + * unreadable draft resolves to the BASELINE (snap-back; a brief flash + + * aria-live announce the restoration, no persistent state). */ - protected toggleCalendar(event: Event) { - event.preventDefault(); - event.stopPropagation(); + #settle(key: SideKey, options: { resolve?: IsoDate | null; revert?: boolean; keepOpen?: boolean } = {}) { + const side = this.#side(key); + if (!side.open()) return; + + // An untouched session settles where the value stands — no re-derive, + // no write (see DateSide.dirty). + const untouched = !options.revert && options.resolve === undefined && !side.dirty; + + let day: IsoDate | null; + let snappedBack = false; + if (untouched) { + day = side.committedDay(); + } else if (options.revert) { + day = side.baselineDay; + } else if (options.resolve !== undefined) { + day = options.resolve; + } else { + const parsed = parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()); + snappedBack = parsed === undefined; + day = parsed === undefined ? side.baselineDay : parsed; + } - if (!this.editing()) { - this.editing.set(true); - return; + if (!untouched) this.#writeSideDay(key, day); + const changed = !untouched && day !== side.baselineDay; + side.dirty = false; + + if (options.keepOpen) { + // Enter / pick settle in place: the session continues on the new baseline. + side.baselineDay = day; + side.draft.set(formatIsoDate(day, this.locale())); + side.saveAttempted.set(false); + } else { + side.open.set(false); + side.saveAttempted.set(false); } - this.calendarVisible.update((visible) => !visible); + if (snappedBack) this.#announceRevert(key, day); + + this.#selfTouched.set(true); + this.touch.emit(); + + const value = this.value(); + if (changed) this.savedModelChange.emit(value); + this.saved.emit({ value, changed }); } + #announceRevert(key: SideKey, day: IsoDate | null) { + const restored = day === null ? 'empty' : formatIsoDate(day, this.locale()); + this.revertNotice.set(`Reverted to ${restored}`); + this.revertFlash.set(key); + + if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); + this.#flashTimer = setTimeout(() => this.revertFlash.set(null), 600); + } + + // -- Keyboard ------------------------------------------------------------------- + + protected handleInputKeydown(key: SideKey, event: KeyboardEvent) { + switch (event.key) { + case 'Enter': { + event.preventDefault(); + const side = this.#side(key); + if (parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()) === undefined) { + // The parse gate: the user ASKED for a commit — block and say why. + side.saveAttempted.set(true); + return; + } + + this.#settle(key, { keepOpen: true }); + this.overlayOpen.set(false); + return; + } + case 'Escape': { + event.preventDefault(); + event.stopPropagation(); + this.#settle(key, { revert: true, keepOpen: true }); + this.overlayOpen.set(false); + return; + } + case 'ArrowDown': { + // The combobox-datepicker handoff: focus moves INTO the grid. + if (event.altKey || event.defaultPrevented) return; + if (!this.showCalendar() || this.effectiveReadonly() || this.effectiveDisabled()) return; + + event.preventDefault(); + this.overlayOpen.set(true); + + const grid = this.calendar(); + if (grid) grid.focusGrid(); + else afterNextRender(() => this.calendar()?.focusGrid(), { injector: this.#injector }); + return; + } + } + } + + // -- Calendar --------------------------------------------------------------------- + /** - * ArrowDown in the field hands focus to the grid (the combobox-datepicker - * shape) — unless the slash menu already consumed the key. + * A pick IS a commit of the focused side. Picking a range with the other + * side still empty hands the session over (the seamless two-pick flow); + * otherwise the popup closes. An inverted pair is sorted, iusta-style. */ - protected handleHostKeydown(event: KeyboardEvent) { - if (event.key !== 'ArrowDown' || event.defaultPrevented) return; - if (!this.editing() || !this.calendarActive()) return; + protected pickDate(day: IsoDate) { + const key = this.focusTarget() ?? 'start'; + const side = this.#side(key); + if (!side.open()) { + side.baselineDay = side.committedDay(); + side.open.set(true); + } - event.preventDefault(); - this.calendar()?.focusGrid(); + // Sort BEFORE settling: the settlement must emit the sorted value and + // re-baseline the side on its own (possibly swapped) day — else the + // frozen draft still shows the pre-sort pick and the next blur would + // commit it back, un-sorting the pair. + this.#writeSideDay(key, day); + this.#sortIfInverted(); + this.#settle(key, { resolve: side.committedDay(), keepOpen: true }); + + const other: SideKey = key === 'start' ? 'end' : 'start'; + if (this.twoFields() && this.#side(other).committedDay() === null) { + this.#focusSide(other); + } else { + this.overlayOpen.set(false); + this.#focusSide(key); + } + } + + #sortIfInverted() { + const { start, end } = this.internalRange(); + if (start !== null && end !== null && start > end) { + // ISO days compare lexicographically. + this.value.set(this.#daysToDbShape({ start: end, end: start }, this.shape())); + } } /** - * A pick IS the choice: refocus the field synchronously, rewrite the - * draft, and COMMIT the session (the panel has no Save button — mouse - * users click a day, keyboard users type and press Enter). + * Commits BOTH sides in one settlement (drag, Ctrl+click): one value + * write, both sides re-baselined, ONE `saved`. */ - protected pickDate(day: IsoDate) { - this.inner().focus(); - this.handleInnerValue(day); - // Write the INNER draft synchronously — accept() must not read the - // pre-pick draft while change detection still owes it the new value. - this.inner().value.set(day); - this.inner().accept(); + #commitBothSides(startDay: IsoDate | null, endDay: IsoDate | null) { + const before = this.value(); + const echoed = this.#daysToDbShape({ start: startDay, end: endDay }, this.shape()); + if (!dateValuesEqual(echoed, before)) this.value.set(echoed); + + for (const key of ['start', 'end'] as const) { + const side = this.#side(key); + side.baselineDay = side.committedDay(); + side.draft.set(side.display()); + side.dirty = false; + side.saveAttempted.set(false); + } + + const value = this.value(); + const changed = !dateValuesEqual(value, before); + this.#selfTouched.set(true); + this.touch.emit(); + if (changed) this.savedModelChange.emit(value); + this.saved.emit({ value, changed }); } - /** Escape in the grid hands control back to the field (stage one of two). */ - protected escapeCalendar() { - this.inner().focus(); + /** A drag painted [start, end] — commit the pair whole and close. */ + protected commitDraggedRange(range: { start: IsoDate; end: IsoDate }) { + this.#commitBothSides(range.start, range.end); + this.overlayOpen.set(false); + this.#focusSide(this.focusTarget() ?? 'start'); } - /** Live channel: readable drafts flow into the model as DB entries, in the bound shape. */ - protected handleInnerValue(raw: string) { - this.innerValue.set(raw); + /** + * Ctrl/Cmd+click: "restart the range HERE" — start = the day, end clears + * (a committed half-open range), and the session hands to the end side so + * the very next pick completes the pair. + */ + protected ctrlPickDate(day: IsoDate) { + if (!this.twoFields()) { + this.pickDate(day); + return; + } - const day = parseDateInput(raw, this.now()(), this.locale()); - if (day === undefined) return; + this.#commitBothSides(day, null); + this.#focusSide('end'); + } - const echoed = this.#daysToDbShape(this.#mergeDay(day), this.shape()); - if (!dateValuesEqual(echoed, this.value())) this.value.set(echoed); + /** Escape in the grid hands control back to the focused input (session continues). */ + protected escapeCalendar() { + this.#focusSide(this.focusTarget() ?? 'start'); } - /** Retype the settled session: local days inside, DB entries in the echoed shape outside. */ - protected handleInnerSaved(session: InlineTextSaved) { - const day = parseDateInput(session.value, this.now()(), this.locale()); - const value = - day === undefined - ? this.value() - : this.#daysToDbShape(this.#mergeDay(day), this.shape()); + /** + * Toggles the panel like the 📅 icon: opens the session when idle, + * closes an open popup, reopens a closed one. PUBLIC — the + * container-click affordance a hosting container (the mat-form-field + * adapter) delegates to. + */ + togglePanel() { + if (this.effectiveDisabled() || this.effectiveReadonly()) return; - if (session.changed) { - this.value.set(value); - this.savedModelChange.emit(value); + if (this.overlayOpen()) { + this.overlayOpen.set(false); + return; } - this.saved.emit({ value, changed: session.changed }); + if (this.focusTarget() === null) this.#focusSide('start'); + this.overlayOpen.set(true); + } + + /** The 📅 trigger. */ + protected toggleCalendar(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.togglePanel(); + } + + /** Clicking free space in the panel must not blur the inputs. */ + protected handlePanelMousedown(event: MouseEvent) { + const target = event.target as HTMLElement; + if (target.closest('input, button') === null) event.preventDefault(); } - /** Form Value Contract: focus — delegates to the inner control. */ + /** + * An outside click DISMISSES the panel — and only that. Settling belongs + * to the focusout path: when the click also moves focus away, the blur + * settle runs anyway; when it does NOT (a hosting container's prevented + * chrome click), the session must survive the dismissal. + */ + protected handleOutsideClick() { + this.overlayOpen.set(false); + } + + #inputOf(key: SideKey): HTMLInputElement | undefined { + return (key === 'start' ? this.startInput() : this.endInput())?.nativeElement; + } + + #focusSide(key: SideKey) { + const element = this.#inputOf(key); + if (element) element.focus(); + else afterNextRender(() => this.#inputOf(key)?.focus(), { injector: this.#injector }); + } + + // -- Form Value Contract ------------------------------------------------------------ + focus(options?: FocusOptions) { - this.inner().focus(options); + this.#inputOf('start')?.focus(options); } - /** Form Value Contract: reset — delegates to the inner control. */ + /** + * Presentation-only rollback (the MatInput precedent): an open draft is + * discarded back to the baseline with no `touch`, no `saved`, no focus + * stealing. + */ reset() { - this.inner().reset(); + for (const key of ['start', 'end'] as const) { + const side = this.#side(key); + if (!side.open()) continue; + + this.#writeSideDay(key, side.baselineDay); + side.baselineDay = side.committedDay(); + side.draft.set(side.display()); + side.saveAttempted.set(false); + } + + this.overlayOpen.set(false); } } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts index 3d1d231..361e6b7 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts @@ -130,7 +130,7 @@ function nameTable(locale: string | string[] | undefined) { /** `'Dec 24, 2026'`, `'24. Dezember 2026'`, `'Thursday, December 24, 2026'` … */ function parseNamedDate( raw: string, - now: Date, + nowYear: number, locale: string | string[] | undefined, ): IsoDate | undefined { const { months, weekdays } = nameTable(locale); @@ -167,7 +167,7 @@ function parseNamedDate( } if (month === undefined || day === undefined) return undefined; - return isoIfValid(year ?? now.getFullYear(), month, day); + return isoIfValid(year ?? nowYear, month, day); } /** @@ -183,10 +183,21 @@ export function parseDateInput( raw: string, now: Date = new Date(), locale?: string | string[], + zone?: string, ): IsoDate | null | undefined { const trimmed = raw.trim(); if (trimmed === '') return null; + // A FULL ISO datetime (pasted) decomposes: the DISPLAY-ZONE day of the instant. + if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) { + const iso = trimmed.replace(' ', 'T'); + const instant = zone ? DateTime.fromISO(iso, { zone }) : DateTime.fromISO(iso); + return instant.isValid ? instant.toFormat('yyyy-MM-dd') : undefined; + } + + // Year-less shapes complete from `now` — read in the display zone. + const nowYear = zone ? DateTime.fromJSDate(now, { zone }).year : now.getFullYear(); + // ISO: yyyy-M-d let match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(trimmed); if (match) return isoIfValid(Number(match[1]), Number(match[2]), Number(match[3])); @@ -198,7 +209,7 @@ export function parseDateInput( const month = Number(match[2]); const year = match[3] === undefined - ? now.getFullYear() + ? nowYear : match[3].length === 2 ? 2000 + Number(match[3]) : Number(match[3]); @@ -207,11 +218,60 @@ export function parseDateInput( } // Named months (the display's own format, localized + English). - if (/[\p{L}]/u.test(trimmed)) return parseNamedDate(trimmed, now, locale); + if (/[\p{L}]/u.test(trimmed)) return parseNamedDate(trimmed, nowYear, locale); return undefined; } +const placeholderCache = new Map(); + +/** + * The locale's NUMERIC date pattern as a fixed-size typing hint: + * `'dd.mm.yyyy'` for German, `'mm/dd/yyyy'` for en-US, `'yyyy. mm. dd.'` + * for Korean — order and separators from `Intl.formatToParts`, zero + * bundled tables. Four-digit year on purpose: it is what a commit + * displays back (the parser reads 2-digit years as 20xx regardless). + * Used as the control's default placeholder, which also floors the + * field width — same locale, same size, every render. + */ +export function localeDatePlaceholder(locale?: string | string[]): string { + const key = JSON.stringify(locale ?? ''); + const cached = placeholderCache.get(key); + if (cached !== undefined) return cached; + + // The reference day needs 2-digit day AND month — else a locale that + // ignores the '2-digit' request would produce a lying width floor. + let pattern = 'yyyy-mm-dd'; + try { + pattern = new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + .formatToParts(new Date(2024, 11, 31)) + .map((part) => { + switch (part.type) { + case 'day': + return 'dd'; + case 'month': + return 'mm'; + case 'year': + return 'yyyy'; + case 'literal': + return part.value; + default: + return ''; // era etc. — not typing hints + } + }) + .join(''); + } catch { + // Unknown locale tag — the ISO fallback stands. + } + + placeholderCache.set(key, pattern); + return pattern; +} + /** Localized display of an ISO date (`'12 May 2026'` / `'12. Mai 2026'`). */ export function formatIsoDate( iso: IsoDate | null, @@ -289,42 +349,49 @@ function weekdayLabel(date: Date, locale?: string | string[]): string { } /** - * The built-in slash commands: yesterday/today/tomorrow plus the next seven - * weekdays. Labels localize to `locale` (browser default when omitted); the - * matching basis always includes the English name and the ISO date. + * The built-in quick-pick commands: yesterday/today/tomorrow plus the next + * seven weekdays. Labels localize to `locale` (browser default when + * omitted); the matching basis always includes the English name and the + * ISO date. "Today" is the DISPLAY ZONE's today when a zone is given. */ -export function buildDateCommands(now: Date, locale?: string | string[]): DateCommand[] { +export function buildDateCommands( + now: Date, + locale?: string | string[], + zone?: string, +): DateCommand[] { + const base = zone ? DateTime.fromJSDate(now, { zone }) : DateTime.fromJSDate(now); + // Label formatting runs over a machine-local Date REBUILT from the + // calendar-day parts — weekday names are day-of-calendar facts, zone-free. const at = (offset: number) => { - const date = new Date(now); - date.setDate(date.getDate() + offset); - return date; + const day = base.plus({ days: offset }); + return { iso: day.toFormat('yyyy-MM-dd'), date: new Date(day.year, day.month - 1, day.day) }; }; const english = (date: Date) => new Intl.DateTimeFormat('en', { weekday: 'long' }).format(date).toLowerCase(); const relatives: DateCommand[] = ([-1, 0, 1] as const).map((offset) => { - const date = at(offset); + const { iso } = at(offset); const label = relativeLabel(offset, locale); const englishName = offset === 0 ? 'today' : offset === 1 ? 'tomorrow' : 'yesterday'; return { id: `ai-date-${englishName}`, label, - match: `${label} ${englishName} ${toIsoDate(date)}`.toLowerCase(), - iso: toIsoDate(date), + match: `${label} ${englishName} ${iso}`.toLowerCase(), + iso, }; }); const weekdays: DateCommand[] = Array.from({ length: 7 }, (_, index) => { - const date = at(index + 1); + const { iso, date } = at(index + 1); const label = weekdayLabel(date, locale); return { id: `ai-date-weekday-${index}`, label, - match: `${label} ${english(date)} ${toIsoDate(date)}`.toLowerCase(), - iso: toIsoDate(date), + match: `${label} ${english(date)} ${iso}`.toLowerCase(), + iso, }; }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts index 9f2d2bd..f71743a 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts @@ -1,5 +1,6 @@ import { Component, + DestroyRef, ElementRef, Injector, afterNextRender, @@ -10,10 +11,12 @@ import { output, signal, } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; import { DateTime } from 'luxon'; import { toIsoDate, formatIsoDate, type IsoDate } from './date-codec'; +import { todayIn } from '../datetime/db-entry'; interface CalendarDay { iso: IsoDate; @@ -102,6 +105,8 @@ function firstDayOfWeek(locale: string | string[] | undefined): number { (keydown)="handleKeydown($event)" (focusin)="gridFocused = true" (focusout)="gridFocused = false" + (mousedown)="handleGridMousedown($event)" + (mouseover)="handleGridMouseover($event)" >
@for (name of weekdayNames(); track $index) { @@ -120,11 +125,14 @@ function firstDayOfWeek(locale: string | string[] | undefined): number { [attr.data-today]="cell.today || null" [attr.data-active]="cell.iso === active() || null" [attr.data-selected]="cell.iso === selectedDay() || null" + [attr.data-range-start]="cell.iso === paintedRange()?.start || null" + [attr.data-range-end]="cell.iso === paintedRange()?.end || null" + [attr.data-in-range]="inPaintedRange(cell.iso) || null" [attr.aria-selected]="cell.iso === selectedDay()" [attr.aria-label]="dayAria(cell.iso)" [tabindex]="cell.iso === active() ? 0 : -1" (mousedown)="$event.preventDefault()" - (click)="picked.emit(cell.iso)" + (click)="handleCellClick(cell.iso, $event)" > {{ cell.day }} @@ -188,11 +196,23 @@ function firstDayOfWeek(locale: string | string[] | undefined): number { background: var(--mat-sys-primary, #4285f4); color: var(--mat-sys-on-primary, #fff); } + /* Range painting: endpoints filled, days between tinted (drag preview + committed range). */ + .cal__day[data-in-range] { + background: var(--mat-sys-secondary-container, #e8f0fe); + color: var(--mat-sys-on-secondary-container, #174ea6); + border-radius: 0; + } + .cal__day[data-range-start], + .cal__day[data-range-end] { + background: var(--mat-sys-primary, #4285f4); + color: var(--mat-sys-on-primary, #fff); + } .cal__day:focus-visible { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: 1px; } `, }) export class AngularInlineCalendar { #injector = inject(Injector); + #document = inject(DOCUMENT); /** The pending day — the field's parsed draft, mirrored per keystroke. */ activeDay = input(null); @@ -200,17 +220,122 @@ export class AngularInlineCalendar { /** The committed day (rendered filled). */ selectedDay = input(null); + /** + * Range gestures (T5): press-hold-drag paints a range, Ctrl/Cmd+click + * restarts one. Off by default — single-date fields keep plain picks. + */ + rangeGestures = input(false); + + /** The committed range endpoints, painted when no drag is in flight. */ + rangeStart = input(null); + rangeEnd = input(null); + locale = input(undefined); + /** The display zone (T6) — the today marker is that zone's today. */ + zone = input(undefined); + /** Reference clock — the today marker and the empty-field fallback month. */ now = input<() => Date>(() => new Date()); + /** Today, in the display zone. */ + protected today = computed(() => todayIn(this.now()(), this.zone())); + picked = output(); + /** Ctrl/Cmd+click (or Ctrl/Cmd+Enter in the grid): "restart the range here". */ + ctrlPicked = output(); + /** A drag settled across at least two days — the sorted range. */ + dragEnded = output<{ start: IsoDate; end: IsoDate }>(); escaped = output(); + // -- The drag (iusta's DateRangeDragAndRelease pointer logic, on our cells) ---- + + #dragAnchor = signal(null); + #dragHover = signal(null); + /** A finished drag must swallow the click the same mouseup produces. */ + #suppressClick = false; + #detachMouseup: (() => void) | null = null; + + /** What the grid paints: the live drag preview, else the committed range. */ + protected paintedRange = computed<{ start: IsoDate; end: IsoDate } | null>(() => { + const anchor = this.#dragAnchor(); + if (anchor !== null) { + const hover = this.#dragHover() ?? anchor; + return anchor <= hover ? { start: anchor, end: hover } : { start: hover, end: anchor }; + } + + const start = this.rangeStart(); + const end = this.rangeEnd(); + if (start === null || end === null || start === end) return null; + + return start <= end ? { start, end } : { start: end, end: start }; + }); + + protected inPaintedRange(iso: IsoDate): boolean { + const range = this.paintedRange(); + return range !== null && iso > range.start && iso < range.end; + } + protected gridRef = inject>(ElementRef); protected gridFocused = false; + constructor() { + inject(DestroyRef).onDestroy(() => this.#detachMouseup?.()); + } + + #dayOf(event: Event): IsoDate | null { + const cell = (event.target as HTMLElement).closest('[data-day]'); + return cell?.getAttribute('data-day') ?? null; + } + + /** Anchor a drag on primary-button press over a cell (range mode only). */ + protected handleGridMousedown(event: MouseEvent) { + if (!this.rangeGestures() || event.button !== 0) return; + const day = this.#dayOf(event); + if (day === null) return; + + this.#dragAnchor.set(day); + this.#dragHover.set(day); + + // Mouseup may land anywhere (outside the grid mid-drag) — listen on the document. + const onMouseup = () => this.#finishDrag(); + this.#document.addEventListener('mouseup', onMouseup, { once: true }); + this.#detachMouseup = () => this.#document.removeEventListener('mouseup', onMouseup); + } + + protected handleGridMouseover(event: MouseEvent) { + if (this.#dragAnchor() === null) return; + const day = this.#dayOf(event); + if (day !== null) this.#dragHover.set(day); + } + + #finishDrag() { + this.#detachMouseup = null; + const anchor = this.#dragAnchor(); + const hover = this.#dragHover(); + this.#dragAnchor.set(null); + this.#dragHover.set(null); + if (anchor === null || hover === null || anchor === hover) return; // a plain click — let it pick + + this.#suppressClick = true; + queueMicrotask(() => (this.#suppressClick = false)); + this.dragEnded.emit(anchor <= hover ? { start: anchor, end: hover } : { start: hover, end: anchor }); + } + + #cancelDrag() { + this.#detachMouseup?.(); + this.#detachMouseup = null; + this.#dragAnchor.set(null); + this.#dragHover.set(null); + } + + protected handleCellClick(day: IsoDate, event: MouseEvent) { + if (this.#suppressClick) return; + + if (this.rangeGestures() && (event.ctrlKey || event.metaKey)) this.ctrlPicked.emit(day); + else this.picked.emit(day); + } + /** * The active cell: FOLLOWS the draft mirror (`activeDay`), overridden by * grid navigation; an unparseable draft (null source) keeps the last @@ -218,13 +343,13 @@ export class AngularInlineCalendar { */ protected active = linkedSignal({ source: this.activeDay, - computation: (day, previous) => day ?? previous?.value ?? toIsoDate(this.now()()), + computation: (day, previous) => day ?? previous?.value ?? this.today(), }); protected weeks = computed(() => { const [, month] = parts(this.active()); const first = firstDayOfWeek(this.locale()); - const today = toIsoDate(this.now()()); + const today = this.today(); const firstOfMonth = DateTime.fromISO(this.active()).startOf('month'); // Luxon weekday: 1=Mon…7=Sun → JS convention (0=Sun) for the lead math. @@ -338,11 +463,20 @@ export class AngularInlineCalendar { case 'Enter': case ' ': event.preventDefault(); - this.picked.emit(this.active()); + if (this.rangeGestures() && (event.ctrlKey || event.metaKey)) { + this.ctrlPicked.emit(this.active()); + } else { + this.picked.emit(this.active()); + } break; case 'Escape': event.preventDefault(); event.stopPropagation(); + // Stage zero: a drag in flight cancels; the field keeps the session. + if (this.#dragAnchor() !== null) { + this.#cancelDrag(); + break; + } this.escaped.emit(); break; } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html index 2d62e48..3fb1707 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html @@ -1,30 +1,67 @@ - -{{ preview() }} - - - - + @if (prefixTpl(); as tpl) { + + } + + + + @if (suffixTpl(); as tpl) { + + } + + + {{ revertNotice() }} + + + +
+ @if (preview(); as reading) { +
{{ reading }}
+ } + +
+ +
+
+
diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts index ba453e9..4bfc7c5 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts @@ -4,7 +4,6 @@ import { FormField, form } from '@angular/forms/signals'; import { AngularInlineDuration, type InlineDurationSaved } from './angular-inline-duration'; import { parseDuration, formatDuration, describeDuration } from './duration-codec'; -import { AngularInlineText } from 'angular-inline-select'; // ============================================================================= // Codec @@ -46,7 +45,7 @@ describe('duration codec', () => { }); // ============================================================================= -// Component +// Component — the input rehost: one real input, gesture-tiered sessions // ============================================================================= @Component({ @@ -71,9 +70,7 @@ class DurationFormHost { interface Harness { fixture: ComponentFixture; host: DurationFormHost; - display: () => HTMLElement; - editor: () => HTMLElement | null; - inner: () => AngularInlineText; + input: () => HTMLInputElement; } function setup(): Harness { @@ -83,73 +80,97 @@ function setup(): Harness { return { fixture, host: fixture.componentInstance, - display: () => fixture.nativeElement.querySelector('.editable-text__display') as HTMLElement, - editor: () => document.querySelector('.editable-text__editor') as HTMLElement | null, - inner: () => - fixture.debugElement.children[0].children[0].componentInstance as AngularInlineText, + input: () => + fixture.nativeElement.querySelector('.inline-duration__input') as HTMLInputElement, }; } -async function typeText(h: Harness, text: string) { - const display = h.display(); - - const event = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; - Object.defineProperty(event, 'inputType', { value: 'insertText' }); - Object.defineProperty(event, 'data', { value: 'x' }); - - display.dispatchEvent(event); +/** Focus settlement runs a macrotask behind (`setTimeout(0)`) — flush it. */ +async function settle(h: Harness) { h.fixture.detectChanges(); - await h.fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); h.fixture.detectChanges(); +} - const editor = h.editor(); - if (!editor) throw new Error('elevated editor not found'); - - editor.textContent = text; - editor.dispatchEvent(new Event('input', { bubbles: true })); +function type(h: Harness, text: string) { + const input = h.input(); + input.focus(); + h.fixture.detectChanges(); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); h.fixture.detectChanges(); } -function accept(h: Harness) { - (h.inner() as unknown as { accept(): void }).accept(); +function press(h: Harness, key: string) { + h.input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); h.fixture.detectChanges(); } -describe('AngularInlineDuration', () => { +async function blurAway(h: Harness) { + (document.activeElement as HTMLElement | null)?.blur(); + await settle(h); +} + +describe('AngularInlineDuration (input rehost)', () => { let h: Harness; beforeEach(() => { h = setup(); }); - it('renders the committed seconds in clock format', () => { - expect(h.display().textContent).toBe('1:30'); + afterEach(async () => { + await blurAway(h); }); - it('commits unit tokens as seconds, snapped to step, with a live preview', async () => { - await typeText(h, '2h 15m'); + it('renders the committed seconds in clock format in a real input', () => { + expect(h.input().value).toBe('1:30'); + }); - const hint = document.querySelector('.editable-panel__message--hint'); - expect(hint?.textContent?.trim()).toBe('✓ 2 h 15 min'); + it('Enter commits unit tokens as seconds, snapped to step, with a live preview', () => { + type(h, '2h 15m'); - accept(h); + expect(document.querySelector('.inline-duration__preview')?.textContent?.trim()).toBe( + '✓ 2 h 15 min', + ); + + press(h, 'Enter'); expect(h.host.saved).toEqual([8100]); expect(h.host.sessions).toEqual([{ value: 8100, changed: true }]); - expect(h.display().textContent).toBe('2:15'); + expect(h.input().value).toBe('2:15'); // commits round-trip the codec }); - it('the parse gate blocks unreadable drafts', async () => { - await typeText(h, '1:75'); - accept(h); + it('the parse gate blocks Enter on unreadable drafts', () => { + type(h, '1:75'); + press(h, 'Enter'); expect(h.host.saved).toEqual([]); expect(h.host.field().value()).toBe(5400); + expect(h.input().getAttribute('aria-invalid')).toBe('true'); + }); + + it('blur with an unreadable draft SNAPS BACK to the baseline', async () => { + type(h, '1:75'); + await blurAway(h); + + expect(h.host.field().value()).toBe(5400); + expect(h.input().value).toBe('1:30'); + expect(h.host.saved).toEqual([]); + expect(h.host.sessions).toEqual([{ value: 5400, changed: false }]); + }); + + it('Escape reverts to the session baseline', () => { + type(h, '2h'); + press(h, 'Escape'); + + expect(h.host.field().value()).toBe(5400); + expect(h.input().value).toBe('1:30'); + expect(h.host.saved).toEqual([]); }); - it('an empty draft commits null', async () => { - await typeText(h, ''); - accept(h); + it('an empty draft commits null', () => { + type(h, ''); + press(h, 'Enter'); expect(h.host.field().value()).toBeNull(); expect(h.host.sessions).toEqual([{ value: null, changed: true }]); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index ebdcf21..1b16eee 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -1,23 +1,25 @@ import { Component, + DestroyRef, + ElementRef, + computed, + contentChild, + effect, inject, - TemplateRef, input, + linkedSignal, model, output, - computed, - linkedSignal, + signal, + untracked, viewChild, - contentChild, + type TemplateRef, } from '@angular/core'; +import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; +import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; -import { - AngularInlineText, - EditablePrefix, - EditableSuffix, - type InlineTextSaved, -} from 'angular-inline-select'; +import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; import { parseDuration, formatDuration, @@ -35,9 +37,14 @@ export interface InlineDurationSaved { } /** - * Inline duration: a `FormValueControl` for durations that COMPOSES the - * inline text control — the number control's sibling with a clock-shaped - * codec. Canonical value: SECONDS (`number | null`, empty commits `null`). + * Inline duration on a NATIVE INPUT — the input rehost (see + * ROADMAP-DATETIME). A `FormValueControl` for durations. Canonical value: + * SECONDS (`number | null`, empty commits `null`). + * + * Session semantics are GESTURE-TIERED (the family rule): Enter commits + * (an unreadable draft BLOCKS with the error), Escape reverts to the + * baseline, Tab/blur commits a readable draft and SNAPS an unreadable one + * back — never traps, never persists a draft error. * * - Drafts accept colon notation (positional by `durationFormat`), unit * tokens (`'1h 30m'`, `'45m'`, `'1.5h'`), or a bare number (minutes under @@ -49,21 +56,134 @@ export interface InlineDurationSaved { */ @Component({ selector: 'angular-inline-duration', - imports: [AngularInlineText], + imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet], templateUrl: './angular-inline-duration.html', - styles: ':host { display: inline; }', + styles: ` + :host { + display: inline; + } + + .inline-duration { + display: inline-flex; + align-items: baseline; + gap: 0.25ch; + max-width: 100%; + } + + /* The family look, on an input (see the date control for the rationale). */ + .inline-duration__input { + font: inherit; + color: inherit; + background: transparent; + border: 0; + padding: 0 0 0.1em; + margin: 0; + outline: none; + min-width: 1ch; + max-width: 100%; + field-sizing: content; + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + border-bottom: 0.0625rem dashed + var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); + } + .inline-duration__input:focus { + border-bottom-style: solid; + border-bottom-width: 0.125rem; + padding-bottom: calc(0.1em - 0.0625rem); + } + .inline-duration__input::placeholder { + font-style: italic; + color: inherit; + opacity: var(--editable-text-placeholder-opacity, 0.3875); + } + .inline-duration__input:disabled { + cursor: default; + border-bottom-color: var(--mat-sys-outline, #999); + } + + .inline-duration--invalid .inline-duration__input { + border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); + } + + /* BARE CHROME — the hosting container draws the chrome (see the date control). */ + :host(.inline-field-bare) .inline-duration__input { + border-bottom: none; + padding-bottom: 0; + } + :host(.inline-field-bare--hide-placeholder) .inline-duration__input::placeholder { + opacity: 0; + } + + .inline-duration__input--reverted { + animation: inline-duration-revert 0.6s ease-out; + } + @keyframes inline-duration-revert { + 0% { + background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); + } + 100% { + background: transparent; + } + } + + .inline-duration__affix { + white-space: nowrap; + user-select: none; + color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); + } + + .inline-duration__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .inline-duration__panel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); + color: var(--mat-sys-on-surface, inherit); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: var( + --mat-sys-level2, + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 2px 6px 2px rgba(0, 0, 0, 0.15) + ); + } + .inline-duration__preview { + padding: 2px 8px; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-on-surface-variant, #5f6368); + font-variant-numeric: tabular-nums; + } + .inline-duration__errors:not([hidden]) { + padding: 0 8px 4px; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-error, #dc3545); + } + + @media (prefers-reduced-motion: reduce) { + .inline-duration__input--reverted { + animation: none; + } + } + `, host: { '[style.display]': 'hidden() ? "none" : null', }, }) export class AngularInlineDuration implements FormValueControl { - /** The composed text control — all session machinery lives there. */ - protected inner = viewChild.required(AngularInlineText); + #document = inject(DOCUMENT); /** The committed value channel: duration in SECONDS, or `null`. */ value = model(null); - /** Form Value Contract — forwarded into the inner control. */ + /** Form Value Contract. */ errors = input([]); disabled = input(false); readonly = input(false); @@ -74,7 +194,7 @@ export class AngularInlineDuration implements FormValueControl { placeholder = input('0:00'); - /** Accessible name for the field (contenteditable has no native label association). */ + /** Accessible name for the field. */ ariaLabel = input(undefined); /** How colon notation reads and how committed values render. */ @@ -93,14 +213,14 @@ export class AngularInlineDuration implements FormValueControl { protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); - /** * Group-forwarded contract state (role-provided; absent standalone). * Merged by PULL — the leaf stays decoupled, no effects involved. */ #leafState = inject(INLINE_TEMPORAL_LEAF_STATE, { optional: true, self: true }); - protected effectiveDisabled = computed( + /** Public: the composed disabled verdict (own input + group-fed state). */ + readonly effectiveDisabled = computed( () => this.disabled() || (this.#leafState?.disabled() ?? false), ); protected effectiveReadonly = computed( @@ -113,43 +233,84 @@ export class AngularInlineDuration implements FormValueControl { () => this.invalid() || (this.#leafState?.invalid() ?? false), ); - /** Form Value Contract: touch — forwarded from the inner control. */ + /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per accepted edit session — seconds or `null`. */ + /** Hard commit event: fires once per changed settlement — seconds or `null`. */ savedModelChange = output(); - /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ saved = output(); - /** Whether an edit session is open. Two-way bindable. */ + /** Whether an edit session is open (= focus is within). Two-way bindable. */ editing = model(false); + protected display = computed(() => formatDuration(this.value(), this.durationFormat())); + + // -- The session (one field, the date control's side pattern) ------------------ + + /** Whether a session is open on this field. */ + #open = signal(false); + /** - * The string channel feeding the inner control: the formatted committed - * value while idle, the raw draft while a session is open. + * The input's text: user-owned while the session is open (frozen + * linkedSignal), the committed display otherwise. */ - protected innerValue = linkedSignal({ - source: () => formatDuration(this.value(), this.durationFormat()), - computation: (source, prev) => (this.editing() ? (prev?.value ?? source) : source), + protected draft = linkedSignal({ + source: this.display, + computation: (source, prev) => (this.#open() ? (prev?.value ?? source) : source), }); + /** The committed VALUE at session start — what Escape and snap-back restore. */ + #baselineValue: number | null = null; + + /** + * Whether the USER touched the draft since the last settlement. An + * untouched session settles WHERE THE VALUE STANDS — re-deriving it from + * the draft would undo external writes (a group moving this length) with + * stale session state. + */ + #dirty = false; + + /** Enter was pressed on an unreadable draft — reveals the parse-gate error. */ + #saveAttempted = signal(false); + + /** Enter/Escape hide the panel until the next keystroke or session. */ + #panelDismissed = signal(false); + /** The parse gate: whether the current draft fails the codec. Public for consumers. */ readonly parseFailed = computed( - () => parseDuration(this.innerValue(), this.durationFormat()) === undefined, + () => parseDuration(this.draft(), this.durationFormat()) === undefined, ); - /** Errors forwarded inward: contract + group-routed errors + the parse gate. */ - protected innerErrors = computed(() => { - const groupErrors = this.#leafState?.errors() ?? []; - const base = groupErrors.length ? [...this.errors(), ...groupErrors] : this.errors(); + #selfTouched = signal(false); - return this.parseFailed() ? [...base, { kind: 'parse' }] : base; - }); + protected isInvalid = computed( + () => + this.effectiveInvalid() || + this.errors().length > 0 || + (this.#leafState?.errors().length ?? 0) > 0, + ); + + /** + * The mat split: the consumer decides what errors say, the field when they + * show. Public — the field's presentational verdict, the thing a hosting + * container (a mat-form-field adapter) needs to mirror. + */ + readonly errorsVisible = computed( + () => this.isInvalid() && (this.effectiveTouched() || this.#selfTouched()), + ); + + /** Public: whether the field holds no value. */ + readonly isEmpty = computed(() => this.value() === null); + + protected parseGateVisible = computed(() => this.#saveAttempted() && this.parseFailed()); + + protected errorSlotVisible = computed(() => this.errorsVisible() || this.parseGateVisible()); /** Live interpretation preview: `✓ 1 h 30 min` / `… raw`. */ protected preview = computed(() => { - const raw = this.innerValue().trim(); + const raw = this.draft().trim(); if (!raw) return ''; const parsed = parseDuration(raw, this.durationFormat()); @@ -158,39 +319,222 @@ export class AngularInlineDuration implements FormValueControl { return `✓ ${describeDuration(this.#snap(parsed))}`; }); + /** The panel appears when there is something to say — a reading or an error. */ + protected panelOpen = computed( + () => + this.#open() && + !this.#panelDismissed() && + (this.preview() !== '' || this.errorSlotVisible()), + ); + + /** Public: whether the panel is showing (hosting containers coordinate on it). */ + readonly panelVisible = computed(() => this.panelOpen()); + + /** An outside click dismisses the panel — the session survives (focusout settles). */ + protected dismissPanel() { + this.#panelDismissed.set(true); + } + + protected overlayPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, + ]; + + protected revertFlash = signal(false); + protected revertNotice = signal(''); + + protected durationInput = viewChild>('durationInput'); + protected panelRef = viewChild>('panel'); + + #focusCheckTimer: ReturnType | null = null; + #flashTimer: ReturnType | null = null; + + constructor() { + inject(DestroyRef).onDestroy(() => { + if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); + if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); + }); + + // The editing bridge — see the date control. + effect(() => { + const editing = this.editing(); + untracked(() => { + const open = this.#open(); + if (editing && !open) { + this.durationInput()?.nativeElement.focus(); + } else if (!editing && open) { + this.#settle(); + this.durationInput()?.nativeElement.blur(); + } + }); + }); + } + #snap(seconds: number): number { const step = this.step(); return step > 1 ? Math.round(seconds / step) * step : seconds; } - /** Live channel: every keystroke parses; readable drafts flow as seconds. */ - protected handleInnerValue(raw: string) { - this.innerValue.set(raw); + protected sizeOf(): number { + return Math.max(1, (this.draft() || this.placeholder()).length); + } + + protected ariaInvalid(): boolean { + return this.errorsVisible() || (this.#open() && this.#saveAttempted() && this.parseFailed()); + } + + // -- The live channel ----------------------------------------------------------- + + #openSession() { + if (this.#open()) return; + this.#baselineValue = this.value(); + this.#dirty = false; + this.#saveAttempted.set(false); + this.#panelDismissed.set(false); + this.#open.set(true); + } + + /** Every keystroke: readable drafts flow into the model live (unsnapped). */ + protected handleInput(raw: string) { + this.#openSession(); + this.draft.set(raw); + this.#dirty = true; + this.#saveAttempted.set(false); + this.#panelDismissed.set(false); const parsed = parseDuration(raw, this.durationFormat()); if (parsed !== undefined && parsed !== this.value()) this.value.set(parsed); } - /** Retype the settled session: strings inside, seconds outside. */ - protected handleInnerSaved(session: InlineTextSaved) { - const parsed = parseDuration(session.value, this.durationFormat()); - const value = parsed === undefined ? this.value() : parsed === null ? null : this.#snap(parsed); + // -- Focus flow ------------------------------------------------------------------- + + protected handleFocusIn() { + this.#openSession(); + this.editing.set(true); + } - if (session.changed) { - this.value.set(value); - this.savedModelChange.emit(value); + protected handleFocusOut() { + if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); + this.#focusCheckTimer = setTimeout(() => this.#onFocusSettled(), 0); + } + + #onFocusSettled() { + this.#focusCheckTimer = null; + const active = this.#document.activeElement; + const inField = active !== null && active === this.durationInput()?.nativeElement; + const inPanel = (active !== null && this.panelRef()?.nativeElement.contains(active)) ?? false; + + if (!inField && !inPanel) { + this.#settle(); + this.editing.set(false); + } + } + + // -- Settlement (ONE per session — commit, snap-back, Escape, clear) -------------- + + #settle(options: { revert?: boolean; keepOpen?: boolean } = {}) { + if (!this.#open()) return; + + // An untouched session settles where the value stands (see #dirty). + const untouched = !options.revert && !this.#dirty; + + let value: number | null; + let snappedBack = false; + + if (untouched) { + value = this.value(); + } else if (options.revert) { + value = this.#baselineValue; + } else { + const parsed = parseDuration(this.draft(), this.durationFormat()); + if (parsed === undefined) { + // Snap-back: an unreadable draft reverts to the session baseline. + snappedBack = true; + value = this.#baselineValue; + } else { + value = parsed === null ? null : this.#snap(parsed); + } + } + + if (!untouched && value !== this.value()) this.value.set(value); + const changed = !untouched && value !== this.#baselineValue; + this.#dirty = false; + + if (options.keepOpen) { + this.#baselineValue = value; + this.draft.set(this.display()); + this.#saveAttempted.set(false); + } else { + this.#open.set(false); + this.#saveAttempted.set(false); + } + + if (snappedBack) this.#announceRevert(value); + + this.#selfTouched.set(true); + this.touch.emit(); + + if (changed) this.savedModelChange.emit(value); + this.saved.emit({ value, changed }); + } + + #announceRevert(value: number | null) { + const restored = value === null ? 'empty' : formatDuration(value, this.durationFormat()); + this.revertNotice.set(`Reverted to ${restored}`); + this.revertFlash.set(true); + + if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); + this.#flashTimer = setTimeout(() => this.revertFlash.set(false), 600); + } + + // -- Keyboard ----------------------------------------------------------------------- + + protected handleKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'Enter': { + event.preventDefault(); + if (parseDuration(this.draft(), this.durationFormat()) === undefined) { + // The parse gate: the user ASKED for a commit — block and say why. + this.#saveAttempted.set(true); + return; + } + + this.#settle({ keepOpen: true }); + this.#panelDismissed.set(true); + return; + } + case 'Escape': { + event.preventDefault(); + event.stopPropagation(); + this.#settle({ revert: true, keepOpen: true }); + this.#panelDismissed.set(true); + return; + } } + } - this.saved.emit({ value, changed: session.changed }); + /** + * Toggles the preview panel. PUBLIC — the container-click affordance a + * hosting container (the mat-form-field adapter) delegates to. + */ + togglePanel() { + if (this.effectiveDisabled() || this.effectiveReadonly()) return; + this.#panelDismissed.update((dismissed) => !dismissed); } - /** Form Value Contract: focus — delegates to the inner control. */ + // -- Form Value Contract ------------------------------------------------------------------ + focus(options?: FocusOptions) { - this.inner().focus(options); + this.durationInput()?.nativeElement.focus(options); } - /** Form Value Contract: reset — delegates to the inner control. */ + /** Presentation-only rollback — see the date control. */ reset() { - this.inner().reset(); + if (!this.#open()) return; + + if (this.#baselineValue !== this.value()) this.value.set(this.#baselineValue); + this.draft.set(this.display()); + this.#saveAttempted.set(false); + this.#panelDismissed.set(true); } } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index 1324add..b188dd2 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -1,22 +1,60 @@ - -{{ preview() }} - - - @if (dayOffset() > 0) { - +{{ dayOffset() }} + + @if (prefixTpl(); as tpl) { + } - @if (consumerSuffixTpl(); as consumer) { - + + + + + + @if (dayOffset() > 0) { + +{{ dayOffset() }} + } + + + @if (consumerSuffixTpl(); as tpl) { + } @else if (showNativePicker()) {
`, }) @@ -57,6 +61,7 @@ class QuartetHost { start = signal(at('2026-07-21', '21:00')); end = signal(at('2026-07-22', '06:00')); length = signal(32_400); + endDay = signal(dayToDbEntry('2026-07-22')); dateRanges: (ComposedDateRange | null)[] = []; timeRanges: (ComposedTimeRange | null)[] = []; @@ -65,11 +70,14 @@ class QuartetHost { now = () => NOW; } +// Every leaf is a REAL INPUT since the rehost — one selector, DOM order. +const LEAF_INPUTS = '.inline-date__input, .inline-time__input, .inline-duration__input'; + interface Harness { fixture: ComponentFixture; host: QuartetHost; group: () => DateTimeRangeGroup; - displays: () => HTMLElement[]; + inputs: () => HTMLInputElement[]; } function setup(): Harness { @@ -80,47 +88,38 @@ function setup(): Harness { fixture, host: fixture.componentInstance, group: () => fixture.componentInstance.group(), - displays: () => [...fixture.nativeElement.querySelectorAll('.editable-text__display')], + inputs: () => [...fixture.nativeElement.querySelectorAll(LEAF_INPUTS)] as HTMLInputElement[], }; } -/** Elevate the field at `index`, type `text`, commit with the accept action. */ +/** + * Type into the leaf input at `index` and commit with Enter (synchronous); + * the trailing blur flushes the focus-settlement timer. + */ async function commitInto(h: Harness, index: number, text: string) { - const display = h.displays()[index]; - - const before = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; - Object.defineProperty(before, 'inputType', { value: 'insertText' }); - Object.defineProperty(before, 'data', { value: 'x' }); - display.dispatchEvent(before); - h.fixture.detectChanges(); - await h.fixture.whenStable(); + const input = h.inputs()[index]; + input.focus(); h.fixture.detectChanges(); - const editor = document.querySelector('.editable-text__editor') as HTMLElement | null; - if (!editor) throw new Error('elevated editor not found'); - - editor.textContent = text; - const selection = document.getSelection(); - const range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection?.removeAllRanges(); - selection?.addRange(range); - editor.dispatchEvent(new Event('input', { bubbles: true })); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); h.fixture.detectChanges(); - editor.dispatchEvent( + input.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); h.fixture.detectChanges(); - await h.fixture.whenStable(); + + input.blur(); + await new Promise((resolve) => setTimeout(resolve)); h.fixture.detectChanges(); } -// Field order in the template: 0 day · 1 start · 2 end · 3 length. +// Leaf input order in the template: 0 day · 1 start · 2 end · 3 length · 4 end day. const START = 1; const END = 2; const LENGTH = 3; +const END_DAY = 4; describe('DateTimeRangeGroup', () => { let h: Harness; @@ -255,6 +254,47 @@ describe('DateTimeRangeGroup', () => { expect(h.host.durations).toEqual([]); }); + it('end-day commits move the end onto the day, wall-clock preserved, duration follows', async () => { + await commitInto(h, END_DAY, '24.7.2026'); + + expect(h.host.end()).toBe(at('2026-07-24', '06:00')); + expect(h.host.length()).toBe(57 * 3600); // 21 Jul 21:00 → 24 Jul 06:00 + expect(h.group().endDayOffset()).toBe(3); + }); + + it('an end-day BEFORE the start is an ERROR, not an auto-fix', async () => { + await commitInto(h, END_DAY, '20.7.2026'); + + // The violation STANDS (no roll-forward), the duration is underivable. + expect(h.host.end()).toBe(at('2026-07-20', '06:00')); + expect(h.host.length()).toBeNull(); + expect(h.group().orderingErrors().length).toBe(1); + + // Recovery clears the error and re-derives the duration. + await commitInto(h, END_DAY, '22.7.2026'); + expect(h.group().orderingErrors()).toEqual([]); + expect(h.host.length()).toBe(32_400); + }); + + it('ISO-paste into the START decomposes: the instant lands, the day leaves sync', async () => { + await commitInto(h, START, '2026-07-25T08:00'); + + expect(h.host.start()).toBe(at('2026-07-25', '08:00')); + expect(h.host.day()).toBe(dayToDbEntry('2026-07-25')); // day leaf synced + // The end rolled forward past the new start, wall-clock preserved. + expect(h.host.end()).toBe(at('2026-07-26', '06:00')); + expect(h.host.endDay()).toBe(dayToDbEntry('2026-07-26')); // end-day leaf synced + expect(h.host.length()).toBe(22 * 3600); + }); + + it('ISO-paste into the END is explicit: no re-anchor, a violation stands as the error', async () => { + await commitInto(h, END, '2026-07-19T06:00'); + + expect(h.host.end()).toBe(at('2026-07-19', '06:00')); + expect(h.host.length()).toBeNull(); + expect(h.group().orderingErrors().length).toBe(1); + }); + it('group writes flow through value, never emitting saved on the written control', async () => { let endSessions = 0; const endControl = h.fixture.debugElement.children[0].children[2] @@ -364,41 +404,32 @@ function boundSetup(type: Type) { return { fixture, host: fixture.componentInstance, - displays: () => - [...fixture.nativeElement.querySelectorAll('.editable-text__display')] as HTMLElement[], + inputs: () => + [...fixture.nativeElement.querySelectorAll(LEAF_INPUTS)] as HTMLInputElement[], }; } async function commitIntoBound( fixture: ComponentFixture, - displays: () => HTMLElement[], + inputs: () => HTMLInputElement[], index: number, text: string, ) { - const before = new Event('beforeinput', { bubbles: true, cancelable: true }) as InputEvent; - Object.defineProperty(before, 'inputType', { value: 'insertText' }); - Object.defineProperty(before, 'data', { value: 'x' }); - displays()[index].dispatchEvent(before); - fixture.detectChanges(); - await fixture.whenStable(); + const input = inputs()[index]; + input.focus(); fixture.detectChanges(); - const editor = document.querySelector('.editable-text__editor') as HTMLElement; - editor.textContent = text; - const selection = document.getSelection(); - const range = document.createRange(); - range.selectNodeContents(editor); - range.collapse(false); - selection?.removeAllRanges(); - selection?.addRange(range); - editor.dispatchEvent(new Event('input', { bubbles: true })); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); fixture.detectChanges(); - editor.dispatchEvent( + input.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); fixture.detectChanges(); - await fixture.whenStable(); + + input.blur(); + await new Promise((resolve) => setTimeout(resolve)); fixture.detectChanges(); } @@ -408,8 +439,12 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - const texts = h.displays().map((display) => display.textContent?.trim()); - expect(texts).toEqual(['Jul 21, 2026', '21:00', '06:00', '9:00']); + expect(h.inputs().map((input) => input.value)).toEqual([ + 'Jul 21, 2026', + '21:00', + '06:00', + '9:00', + ]); }); it('a leaf commit flows UP: one composed model write, one savedModelChange', async () => { @@ -417,7 +452,7 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - await commitIntoBound(h.fixture, h.displays, 2, '23:30'); // end field + await commitIntoBound(h.fixture, h.inputs, END, '23:30'); expect(h.host.model()).toEqual({ start: at('2026-07-21', '21:00'), @@ -434,7 +469,7 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - await commitIntoBound(h.fixture, h.displays, 3, '2:00'); // length field + await commitIntoBound(h.fixture, h.inputs, LENGTH, '2:00'); expect(h.host.model()).toEqual({ start: at('2026-07-21', '21:00'), @@ -457,8 +492,12 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { await h.fixture.whenStable(); h.fixture.detectChanges(); - const texts = h.displays().map((display) => display.textContent?.trim()); - expect(texts).toEqual(['Aug 1, 2026', '08:00', '12:00', '4:00']); + expect(h.inputs().map((input) => input.value)).toEqual([ + 'Aug 1, 2026', + '08:00', + '12:00', + '4:00', + ]); }); it('shape-echo: a {start, end} binding never grows a duration key', async () => { @@ -467,9 +506,9 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { h.fixture.detectChanges(); // The duration leaf still DISPLAYS the derived length… - expect(h.displays()[3].textContent?.trim()).toBe('9:00'); + expect(h.inputs()[LENGTH].value).toBe('9:00'); - await commitIntoBound(h.fixture, h.displays, 2, '23:30'); + await commitIntoBound(h.fixture, h.inputs, END, '23:30'); // …but the model echoes the bound shape: no duration key. expect(h.host.model()).toEqual({ diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.ts index 9897a9d..88d668d 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.ts @@ -18,6 +18,7 @@ import { AngularInlineTime } from '../angular-inline-time/angular-inline-time'; import { INLINE_TIME_DAY_OFFSET } from '../angular-inline-time/day-offset'; import { AngularInlineDuration } from '../angular-inline-duration/angular-inline-duration'; import { INLINE_TEMPORAL_LEAF_STATE, type TemporalLeafState } from '../leaf-state'; +import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; import { addLocalDays, composeDbEntry, @@ -108,6 +109,7 @@ const NO_ERRORS = signal([]).a }) export class DateTimeRangeGroup { #day = signal(null); + #endDay = signal(null); #start = signal(null); #end = signal(null); #length = signal(null); @@ -115,6 +117,18 @@ export class DateTimeRangeGroup { /** Present when the GROUP carries the `[formField]` — form-bound mode. */ #ownField = inject(FormField, { optional: true, self: true }); + /** + * T6 — the DISPLAY ZONE the group's day arithmetic runs in. MUST agree + * with the leaves' zones: set it once via `provideInlineTemporalZone` + * (both group and leaves fall back to the token), or set the input on + * the group AND every leaf. + */ + zone = input(undefined); + + #zoneDefault = inject(INLINE_TEMPORAL_ZONE, { optional: true }); + + readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); + /** * The group's OWN value channel. Outbound it always mirrors the composed * leaves (harmless when nobody listens); inbound it only pushes down when @@ -165,7 +179,34 @@ export class DateTimeRangeGroup { if (!control) return null; const start = toInternalRange(control.value()).start; - return start === null ? null : localDayOf(start); + return start === null ? null : localDayOf(start, this.effectiveZone()); + }); + + /** The END's LOCAL calendar day, read off the end-day control (T5 maximal form). */ + readonly endDay = computed(() => { + const control = this.#endDay(); + if (!control) return null; + + const start = toInternalRange(control.value()).start; + return start === null ? null : localDayOf(start, this.effectiveZone()); + }); + + /** + * `end >= start` over the COMPOSED instants — REAL now that the end-day + * field (and explicit ISO pastes) can produce violations; typed TIMES + * still roll forward and can't. Routed to the END leaves, revealed by + * their own touched machinery. + */ + readonly orderingErrors = computed(() => { + const start = this.start(); + const end = this.end(); + // DB entries are fixed-width UTC ISO strings — lexicographic order IS + // instant order. + if (start !== null && end !== null && end < start) { + return [{ kind: 'temporal-order', message: 'The end lies before the start.' }]; + } + + return []; }); /** The endpoint instants and duration, read live off the controls. */ @@ -182,22 +223,23 @@ export class DateTimeRangeGroup { if (start === null) return 0; const end = this.end(); - if (end !== null) return Math.max(0, localDayDiff(start, end) ?? 0); + if (end !== null) return Math.max(0, localDayDiff(start, end, this.effectiveZone()) ?? 0); const length = this.length(); - if (length !== null) return Math.max(0, localDayDiff(start, shiftDbEntry(start, length)) ?? 0); + if (length !== null) return Math.max(0, localDayDiff(start, shiftDbEntry(start, length), this.effectiveZone()) ?? 0); return 0; }); /** The composed DATE value: day boundaries as DB entries, over-count applied. */ readonly dateRange = computed(() => { - const startDay = this.day() ?? (this.start() !== null ? localDayOf(this.start()) : null); + const zone = this.effectiveZone(); + const startDay = this.day() ?? (this.start() !== null ? localDayOf(this.start(), zone) : null); if (startDay === null) return null; return { - start: dayToDbEntry(startDay), - end: dayEndToDbEntry(addLocalDays(startDay, this.endDayOffset())), + start: dayToDbEntry(startDay, zone), + end: dayEndToDbEntry(addLocalDays(startDay, this.endDayOffset()), zone), }; }); @@ -272,10 +314,35 @@ export class DateTimeRangeGroup { this.#start()?.value.set(start); this.#end()?.value.set(end); this.#length()?.value.set(duration); - this.#day()?.value.set(start === null ? null : dayToDbEntry(localDayOf(start)!)); + this.#day()?.value.set(start === null ? null : dayToDbEntry(localDayOf(start, this.effectiveZone())!, this.effectiveZone())); + this.#endDay()?.value.set(end === null ? null : dayToDbEntry(localDayOf(end, this.effectiveZone())!, this.effectiveZone())); + } + + /** + * The day leaves are RENDERINGS of the instants' date parts — after any + * propagation that may have moved an instant's day (an ISO paste, a + * multi-day duration, an end-day commit), they re-mirror. Writes go + * through `value` (no `saved`), equality-guarded: no cascades. + */ + #syncDayLeaves() { + const start = this.start(); + const dayControl = this.#day(); + if (dayControl && start !== null) { + const day = dayToDbEntry(localDayOf(start, this.effectiveZone())!, this.effectiveZone()); + if (!Object.is(dayControl.value(), day)) dayControl.value.set(day); + } + + const end = this.end(); + const endDayControl = this.#endDay(); + if (endDayControl && end !== null) { + const day = dayToDbEntry(localDayOf(end, this.effectiveZone())!, this.effectiveZone()); + if (!Object.is(endDayControl.value(), day)) endDayControl.value.set(day); + } } #emitChanges() { + this.#syncDayLeaves(); + let changed = false; const date = this.dateRange(); @@ -325,6 +392,10 @@ export class DateTimeRangeGroup { this.#registerBinding(leafBound, 'rangeDay'); this.#day.set(control); } + attachEndDay(control: AngularInlineDate, leafBound = false) { + this.#registerBinding(leafBound, 'rangeEndDay'); + this.#endDay.set(control); + } attachStart(control: AngularInlineTime, leafBound = false) { this.#registerBinding(leafBound, 'rangeStart'); this.#start.set(control); @@ -382,13 +453,45 @@ export class DateTimeRangeGroup { * `'240:30'` → +10) is an explicit over-count: it anchors on the start's * day directly. */ - endCommitted(dayOverflow = 0) { + endCommitted(dayOverflow = 0, explicitDay = false) { const start = this.start(); const end = this.end(); if (start !== null && end !== null) { - const day = addLocalDays(localDayOf(start)!, dayOverflow); - this.#induceFrom(start, composeDbEntry(day, localTimeOf(end)!)); + if (explicitDay) { + // A pasted full instant IS the end — no re-anchor, no roll. An end + // before the start stands as the ORDERING ERROR; the duration is + // then underivable. + const diff = diffDbEntrySeconds(start, end)!; + this.#writeLength(diff > 0 ? diff : null); + } else { + const day = addLocalDays(localDayOf(start, this.effectiveZone())!, dayOverflow); + this.#induceFrom(start, composeDbEntry(day, localTimeOf(end, this.effectiveZone())!, this.effectiveZone())); + } + } + + this.#emitChanges(); + } + + /** + * The END-DAY settled (the maximal five-field form): the end instant + * moves onto the typed day preserving its wall-clock time — deliberately + * WITHOUT rolling forward. An end before the start is a legitimate ERROR + * state now (the ordering error on the end leaves), and the duration is + * underivable (`null` — never a stale one). + */ + endDayCommitted() { + const day = this.endDay(); + const end = this.end(); + + if (day !== null && end !== null) { + this.#writeEnd(composeDbEntry(day, localTimeOf(end, this.effectiveZone())!, this.effectiveZone())); + + const start = this.start(); + if (start !== null) { + const diff = diffDbEntrySeconds(start, this.end()!)!; + this.#writeLength(diff > 0 ? diff : null); + } } this.#emitChanges(); @@ -413,10 +516,16 @@ export class DateTimeRangeGroup { if (day !== null) { const start = this.start(); const end = this.end(); - const offset = start !== null && end !== null ? Math.max(0, localDayDiff(start, end) ?? 0) : 0; + const offset = start !== null && end !== null ? Math.max(0, localDayDiff(start, end, this.effectiveZone()) ?? 0) : 0; - if (start !== null) this.#writeStart(composeDbEntry(day, localTimeOf(start)!)); - if (end !== null) this.#writeEnd(composeDbEntry(addLocalDays(day, offset), localTimeOf(end)!)); + if (start !== null) { + this.#writeStart(composeDbEntry(day, localTimeOf(start, this.effectiveZone())!, this.effectiveZone())); + } + if (end !== null) { + this.#writeEnd( + composeDbEntry(addLocalDays(day, offset), localTimeOf(end, this.effectiveZone())!, this.effectiveZone()), + ); + } } this.#emitChanges(); @@ -432,7 +541,7 @@ export class DateTimeRangeGroup { if (control && control.value() !== value) control.value.set(value); } - #writeLength(value: number) { + #writeLength(value: number | null) { const control = this.#length(); if (control && control.value() !== value) control.value.set(value); } @@ -453,7 +562,10 @@ function provideLeafState(withErrors: boolean) { readonly: group.readonly, touched: group.touched, invalid: group.invalid, - errors: withErrors ? group.errors : NO_ERRORS, + // Consumer errors + the group's OWN ordering verdict, end leaves only. + errors: withErrors + ? computed(() => [...group.errors(), ...group.orderingErrors()]) + : NO_ERRORS, }; }, }; @@ -515,7 +627,26 @@ export class RangeEnd { group.attachEnd(control, leafHasOwnField()); control.touch.subscribe(() => group.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.endCommitted(session.dayOverflow); + if (session.changed) group.endCommitted(session.dayOverflow, session.explicitDay); + }); + } +} + +/** + * Marks the group's END-DAY control (the maximal five-field form): + * ``. Receives the ordering errors — + * this leaf is where violations are made. + */ +@Directive({ selector: 'angular-inline-date[rangeEndDay]', providers: [provideLeafState(true)] }) +export class RangeEndDay { + constructor() { + const group = inject(DateTimeRangeGroup); + const control = inject(AngularInlineDate); + + group.attachEndDay(control, leafHasOwnField()); + control.touch.subscribe(() => group.touch.emit()); + control.saved.subscribe((session) => { + if (session.changed) group.endDayCommitted(); }); } } diff --git a/projects/angular-inline-select/tsconfig.lib.json b/projects/angular-inline-select/tsconfig.lib.json index d62640b..8faeca0 100644 --- a/projects/angular-inline-select/tsconfig.lib.json +++ b/projects/angular-inline-select/tsconfig.lib.json @@ -8,7 +8,12 @@ "declarationMap": true, "types": [] }, - "include": ["src/**/*.ts", "phone/src/**/*.ts", "temporal/src/**/*.ts"], + "include": [ + "src/**/*.ts", + "phone/src/**/*.ts", + "temporal/src/**/*.ts", + "temporal-mat/src/**/*.ts" + ], "exclude": ["**/*.spec.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/projects/angular-inline-select/tsconfig.spec.json b/projects/angular-inline-select/tsconfig.spec.json index 78a8145..e51d470 100644 --- a/projects/angular-inline-select/tsconfig.spec.json +++ b/projects/angular-inline-select/tsconfig.spec.json @@ -12,7 +12,9 @@ "phone/src/**/*.d.ts", "phone/src/**/*.spec.ts", "temporal/src/**/*.d.ts", - "temporal/src/**/*.spec.ts" + "temporal/src/**/*.spec.ts", + "temporal-mat/src/**/*.d.ts", + "temporal-mat/src/**/*.spec.ts" ], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 874f0f9..7d5b02e 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -3,10 +3,12 @@

Inline temporal editables

- The temporal family — codec compositions over the text core, same styling, same session semantics. There is a - difference between what the user SEES and what is BEHIND it: displays are local readings, models are UTC ISO - DB entries (datetime.toUTC().toISO(), SQL-friendly) — durations plain seconds. All fields are - driven by [formField] and interpret the draft live instead of reformatting it. + The temporal family — NATIVE INPUTS driven by codecs (the input rehost): the family feel is the underline + styling and zero layout shift, not shared DOM. Sessions are gesture-tiered — Enter commits (the parse gate + blocks unreadable drafts), Escape reverts, Tab/blur commits what parses and SNAPS BACK what doesn't. Displays + are local readings; models are UTC ISO DB entries (datetime.toUTC().toISO(), SQL-friendly) — + durations plain seconds. All fields are driven by [formField] and interpret the draft live + instead of reformatting it.

@@ -14,9 +16,10 @@

Inline temporal editables

Date — what the user sees vs the model

- One [formField]. Type “24.12.” (year auto-completes), an ISO date, or “/” for - /today, /tomorrow and the next weekdays (labels via Intl). The model - behind the local calendar day is the UTC ISO DB entry of its local startOf('day'). + One [formField]. Type “24.12.” (year auto-completes), an ISO date, or a month name — focus + opens the calendar panel without stealing the caret (ArrowDown enters the grid; yesterday/today/tomorrow + chips are the quick-picks, labels via Intl). The model behind the local calendar day is the + UTC ISO DB entry of its local startOf('day').

@@ -140,10 +143,12 @@

Duration — what the user sees vs the model

Date range — the binding shape IS the mode

- The SAME date control, bound with {{ '{' }} start, end {{ '}' }} — the shape-echo turns it - ranged and echoes commits in that shape: start = local startOf('day'), - end = endOf('day'). Typing a day moves the start and keeps the distinct end - (the two-field ranged UI + calendar drag arrive with T2/T5). + The SAME date control, bound with {{ '{' }} start, end {{ '}' }} — the shape-echo renders the + TWO-FIELD pair and echoes commits in that shape: start = local startOf('day'), + end = endOf('day'). Tab commits the start and lands in the end; each side owns + its clear (half-open ranges are real states); an unreadable draft snaps back on blur — Enter is the only + gesture the parse gate blocks. On the calendar: PRESS-HOLD-DRAG paints the range, Ctrl/Cmd+click restarts + it half-open, and a pasted full ISO datetime reads as its local day.

@@ -281,6 +286,13 @@

The quartet — ONE form field, the group is the

+ + + + +
{{ stayModel()?.end ?? '∅' }}
End day + + — derived from end —
Length @@ -299,11 +311,141 @@

The quartet — ONE form field, the group is the

The group owns the invariants: a typed end is wall-clock intent (at-or-before the start rolls to the next day — the +{{ stayGroup.endDayOffset() }} badge; overflow hours like “240:30” type the - over-count by hand); a length commit moves the end; a day commit shifts both instants. Each settled commit - writes ONE composed model and emits ONE savedModelChange — see the log. + over-count by hand); a length commit moves the end; a day commit shifts both instants. The END DAY (T5's + maximal form) moves the end WITHOUT rolling — an end before the start finally IS an error (ordering error + on the end leaves, duration underivable). Paste a full ISO datetime into Starts or Ends and it decomposes + across the group. Each settled commit writes ONE composed model and emits ONE + savedModelChange — see the log. +

+ + +
+

The quartet in mat-form-fields (T4) — same controls, mat chrome

+

+ The SAME group and the SAME mat-ignorant leaves — each hosted by <mat-form-field> via + the inlineMatFormField adapter (angular-inline-select/temporal-mat). The adapter + derives MatFormFieldControl entirely from the controls' public signals — label float from + emptiness/focus, errorState from the field's own verdict — and the controls' underline rests + via the generic bare-chrome classes. Sessions, snap-back, the calendar panel, overflow hours and the + +{{ matStayGroup.endDayOffset() }} badge all work unchanged inside the mat box. +

+ +
+ + Stay + + + + + Starts + + + + + Ends + + + + + Length + + whole minutes + +
+ +

+ Model: + {{ matStayModel()?.start ?? '∅' }} → + {{ matStayModel()?.end ?? '∅' }} · + {{ matStayModel()?.duration ?? '∅' }}s

+
+

Timezones (T6) — one instant, three walls

+

+ Values NEVER carry the zone — they stay UTC ISO DB entries. The DISPLAY ZONE is configuration: a + zone input per field, or app-wide via provideInlineTemporalZone (the + ServerSideDatetimeConfiguration analogue). All three fields below bind THE SAME instant; + editing any wall re-composes the instant in THAT zone. +

+ + + + + + + + + + + + + + + + + + + + + + + + +
WallDisplayModel (UTC, SQL-friendly)
Machine zone + + {{ zonedInstant() ?? '∅' }}
New York + +
Tokyo day + +
+
+ @if (emittedEvents().length > 0) {
diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss index 44784f5..6662791 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss @@ -31,3 +31,16 @@ word-break: break-all; } } + +// The T4 card: four labeled mat boxes in one row, wrapping on narrow screens. +.mat-quartet { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: baseline; + + mat-form-field { + flex: 1 1 10rem; + min-width: 9rem; + } +} diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index ec2261f..202baab 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -10,6 +10,7 @@ import { FormField, form, required } from '@angular/forms/signals'; // Material import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; // Components import { @@ -18,6 +19,7 @@ import { AngularInlineTime, DateTimeRangeGroup, RangeDay, + RangeEndDay, RangeStart, RangeEnd, RangeLength, @@ -28,6 +30,7 @@ import { type TemporalRangeValue, type IsoDateRange, } from 'angular-inline-select/temporal'; +import { InlineMatFormField } from 'angular-inline-select/temporal-mat'; @Component({ selector: 'app-temporal-playground', @@ -37,6 +40,8 @@ import { imports: [ // Material MatButtonModule, + MatFormFieldModule, + InlineMatFormField, // Forms FormField, @@ -47,6 +52,7 @@ import { AngularInlineTime, DateTimeRangeGroup, RangeDay, + RangeEndDay, RangeStart, RangeEnd, RangeLength, @@ -118,6 +124,27 @@ export class TemporalPlayground { protected stayForm = form(this.stayModel); + // --------------------------------------------------------------------------- + // The quartet in MAT-FORM-FIELDS (T4): same group, same unbound leaves — + // each hosted by via the temporal-mat adapter. The + // controls stay mat-ignorant; the adapter derives MatFormFieldControl + // from their public signals. + // --------------------------------------------------------------------------- + protected matStayModel = signal({ + start: composeDbEntry('2026-07-21', '21:00'), + end: composeDbEntry('2026-07-22', '06:00'), + duration: 32_400, + }); + + protected matStayForm = form(this.matStayModel); + + // --------------------------------------------------------------------------- + // T6 — the display zone is CONFIGURATION, the value is not: one UTC + // instant, three walls. `zone` per field here; app-wide via + // `provideInlineTemporalZone` (iusta's ServerSideDatetimeConfiguration). + // --------------------------------------------------------------------------- + protected zonedInstant = signal(composeDbEntry('2026-07-21', '21:00')); + /** The page's locale toggle, pinned to 24 h — military time survives `en`. */ protected militaryLocale = computed(() => `${this.dateLocale()}-u-hc-h23`); diff --git a/tsconfig.json b/tsconfig.json index d925807..c939a04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,10 @@ "paths": { "angular-inline-select": ["./projects/angular-inline-select/src/public-api.ts"], "angular-inline-select/phone": ["./projects/angular-inline-select/phone/src/public-api.ts"], - "angular-inline-select/temporal": ["./projects/angular-inline-select/temporal/src/public-api.ts"] + "angular-inline-select/temporal": ["./projects/angular-inline-select/temporal/src/public-api.ts"], + "angular-inline-select/temporal-mat": [ + "./projects/angular-inline-select/temporal-mat/src/public-api.ts" + ] }, "resolveJsonModule": true, "strict": true, From 87e0ffc1fc474ca15aa4faae861890c4a20739f6 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 17:44:55 +0200 Subject: [PATCH 26/48] feat(TimePicker): native now is opt in --- .../angular-inline-time.html | 18 ++----- .../angular-inline-time.spec.ts | 32 +++++++++++ .../angular-inline-time.ts | 53 ++++++++++--------- .../temporal-playground.html | 13 ++++- .../temporal-playground.ts | 3 ++ 5 files changed, 79 insertions(+), 40 deletions(-) diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index b188dd2..e0f9046 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -1,8 +1,9 @@ - } @else if (showNativePicker()) { - } @@ -192,6 +193,7 @@ describe('AngularInlineTime with a display zone (T6) + native bounds (T3)', () = class TimeFormHost { model = signal(at('09:30')); field = form(this.model); + native = signal(false); saved: (string | null)[] = []; sessions: InlineTimeSaved[] = []; @@ -310,6 +312,36 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.saved).toEqual([]); }); + it('there is no trigger button — native mode is the one picker affordance', () => { + expect(h.fixture.nativeElement.querySelector('button')).toBeNull(); + + h.host.native.set(true); + h.fixture.detectChanges(); + + expect(h.fixture.nativeElement.querySelector('button')).toBeNull(); + }); + + it('native mode: a click on the field opens the OS picker, seeded with the value', () => { + h.host.native.set(true); + h.fixture.detectChanges(); + + const shown: string[] = []; + (h.native() as HTMLInputElement & { showPicker: () => void }).showPicker = function ( + this: HTMLInputElement, + ) { + shown.push(this.value); + }; + + h.input().click(); + expect(shown).toEqual(['09:30']); + + // Off, the field's click stays a plain caret placement. + h.host.native.set(false); + h.fixture.detectChanges(); + h.input().click(); + expect(shown).toEqual(['09:30']); + }); + it('an OS-picker change while idle commits immediately', () => { const native = h.native(); native.value = '14:45'; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index 011cd5a..a255a1d 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -71,11 +71,12 @@ export interface InlineTimeSaved { * - Drafts are TYPED (`'9'` → 09:00, `'930'`, `'21:05'`) with a live * interpretation preview; overflow hours declare the day over-count by * hand (`'24:30'` → next day 00:30, previewed `✓ 00:30 +1 day`). - * - **The picker is the OS's own**: a 🕐 suffix drives a visually-hidden - * `` — `showPicker()` where the platform supports it, - * falling back to focusing the input (mobile opens its wheels on focus). - * While a session is open, a pick replaces the draft; idle, it commits - * immediately (the flag-picker convention). + * - **The picker is the OS's own, opt-in via `native`**: the field's own + * click drives a visually-hidden `` — `showPicker()` + * where the platform supports it, falling back to focusing the input + * (mobile opens its wheels on focus). There is NO trigger button; typing + * is the primary road everywhere. While a session is open, a pick + * replaces the draft; idle, it commits immediately. */ @Component({ selector: 'angular-inline-time', @@ -166,12 +167,13 @@ export interface InlineTimeSaved { /* The +n over-count perches on the input's TOP-RIGHT corner (the airline-ticket look) — absolutely positioned, so it costs no line - space and nothing in the row can crowd or obscure it. + space and nothing in the row can crowd or obscure it. The inline-end + overhang has the room it wants: no adornment follows the field. */ .time-day-badge { position: absolute; top: -0.8em; - right: -0.5em; + inset-inline-end: -1.1em; z-index: 1; padding: 0 0.35em; border-radius: var(--mat-sys-corner-small, 0.5rem); @@ -185,20 +187,6 @@ export interface InlineTimeSaved { user-select: none; } - .inline-time__trigger { - font: inherit; - line-height: 1; - padding: 0; - border: 0; - background: transparent; - cursor: pointer; - border-radius: var(--mat-sys-corner-extra-small, 0.25rem); - } - .inline-time__trigger:focus-visible { - outline: 2px solid var(--mat-sys-primary, #4285f4); - outline-offset: 2px; - } - /* Focusable but invisible — display:none would break focus + showPicker anchoring */ .inline-time__native { position: absolute; @@ -304,8 +292,14 @@ export class AngularInlineTime implements FormValueControl { pickerMin = input(undefined); pickerMax = input(undefined); - /** The 🕐 OS-picker affordance. Off, or overridden by suffix content. */ - showNativePicker = input(true); + /** + * NATIVE mode — the one picker affordance: a click on the input opens the + * OS time picker (the date control's calendar-on-edit convention). Typing + * stays fully available; the picker is an assist, never the only road. + * T3's support matrix: `showPicker()` feature-detected, focus fallback + * (mobile opens its wheels on focus). + */ + native = input(false); /** Affix template passthrough (composition channel + content sugar). */ prefixTemplate = input | undefined>(undefined); @@ -726,10 +720,17 @@ export class AngularInlineTime implements FormValueControl { * gesture or in cross-origin iframes — both roads fall back to focusing * the input (iOS opens its wheels on focus). */ - protected openNativePicker(event: Event) { - event.preventDefault(); - event.stopPropagation(); + /** + * Native mode: the input's own click is the picker affordance. The click + * has already focused the field (the session is open), so a pick lands as + * a draft replacement — the calendar-on-edit convention. + */ + protected handleFieldClick() { + if (!this.native() || this.effectiveDisabled() || this.effectiveReadonly()) return; + this.#showOsPicker(); + } + #showOsPicker() { const native = this.nativeInput().nativeElement; native.value = this.localTime() ?? ''; diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 7d5b02e..41b1195 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -71,7 +71,7 @@

Date — what the user sees vs the model

Time — what the user sees vs the model

One [formField]. Type “930”, “9”, “21:05” — or overflow hours (“24:30” reads as next-day - 00:30); the 🕐 opens the platform’s own picker. The local wall-clock display hides a FULL UTC instant that + 00:30); in native mode a click on the field opens the platform’s own picker. The local wall-clock display hides a FULL UTC instant that carries its own day.

@@ -90,6 +90,7 @@

Time — what the user sees vs the model

@@ -98,6 +99,12 @@

Time — what the user sees vs the model

+ +
+ +
@@ -212,6 +219,7 @@

Time range — two time leaves, {{ '{' }} start, @@ -223,6 +231,7 @@

Time range — two time leaves, {{ '{' }} start, @@ -269,6 +278,7 @@

The quartet — ONE form field, the group is the @@ -280,6 +290,7 @@

The quartet — ONE form field, the group is the diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index 202baab..882a141 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -83,6 +83,9 @@ export class TemporalPlayground { }); protected timeForm = form(this.timeModel); + /** Native mode: the field itself opens the OS picker — no 🕐 suffix. */ + protected nativeTimePicker = signal(false); + // --------------------------------------------------------------------------- // Duration — form-driven: the model is seconds // --------------------------------------------------------------------------- From f27619fab969637e614d3ae7e5446bb57af8c7e7 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 17:56:41 +0200 Subject: [PATCH 27/48] feat(Calendar): adjust origin for cdkoverlay if used in mat form field --- .../src/mat-form-field-adapter.spec.ts | 57 ++++++++++++++++++- .../src/mat-form-field-adapter.ts | 14 ++++- .../angular-inline-date.html | 2 +- .../angular-inline-date.ts | 13 +++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts index f1ee75d..722cf43 100644 --- a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormField, form, required } from '@angular/forms/signals'; import { MatFormFieldControl, MatFormFieldModule } from '@angular/material/form-field'; -import { AngularInlineTime } from 'angular-inline-select/temporal'; +import { AngularInlineDate, AngularInlineTime } from 'angular-inline-select/temporal'; import { composeDbEntry } from 'angular-inline-select/temporal'; import { InlineMatFormField } from './mat-form-field-adapter'; @@ -151,3 +151,58 @@ describe('InlineMatFormField (the temporal-mat adapter)', () => { expect(h.input().getAttribute('aria-describedby')).toContain('mat-mdc-hint'); }); }); + +@Component({ + imports: [MatFormFieldModule, AngularInlineDate, InlineMatFormField, FormField], + template: ` + + Deadline + + + `, +}) +class MatDateHost { + model = signal(null); + field = form(this.model); +} + +@Component({ + imports: [AngularInlineDate, FormField], + template: ``, +}) +class BareDateHost { + model = signal(null); + field = form(this.model); +} + +describe('InlineMatFormField calendar anchoring', () => { + it('anchors the calendar to the form-field FLEX box, not the bare input wrapper', () => { + const fixture = TestBed.createComponent(MatDateHost); + fixture.detectChanges(); + + const control = fixture.debugElement + .query((el) => el.name === 'angular-inline-date')! + .componentInstance as AngularInlineDate; + const origin = control.overlayOrigin(); + // getConnectedOverlayOrigin() returns the text-field wrapper — the box + // INCLUDING the underline (line ripple), excluding the subscript row — + // the exact anchor mat-select/-datepicker use, not the text baseline. + const wrapper = fixture.nativeElement.querySelector( + '.mat-mdc-text-field-wrapper', + ) as HTMLElement; + + expect(wrapper).not.toBeNull(); + expect(origin).not.toBeNull(); + expect((origin as { nativeElement: HTMLElement }).nativeElement).toBe(wrapper); + }); + + it('leaves the origin null when the control stands alone — anchors to its own wrapper', () => { + const fixture = TestBed.createComponent(BareDateHost); + fixture.detectChanges(); + + const control = fixture.debugElement + .query((el) => el.name === 'angular-inline-date')! + .componentInstance as AngularInlineDate; + expect(control.overlayOrigin()).toBeNull(); + }); +}); diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts index 8749468..1ae0277 100644 --- a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { Subject } from 'rxjs'; import { _IdGenerator } from '@angular/cdk/a11y'; -import { MatFormFieldControl } from '@angular/material/form-field'; +import { MAT_FORM_FIELD, MatFormFieldControl } from '@angular/material/form-field'; import { AngularInlineDate, @@ -98,6 +98,14 @@ export class InlineMatFormField implements MatFormFieldControl, OnDestr } this.#control = control; + // Anchor the date control's calendar to the form field's FLEX box (what + // mat-select/-datepicker/-autocomplete use), not the bare input wrapper — + // so the panel drops below the underline instead of at the text baseline. + // The control never learns what mat is; it only receives a CDK-generic + // ElementRef through its `overlayOrigin` seam. `getConnectedOverlayOrigin` + // reads a ViewChild, so defer to afterNextRender below. + const formField = inject(MAT_FORM_FIELD, { optional: true }); + // Container CHROME must not steal focus: a mousedown on the box's // padding/label/outline would blur the input, settle the session and // close the panel — and the click's `onContainerClick` would then @@ -109,6 +117,10 @@ export class InlineMatFormField implements MatFormFieldControl, OnDestr const destroyRef = inject(DestroyRef); afterNextRender( () => { + if (formField !== null && this.#control instanceof AngularInlineDate) { + this.#control.overlayOrigin.set(formField.getConnectedOverlayOrigin()); + } + const host = this.#element.nativeElement; const container = host.closest('mat-form-field'); if (container === null) return; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 5560b31..059d02d 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -95,7 +95,7 @@ --> { /** The calendar grid affordance (📅 trigger + open-on-focus popup). */ showCalendar = input(true); + /** + * Generic overlay-anchor override — a container seam, NOT a mat one. When + * unset (the default) the panel anchors to the bare `.inline-date` wrapper. + * A host that draws its own chrome (the mat adapter passes the form field's + * flex box; a dense table cell could pass its own) hands the ElementRef/ + * element here so the calendar anchors under the WHOLE field, below the + * underline — never learning what that container is. The control stays + * mat-ignorant; the type is CDK-generic, not Material. `model` (not + * `input`) so a host directive on the same element can `.set()` it + * programmatically — the same public-writable seam as `editing`. + */ + overlayOrigin = model | HTMLElement | null>(null); + /** * Quick-pick commands rendered as chips in the panel. Defaults to * yesterday/today/tomorrow — INJECTABLE so a consumer's copy can grow From cc385fc039ba0cc3c1f35651fe5a3339bf52b288 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Tue, 7 Jul 2026 18:19:02 +0200 Subject: [PATCH 28/48] feat(Playground): added an example for temporal where mat form field holds empty editable --- .../temporal-playground.html | 29 +++++++++++++++++++ .../temporal-playground.ts | 6 ++++ 2 files changed, 35 insertions(+) diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 41b1195..6ca40b7 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -404,6 +404,35 @@

The quartet in mat-form-fields (T4) — same con

+
+

Baseline — stock Material datepickers in mat-form-fields

+

+ The reference point: Angular Material's OWN <mat-datepicker> and + <mat-date-range-picker> hosted in <mat-form-field>. One field is a range + (matStartDate/matEndDate), the other a single date — both start EMPTY and both use + floatLabel="always", so the labels sit floated above the placeholders with nothing selected. +

+ +
+ + Trip dates + + + + + + + + + + Single day + + + + +
+
+

Timezones (T6) — one instant, three walls

diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index 882a141..e72f350 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -9,8 +9,11 @@ import { import { FormField, form, required } from '@angular/forms/signals'; // Material +import { provideNativeDateAdapter } from '@angular/material/core'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; // Components import { @@ -37,10 +40,13 @@ import { InlineMatFormField } from 'angular-inline-select/temporal-mat'; templateUrl: './temporal-playground.html', styleUrl: './temporal-playground.scss', changeDetection: ChangeDetectionStrategy.Eager, + providers: [provideNativeDateAdapter()], imports: [ // Material MatButtonModule, MatFormFieldModule, + MatInputModule, + MatDatepickerModule, InlineMatFormField, // Forms From c66e23b8f5216643c8ceb9f0133c590611fd3e56 Mon Sep 17 00:00:00 2001 From: Hong Date: Tue, 7 Jul 2026 21:11:09 +0200 Subject: [PATCH 29/48] refactor(TimeDuration): no long pop drop down for time preview --- .claude/launch.json | 4 +- .../src/mat-form-field-adapter.spec.ts | 13 +++-- .../angular-inline-date.ts | 54 ++++++++++++++---- .../angular-inline-duration.html | 7 +-- .../angular-inline-duration.spec.ts | 21 +++---- .../angular-inline-duration.ts | 40 +++---------- .../angular-inline-duration/duration-codec.ts | 6 +- .../angular-inline-time.html | 6 +- .../angular-inline-time.spec.ts | 13 +---- .../angular-inline-time.ts | 56 +++---------------- .../src/range-group/range-group.spec.ts | 6 +- 11 files changed, 88 insertions(+), 138 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index ae17907..c70703e 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,8 +3,8 @@ "configurations": [ { "name": "app", - "runtimeExecutable": "/Users/hongknop/.nvm/versions/node/v24.15.0/bin/node", - "runtimeArgs": ["node_modules/.bin/ng", "serve", "app", "--port", "4300"], + "runtimeExecutable": "npx", + "runtimeArgs": ["ng", "serve", "app", "--port", "4300"], "port": 4300 } ] diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts index 722cf43..7523c6c 100644 --- a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts @@ -103,6 +103,13 @@ describe('InlineMatFormField (the temporal-mat adapter)', () => { }); it('a chrome click focuses when idle, then TOGGLES the panel — never close-and-reopen', async () => { + // The panel is error-only now (there is no live preview), so put the + // field in an error state — required, emptied, touched — to give the + // panel something to show once the session opens. + h.host.model.set(null); + h.host.field().markAsTouched(); + h.fixture.detectChanges(); + const container = h.fixture.nativeElement.querySelector('mat-form-field') as HTMLElement; const chromeClick = () => { const event = new MouseEvent('click', { bubbles: true }); @@ -111,13 +118,9 @@ describe('InlineMatFormField (the temporal-mat adapter)', () => { h.fixture.detectChanges(); }; - // Idle: the click focuses (and the session opens on focusin). + // Idle: the click focuses (the session opens on focusin, the error panel follows). chromeClick(); expect(document.activeElement).toBe(h.input()); - - // A visible panel needs something to say — type a draft. - h.input().value = '9'; - h.input().dispatchEvent(new Event('input', { bubbles: true })); h.fixture.detectChanges(); expect(document.querySelector('.inline-time__panel')).not.toBeNull(); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 15ab919..89e108a 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -3,8 +3,11 @@ import { DestroyRef, ElementRef, Injector, + + // Signals afterNextRender, computed, + contentChild, effect, inject, input, @@ -12,17 +15,25 @@ import { model, output, signal, - untracked, - viewChild, - contentChild, type Signal, type TemplateRef, type WritableSignal, + untracked, + viewChild, } from '@angular/core'; import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; -import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay'; + +// CDK +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + type ConnectedPosition, +} from '@angular/cdk/overlay'; + +// Form import { FormValueControl, type ValidationError } from '@angular/forms/signals'; +// Core import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; import { parseDateInput, @@ -520,7 +531,12 @@ export class AngularInlineDate implements FormValueControl { /** The current draft's ISO reading (`null` empty, `undefined` unreadable). */ readonly parsedDraft = computed(() => { const key = this.focusTarget() ?? 'start'; - return parseDateInput(this.#side(key).draft(), this.now()(), this.locale(), this.effectiveZone()); + return parseDateInput( + this.#side(key).draft(), + this.now()(), + this.locale(), + this.effectiveZone(), + ); }); /** The parse gate: whether the focused draft fails the codec. Public for consumers. */ @@ -573,7 +589,12 @@ export class AngularInlineDate implements FormValueControl { /** The grid's pending day: the focused side's parsed draft, else its committed day. */ protected pendingDay = computed(() => { const key = this.focusTarget() ?? 'start'; - const draft = parseDateInput(this.#side(key).draft(), this.now()(), this.locale(), this.effectiveZone()); + const draft = parseDateInput( + this.#side(key).draft(), + this.now()(), + this.locale(), + this.effectiveZone(), + ); if (typeof draft === 'string') return draft; return this.#side(key).committedDay() ?? this.internalRange().start; @@ -585,7 +606,9 @@ export class AngularInlineDate implements FormValueControl { /** Quick-pick chips: consumer-injected, else yesterday/today/tomorrow. */ protected quickPickList = computed( - () => this.quickPicks() ?? buildDateCommands(this.now()(), this.locale(), this.effectiveZone()).slice(0, 3), + () => + this.quickPicks() ?? + buildDateCommands(this.now()(), this.locale(), this.effectiveZone()).slice(0, 3), ); protected overlayPositions: ConnectedPosition[] = [ @@ -744,7 +767,10 @@ export class AngularInlineDate implements FormValueControl { * unreadable draft resolves to the BASELINE (snap-back; a brief flash + * aria-live announce the restoration, no persistent state). */ - #settle(key: SideKey, options: { resolve?: IsoDate | null; revert?: boolean; keepOpen?: boolean } = {}) { + #settle( + key: SideKey, + options: { resolve?: IsoDate | null; revert?: boolean; keepOpen?: boolean } = {}, + ) { const side = this.#side(key); if (!side.open()) return; @@ -761,7 +787,12 @@ export class AngularInlineDate implements FormValueControl { } else if (options.resolve !== undefined) { day = options.resolve; } else { - const parsed = parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()); + const parsed = parseDateInput( + side.draft(), + this.now()(), + this.locale(), + this.effectiveZone(), + ); snappedBack = parsed === undefined; day = parsed === undefined ? side.baselineDay : parsed; } @@ -806,7 +837,10 @@ export class AngularInlineDate implements FormValueControl { case 'Enter': { event.preventDefault(); const side = this.#side(key); - if (parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()) === undefined) { + if ( + parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()) === + undefined + ) { // The parse gate: the user ASKED for a commit — block and say why. side.saveAttempted.set(true); return; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html index 3fb1707..5810ab3 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html @@ -1,7 +1,6 @@

- @if (preview(); as reading) { -
{{ reading }}
- } -
diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts index 4bfc7c5..d8c709d 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts @@ -35,9 +35,9 @@ describe('duration codec', () => { }); it('formats seconds per format and describes them for the preview', () => { - expect(formatDuration(5400, 'h:mm')).toBe('1:30'); - expect(formatDuration(3723, 'h:mm:ss')).toBe('1:02:03'); - expect(formatDuration(90, 'mm:ss')).toBe('1:30'); + expect(formatDuration(5400, 'h:mm')).toBe('01:30'); + expect(formatDuration(3723, 'h:mm:ss')).toBe('01:02:03'); + expect(formatDuration(90, 'mm:ss')).toBe('01:30'); expect(formatDuration(null)).toBe(''); expect(describeDuration(5400)).toBe('1 h 30 min'); expect(describeDuration(0)).toBe('0 s'); @@ -123,21 +123,16 @@ describe('AngularInlineDuration (input rehost)', () => { }); it('renders the committed seconds in clock format in a real input', () => { - expect(h.input().value).toBe('1:30'); + expect(h.input().value).toBe('01:30'); }); - it('Enter commits unit tokens as seconds, snapped to step, with a live preview', () => { + it('Enter commits unit tokens as seconds, snapped to step', () => { type(h, '2h 15m'); - - expect(document.querySelector('.inline-duration__preview')?.textContent?.trim()).toBe( - '✓ 2 h 15 min', - ); - press(h, 'Enter'); expect(h.host.saved).toEqual([8100]); expect(h.host.sessions).toEqual([{ value: 8100, changed: true }]); - expect(h.input().value).toBe('2:15'); // commits round-trip the codec + expect(h.input().value).toBe('02:15'); // commits round-trip the codec }); it('the parse gate blocks Enter on unreadable drafts', () => { @@ -154,7 +149,7 @@ describe('AngularInlineDuration (input rehost)', () => { await blurAway(h); expect(h.host.field().value()).toBe(5400); - expect(h.input().value).toBe('1:30'); + expect(h.input().value).toBe('01:30'); expect(h.host.saved).toEqual([]); expect(h.host.sessions).toEqual([{ value: 5400, changed: false }]); }); @@ -164,7 +159,7 @@ describe('AngularInlineDuration (input rehost)', () => { press(h, 'Escape'); expect(h.host.field().value()).toBe(5400); - expect(h.input().value).toBe('1:30'); + expect(h.input().value).toBe('01:30'); expect(h.host.saved).toEqual([]); }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index 1b16eee..5da057e 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -20,12 +20,7 @@ import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@ import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; -import { - parseDuration, - formatDuration, - describeDuration, - type DurationFormat, -} from './duration-codec'; +import { parseDuration, formatDuration, type DurationFormat } from './duration-codec'; import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; /** Payload of the `saved` output: one emission per settled edit session. */ @@ -49,9 +44,7 @@ export interface InlineDurationSaved { * - Drafts accept colon notation (positional by `durationFormat`), unit * tokens (`'1h 30m'`, `'45m'`, `'1.5h'`), or a bare number (minutes under * hour formats, seconds under `mm:ss`). - * - The live interpretation preview shows what the draft means on every - * keystroke (`✓ 1 h 30 min`) — the draft itself is never reformatted. - * - Commits round-trip the codec (`'90'` under `h:mm` settles as `'1:30'`) + * - Commits round-trip the codec (`'90'` under `h:mm` settles as `'01:30'`) * and snap to `step` seconds when set (e.g. 60 for whole minutes). */ @Component({ @@ -155,12 +148,6 @@ export interface InlineDurationSaved { 0 2px 6px 2px rgba(0, 0, 0, 0.15) ); } - .inline-duration__preview { - padding: 2px 8px; - font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); - color: var(--mat-sys-on-surface-variant, #5f6368); - font-variant-numeric: tabular-nums; - } .inline-duration__errors:not([hidden]) { padding: 0 8px 4px; font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); @@ -308,23 +295,9 @@ export class AngularInlineDuration implements FormValueControl { protected errorSlotVisible = computed(() => this.errorsVisible() || this.parseGateVisible()); - /** Live interpretation preview: `✓ 1 h 30 min` / `… raw`. */ - protected preview = computed(() => { - const raw = this.draft().trim(); - if (!raw) return ''; - - const parsed = parseDuration(raw, this.durationFormat()); - if (parsed === null || parsed === undefined) return `… ${raw}`; - - return `✓ ${describeDuration(this.#snap(parsed))}`; - }); - - /** The panel appears when there is something to say — a reading or an error. */ + /** The panel appears only to carry an error — there is no live preview. */ protected panelOpen = computed( - () => - this.#open() && - !this.#panelDismissed() && - (this.preview() !== '' || this.errorSlotVisible()), + () => this.#open() && !this.#panelDismissed() && this.errorSlotVisible(), ); /** Public: whether the panel is showing (hosting containers coordinate on it). */ @@ -514,8 +487,9 @@ export class AngularInlineDuration implements FormValueControl { } /** - * Toggles the preview panel. PUBLIC — the container-click affordance a - * hosting container (the mat-form-field adapter) delegates to. + * Toggles the error panel. PUBLIC — the container-click affordance a + * hosting container (the mat-form-field adapter) delegates to. (With no + * error to show the panel stays empty-quiet — there is no live preview.) */ togglePanel() { if (this.effectiveDisabled() || this.effectiveReadonly()) return; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts index fd40d71..d6819d1 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts @@ -67,14 +67,14 @@ export function formatDuration(seconds: number | null, format: DurationFormat = const pad = (value: number) => String(value).padStart(2, '0'); if (format === 'mm:ss') { - return `${Math.floor(seconds / 60)}:${pad(seconds % 60)}`; + return `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); - if (format === 'h:mm:ss') return `${hours}:${pad(minutes)}:${pad(seconds % 60)}`; - return `${hours}:${pad(minutes)}`; + if (format === 'h:mm:ss') return `${pad(hours)}:${pad(minutes)}:${pad(seconds % 60)}`; + return `${pad(hours)}:${pad(minutes)}`; } /** Human reading for the live interpretation preview: `'1 h 30 min'`. */ diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index e0f9046..b72aa6a 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -73,7 +73,7 @@ {{ revertNotice() }} - +
- @if (preview(); as reading) { -
{{ reading }}
- } -
diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts index 7cb7622..a84469f 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -263,9 +263,6 @@ describe('AngularInlineTime (input rehost)', () => { it('Enter commits typed drafts as DB entries anchored on the value own day', async () => { type(h, '2105'); - - expect(document.querySelector('.inline-time__preview')?.textContent?.trim()).toBe('✓ 21:05'); - press(h, 'Enter'); expect(h.host.saved).toEqual([at('21:05')]); @@ -273,7 +270,7 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.model()).toBe(at('21:05')); expect(localDayOf(h.host.model())).toBe(DAY); // the day survives the edit expect(h.input().value).toBe('21:05'); - expect(h.panel()).toBeNull(); // Enter dismisses the panel + expect(h.panel()).toBeNull(); // no panel for a clean commit — there is no preview }); it('the parse gate blocks Enter on impossible times', () => { @@ -371,13 +368,8 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.saved).toEqual([at('10:15')]); }); - it('an overflow draft commits onto the anchor day + n with a +n preview', () => { + it('an overflow draft commits onto the anchor day + n', () => { type(h, '24:30'); - - expect(document.querySelector('.inline-time__preview')?.textContent?.trim()).toBe( - '✓ 00:30 +1 day', - ); - press(h, 'Enter'); expect(h.host.model()).toBe(composeDbEntry('2026-07-22', '00:30')); @@ -389,7 +381,6 @@ describe('AngularInlineTime (input rehost)', () => { it('a pasted FULL ISO datetime is an explicit instant — its own day, no anchor', () => { type(h, '2026-07-25T08:00'); - expect(document.querySelector('.inline-time__preview')?.textContent?.trim()).toContain('✓'); // Live channel already carries the full instant. expect(h.host.field().value()).toBe(composeDbEntry('2026-07-25', '08:00')); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index a255a1d..283f408 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -28,7 +28,6 @@ import { composeDbEntry, localTimeOf, localDayOf, - parseDbEntry, parseDbEntryDraft, todayIn, type DbDateTime, @@ -68,9 +67,9 @@ export interface InlineTimeSaved { * baseline, Tab/blur commits a readable draft and SNAPS an unreadable one * back — never traps, never persists a draft error. * - * - Drafts are TYPED (`'9'` → 09:00, `'930'`, `'21:05'`) with a live - * interpretation preview; overflow hours declare the day over-count by - * hand (`'24:30'` → next day 00:30, previewed `✓ 00:30 +1 day`). + * - Drafts are TYPED (`'9'` → 09:00, `'930'`, `'21:05'`); overflow hours + * declare the day over-count by hand (`'24:30'` → next day 00:30, the + * `+1` badge perching on the field). * - **The picker is the OS's own, opt-in via `native`**: the field's own * click drives a visually-hidden `` — `showPicker()` * where the platform supports it, falling back to focusing the input @@ -222,12 +221,6 @@ export interface InlineTimeSaved { 0 2px 6px 2px rgba(0, 0, 0, 0.15) ); } - .inline-time__preview { - padding: 2px 8px; - font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); - color: var(--mat-sys-on-surface-variant, #5f6368); - font-variant-numeric: tabular-nums; - } .inline-time__errors:not([hidden]) { padding: 0 8px 4px; font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); @@ -267,7 +260,7 @@ export class AngularInlineTime implements FormValueControl { /** Accessible name for the field. */ ariaLabel = input(undefined); - /** Locale for the idle display + preview (`Intl`); browser default when omitted. */ + /** Locale for the idle display (`Intl`); browser default when omitted. */ locale = input(undefined); /** @@ -455,40 +448,9 @@ export class AngularInlineTime implements FormValueControl { protected errorSlotVisible = computed(() => this.errorsVisible() || this.parseGateVisible()); - /** Live interpretation preview: `✓ 9:30 AM`, `✓ 00:30 +1 day` / `… raw`. */ - protected preview = computed(() => { - const raw = this.draft().trim(); - if (!raw) return ''; - - // A pasted full instant reads back whole: `✓ Jul 25, 2026, 8:00 AM`. - const explicit = this.explicitDraft(); - if (explicit !== undefined) { - try { - return `✓ ${new Intl.DateTimeFormat(this.locale(), { - dateStyle: 'medium', - timeStyle: 'short', - timeZone: this.effectiveZone(), - }).format(parseDbEntry(explicit)!)}`; - } catch { - return `✓ ${explicit}`; - } - } - - const draft = this.parsedDraft(); - if (draft === null || draft === undefined) return `… ${raw}`; - - const reading = `✓ ${formatWallClock(draft.time, this.locale())}`; - if (draft.days === 0) return reading; - - return `${reading} +${draft.days} ${draft.days === 1 ? 'day' : 'days'}`; - }); - - /** The panel appears when there is something to say — a reading or an error. */ + /** The panel appears only to carry an error — there is no live preview. */ protected panelOpen = computed( - () => - this.#open() && - !this.#panelDismissed() && - (this.preview() !== '' || this.errorSlotVisible()), + () => this.#open() && !this.#panelDismissed() && this.errorSlotVisible(), ); /** Public: whether the panel is showing (hosting containers coordinate on it). */ @@ -702,9 +664,9 @@ export class AngularInlineTime implements FormValueControl { } /** - * Toggles the preview panel. PUBLIC — the container-click affordance a - * hosting container (the mat-form-field adapter) delegates to. (The - * panel stays content-gated: with nothing to say it remains empty-quiet.) + * Toggles the error panel. PUBLIC — the container-click affordance a + * hosting container (the mat-form-field adapter) delegates to. (With no + * error to show the panel stays empty-quiet — there is no live preview.) */ togglePanel() { if (this.effectiveDisabled() || this.effectiveReadonly()) return; diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts index 6877cf7..ea93627 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts @@ -443,7 +443,7 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { 'Jul 21, 2026', '21:00', '06:00', - '9:00', + '09:00', ]); }); @@ -496,7 +496,7 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { 'Aug 1, 2026', '08:00', '12:00', - '4:00', + '04:00', ]); }); @@ -506,7 +506,7 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { h.fixture.detectChanges(); // The duration leaf still DISPLAYS the derived length… - expect(h.inputs()[LENGTH].value).toBe('9:00'); + expect(h.inputs()[LENGTH].value).toBe('09:00'); await commitIntoBound(h.fixture, h.inputs, END, '23:30'); From 8015d70b0e2e1a49a59c4d44e6fd5d6bcc964715 Mon Sep 17 00:00:00 2001 From: Hong Date: Tue, 7 Jul 2026 21:22:33 +0200 Subject: [PATCH 30/48] refactor(AngularInlineDate): properly use css style url and template --- .../angular-inline-date.html | 2 +- .../angular-inline-date.scss | 167 ++++++++++++++++ .../angular-inline-date.ts | 183 ++---------------- .../calendar/calendar.html | 65 +++++++ .../calendar/calendar.scss | 82 ++++++++ .../calendar/calendar.spec.ts | 22 +++ .../calendar.ts} | 149 +------------- .../angular-inline-duration.scss | 107 ++++++++++ .../angular-inline-duration.ts | 116 +---------- .../angular-inline-time.scss | 150 ++++++++++++++ .../angular-inline-time.ts | 162 +--------------- .../temporal/src/public-api.ts | 2 +- 12 files changed, 629 insertions(+), 578 deletions(-) create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.html create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.scss create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.spec.ts rename projects/angular-inline-select/temporal/src/angular-inline-date/{inline-calendar.ts => calendar/calendar.ts} (70%) create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss create mode 100644 projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 059d02d..9b673e1 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -111,7 +111,7 @@ } @if (showCalendar()) { - { protected startInput = viewChild>('startInput'); protected endInput = viewChild>('endInput'); - protected calendar = viewChild(AngularInlineCalendar); + protected calendar = viewChild(Calendar); protected panelRef = viewChild>('panel'); /** The current draft's ISO reading (`null` empty, `undefined` unreadable). */ diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.html b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.html new file mode 100644 index 0000000..04473ee --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.html @@ -0,0 +1,65 @@ +
+ +
{{ monthLabel() }}
+ +
+ +
+
+ @for (name of weekdayNames(); track $index) { + {{ name }} + } +
+ @for (week of weeks(); track $index) { +
+ @for (cell of week; track cell.iso) { + + } +
+ } +
diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.scss b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.scss new file mode 100644 index 0000000..8605a63 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.scss @@ -0,0 +1,82 @@ +:host { + display: block; + padding: 8px; + user-select: none; +} +.cal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 4px 6px; +} +.cal__label { + font: var(--mat-sys-title-small, 500 0.875rem/1.25 system-ui); + text-transform: capitalize; +} +.cal__nav { + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 4px 8px; + border-radius: var(--mat-sys-corner-small, 0.5rem); + color: var(--mat-sys-on-surface-variant, #5f6368); +} +.cal__nav:hover { + background: var(--mat-sys-surface-container-highest, #eee); +} +.cal__weekdays, +.cal__week { + display: grid; + grid-template-columns: repeat(7, 2.1rem); +} +.cal__weekday { + text-align: center; + font: var(--mat-sys-label-small, 500 0.6875rem/1.6 system-ui); + color: var(--mat-sys-on-surface-variant, #5f6368); + padding-block: 2px; +} +.cal__day { + height: 2.1rem; + border: 0; + background: transparent; + border-radius: 50%; + cursor: pointer; + font: var(--mat-sys-body-small, 0.8125rem/1 system-ui); + color: var(--mat-sys-on-surface, #1f1f1f); +} +.cal__day:hover { + background: var(--mat-sys-surface-container-highest, #eee); +} +.cal__day[data-outside] { + color: var(--mat-sys-outline, #999); +} +.cal__day[data-today] { + outline: 1px solid var(--mat-sys-outline, #999); + outline-offset: -1px; +} +.cal__day[data-active] { + outline: 2px solid var(--mat-sys-primary, #4285f4); + outline-offset: -2px; +} +.cal__day[data-selected] { + background: var(--mat-sys-primary, #4285f4); + color: var(--mat-sys-on-primary, #fff); +} +/* Range painting: endpoints filled, days between tinted (drag preview + committed range). */ +.cal__day[data-in-range] { + background: var(--mat-sys-secondary-container, #e8f0fe); + color: var(--mat-sys-on-secondary-container, #174ea6); + border-radius: 0; +} +.cal__day[data-range-start], +.cal__day[data-range-end] { + background: var(--mat-sys-primary, #4285f4); + color: var(--mat-sys-on-primary, #fff); +} +.cal__day:focus-visible { + outline: 2px solid var(--mat-sys-primary, #4285f4); + outline-offset: 1px; +} diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.spec.ts new file mode 100644 index 0000000..54080f4 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Calendar } from './calendar'; + +describe('Calendar', () => { + let component: Calendar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Calendar], + }).compileComponents(); + + fixture = TestBed.createComponent(Calendar); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts similarity index 70% rename from projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts rename to projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts index f71743a..c8370e8 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/inline-calendar.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts @@ -15,8 +15,8 @@ import { DOCUMENT } from '@angular/common'; import { DateTime } from 'luxon'; -import { toIsoDate, formatIsoDate, type IsoDate } from './date-codec'; -import { todayIn } from '../datetime/db-entry'; +import { toIsoDate, formatIsoDate, type IsoDate } from '../date-codec'; +import { todayIn } from '../../datetime/db-entry'; interface CalendarDay { iso: IsoDate; @@ -73,144 +73,11 @@ function firstDayOfWeek(locale: string | string[] | undefined): number { * adapter stays at ITS boundary. */ @Component({ - selector: 'angular-inline-calendar', - template: ` -
- -
{{ monthLabel() }}
- -
- -
-
- @for (name of weekdayNames(); track $index) { - {{ name }} - } -
- @for (week of weeks(); track $index) { -
- @for (cell of week; track cell.iso) { - - } -
- } -
- `, - styles: ` - :host { - display: block; - padding: 8px; - user-select: none; - } - .cal__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 4px 6px; - } - .cal__label { - font: var(--mat-sys-title-small, 500 0.875rem/1.25 system-ui); - text-transform: capitalize; - } - .cal__nav { - border: 0; - background: transparent; - cursor: pointer; - font-size: 1.1rem; - line-height: 1; - padding: 4px 8px; - border-radius: var(--mat-sys-corner-small, 0.5rem); - color: var(--mat-sys-on-surface-variant, #5f6368); - } - .cal__nav:hover { background: var(--mat-sys-surface-container-highest, #eee); } - .cal__weekdays, .cal__week { - display: grid; - grid-template-columns: repeat(7, 2.1rem); - } - .cal__weekday { - text-align: center; - font: var(--mat-sys-label-small, 500 0.6875rem/1.6 system-ui); - color: var(--mat-sys-on-surface-variant, #5f6368); - padding-block: 2px; - } - .cal__day { - height: 2.1rem; - border: 0; - background: transparent; - border-radius: 50%; - cursor: pointer; - font: var(--mat-sys-body-small, 0.8125rem/1 system-ui); - color: var(--mat-sys-on-surface, #1f1f1f); - } - .cal__day:hover { background: var(--mat-sys-surface-container-highest, #eee); } - .cal__day[data-outside] { color: var(--mat-sys-outline, #999); } - .cal__day[data-today] { outline: 1px solid var(--mat-sys-outline, #999); outline-offset: -1px; } - .cal__day[data-active] { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: -2px; } - .cal__day[data-selected] { - background: var(--mat-sys-primary, #4285f4); - color: var(--mat-sys-on-primary, #fff); - } - /* Range painting: endpoints filled, days between tinted (drag preview + committed range). */ - .cal__day[data-in-range] { - background: var(--mat-sys-secondary-container, #e8f0fe); - color: var(--mat-sys-on-secondary-container, #174ea6); - border-radius: 0; - } - .cal__day[data-range-start], - .cal__day[data-range-end] { - background: var(--mat-sys-primary, #4285f4); - color: var(--mat-sys-on-primary, #fff); - } - .cal__day:focus-visible { outline: 2px solid var(--mat-sys-primary, #4285f4); outline-offset: 1px; } - `, + selector: 'temporal-calendar', + templateUrl: './calendar.html', + styleUrl: './calendar.scss', }) -export class AngularInlineCalendar { +export class Calendar { #injector = inject(Injector); #document = inject(DOCUMENT); @@ -319,7 +186,9 @@ export class AngularInlineCalendar { this.#suppressClick = true; queueMicrotask(() => (this.#suppressClick = false)); - this.dragEnded.emit(anchor <= hover ? { start: anchor, end: hover } : { start: hover, end: anchor }); + this.dragEnded.emit( + anchor <= hover ? { start: anchor, end: hover } : { start: hover, end: anchor }, + ); } #cancelDrag() { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss new file mode 100644 index 0000000..96f3042 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss @@ -0,0 +1,107 @@ +:host { + display: inline; +} + +.inline-duration { + display: inline-flex; + align-items: baseline; + gap: 0.25ch; + max-width: 100%; +} + +/* The family look, on an input (see the date control for the rationale). */ +.inline-duration__input { + font: inherit; + color: inherit; + background: transparent; + border: 0; + padding: 0 0 0.1em; + margin: 0; + outline: none; + min-width: 1ch; + max-width: 100%; + field-sizing: content; + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + border-bottom: 0.0625rem dashed + var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); +} +.inline-duration__input:focus { + border-bottom-style: solid; + border-bottom-width: 0.125rem; + padding-bottom: calc(0.1em - 0.0625rem); +} +.inline-duration__input::placeholder { + font-style: italic; + color: inherit; + opacity: var(--editable-text-placeholder-opacity, 0.3875); +} +.inline-duration__input:disabled { + cursor: default; + border-bottom-color: var(--mat-sys-outline, #999); +} + +.inline-duration--invalid .inline-duration__input { + border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); +} + +/* BARE CHROME — the hosting container draws the chrome (see the date control). */ +:host(.inline-field-bare) .inline-duration__input { + border-bottom: none; + padding-bottom: 0; +} +:host(.inline-field-bare--hide-placeholder) .inline-duration__input::placeholder { + opacity: 0; +} + +.inline-duration__input--reverted { + animation: inline-duration-revert 0.6s ease-out; +} +@keyframes inline-duration-revert { + 0% { + background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); + } + 100% { + background: transparent; + } +} + +.inline-duration__affix { + white-space: nowrap; + user-select: none; + color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); +} + +.inline-duration__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.inline-duration__panel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); + color: var(--mat-sys-on-surface, inherit); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: var( + --mat-sys-level2, + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 2px 6px 2px rgba(0, 0, 0, 0.15) + ); +} +.inline-duration__errors:not([hidden]) { + padding: 0 8px 4px; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-error, #dc3545); +} + +@media (prefers-reduced-motion: reduce) { + .inline-duration__input--reverted { + animation: none; + } +} diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index 5da057e..9cb2ffb 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -16,7 +16,11 @@ import { type TemplateRef, } from '@angular/core'; import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; -import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay'; +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + type ConnectedPosition, +} from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; @@ -51,115 +55,7 @@ export interface InlineDurationSaved { selector: 'angular-inline-duration', imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet], templateUrl: './angular-inline-duration.html', - styles: ` - :host { - display: inline; - } - - .inline-duration { - display: inline-flex; - align-items: baseline; - gap: 0.25ch; - max-width: 100%; - } - - /* The family look, on an input (see the date control for the rationale). */ - .inline-duration__input { - font: inherit; - color: inherit; - background: transparent; - border: 0; - padding: 0 0 0.1em; - margin: 0; - outline: none; - min-width: 1ch; - max-width: 100%; - field-sizing: content; - caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); - border-bottom: 0.0625rem dashed - var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); - } - .inline-duration__input:focus { - border-bottom-style: solid; - border-bottom-width: 0.125rem; - padding-bottom: calc(0.1em - 0.0625rem); - } - .inline-duration__input::placeholder { - font-style: italic; - color: inherit; - opacity: var(--editable-text-placeholder-opacity, 0.3875); - } - .inline-duration__input:disabled { - cursor: default; - border-bottom-color: var(--mat-sys-outline, #999); - } - - .inline-duration--invalid .inline-duration__input { - border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); - } - - /* BARE CHROME — the hosting container draws the chrome (see the date control). */ - :host(.inline-field-bare) .inline-duration__input { - border-bottom: none; - padding-bottom: 0; - } - :host(.inline-field-bare--hide-placeholder) .inline-duration__input::placeholder { - opacity: 0; - } - - .inline-duration__input--reverted { - animation: inline-duration-revert 0.6s ease-out; - } - @keyframes inline-duration-revert { - 0% { - background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); - } - 100% { - background: transparent; - } - } - - .inline-duration__affix { - white-space: nowrap; - user-select: none; - color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); - } - - .inline-duration__sr { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - } - - .inline-duration__panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px; - background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); - color: var(--mat-sys-on-surface, inherit); - border-radius: var(--mat-sys-corner-medium, 0.75rem); - box-shadow: var( - --mat-sys-level2, - 0 1px 2px rgba(0, 0, 0, 0.3), - 0 2px 6px 2px rgba(0, 0, 0, 0.15) - ); - } - .inline-duration__errors:not([hidden]) { - padding: 0 8px 4px; - font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); - color: var(--mat-sys-error, #dc3545); - } - - @media (prefers-reduced-motion: reduce) { - .inline-duration__input--reverted { - animation: none; - } - } - `, + styleUrl: './angular-inline-duration.scss', host: { '[style.display]': 'hidden() ? "none" : null', }, diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss new file mode 100644 index 0000000..03a931f --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss @@ -0,0 +1,150 @@ +:host { + display: inline; + position: relative; +} + +.inline-time { + display: inline-flex; + align-items: baseline; + gap: 0.25ch; + max-width: 100%; +} + +/* The family look, on an input (see the date control for the rationale). */ +.inline-time__input { + font: inherit; + color: inherit; + background: transparent; + border: 0; + padding: 0 0 0.1em; + margin: 0; + outline: none; + min-width: 1ch; + max-width: 100%; + field-sizing: content; + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + border-bottom: 0.0625rem dashed + var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); +} +.inline-time__input:focus { + border-bottom-style: solid; + border-bottom-width: 0.125rem; + padding-bottom: calc(0.1em - 0.0625rem); +} +.inline-time__input::placeholder { + font-style: italic; + color: inherit; + opacity: var(--editable-text-placeholder-opacity, 0.3875); +} +.inline-time__input:disabled { + cursor: default; + border-bottom-color: var(--mat-sys-outline, #999); +} + +.inline-time--invalid .inline-time__input { + border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); +} + +/* BARE CHROME — the hosting container draws the chrome (see the date control). */ +:host(.inline-field-bare) .inline-time__input { + border-bottom: none; + padding-bottom: 0; +} +:host(.inline-field-bare--hide-placeholder) .inline-time__input::placeholder { + opacity: 0; +} + +.inline-time__input--reverted { + animation: inline-time-revert 0.6s ease-out; +} +@keyframes inline-time-revert { + 0% { + background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); + } + 100% { + background: transparent; + } +} + +.inline-time__affix { + white-space: nowrap; + user-select: none; + color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); +} + +/* The badge's anchor: the input's own box. */ +.inline-time__field { + position: relative; + display: inline-flex; + align-items: baseline; +} + +/* + The +n over-count perches on the input's TOP-RIGHT corner (the + airline-ticket look) — absolutely positioned, so it costs no line + space and nothing in the row can crowd or obscure it. The inline-end + overhang has the room it wants: no adornment follows the field. + */ +.time-day-badge { + position: absolute; + top: -0.8em; + inset-inline-end: -1.1em; + z-index: 1; + padding: 0 0.35em; + border-radius: var(--mat-sys-corner-small, 0.5rem); + background: var(--mat-sys-tertiary-container, #e8f0fe); + color: var(--mat-sys-on-tertiary-container, #174ea6); + font-size: 0.68em; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + pointer-events: none; + user-select: none; +} + +/* Focusable but invisible — display:none would break focus + showPicker anchoring */ +.inline-time__native { + position: absolute; + inset-inline-start: 0; + inset-block-end: 0; + width: 1px; + height: 1px; + opacity: 0; + border: 0; + padding: 0; +} + +.inline-time__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.inline-time__panel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); + color: var(--mat-sys-on-surface, inherit); + border-radius: var(--mat-sys-corner-medium, 0.75rem); + box-shadow: var( + --mat-sys-level2, + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 2px 6px 2px rgba(0, 0, 0, 0.15) + ); +} +.inline-time__errors:not([hidden]) { + padding: 0 8px 4px; + font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); + color: var(--mat-sys-error, #dc3545); +} + +@media (prefers-reduced-motion: reduce) { + .inline-time__input--reverted { + animation: none; + } +} diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index 283f408..0637c1b 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -16,7 +16,11 @@ import { type TemplateRef, } from '@angular/core'; import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; -import { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay'; +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + type ConnectedPosition, +} from '@angular/cdk/overlay'; import { FormValueControl, type ValidationError } from '@angular/forms/signals'; import { EditablePrefix, EditableSuffix } from 'angular-inline-select'; @@ -81,158 +85,7 @@ export interface InlineTimeSaved { selector: 'angular-inline-time', imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet], templateUrl: './angular-inline-time.html', - styles: ` - :host { - display: inline; - position: relative; - } - - .inline-time { - display: inline-flex; - align-items: baseline; - gap: 0.25ch; - max-width: 100%; - } - - /* The family look, on an input (see the date control for the rationale). */ - .inline-time__input { - font: inherit; - color: inherit; - background: transparent; - border: 0; - padding: 0 0 0.1em; - margin: 0; - outline: none; - min-width: 1ch; - max-width: 100%; - field-sizing: content; - caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); - border-bottom: 0.0625rem dashed - var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); - } - .inline-time__input:focus { - border-bottom-style: solid; - border-bottom-width: 0.125rem; - padding-bottom: calc(0.1em - 0.0625rem); - } - .inline-time__input::placeholder { - font-style: italic; - color: inherit; - opacity: var(--editable-text-placeholder-opacity, 0.3875); - } - .inline-time__input:disabled { - cursor: default; - border-bottom-color: var(--mat-sys-outline, #999); - } - - .inline-time--invalid .inline-time__input { - border-bottom-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); - } - - /* BARE CHROME — the hosting container draws the chrome (see the date control). */ - :host(.inline-field-bare) .inline-time__input { - border-bottom: none; - padding-bottom: 0; - } - :host(.inline-field-bare--hide-placeholder) .inline-time__input::placeholder { - opacity: 0; - } - - .inline-time__input--reverted { - animation: inline-time-revert 0.6s ease-out; - } - @keyframes inline-time-revert { - 0% { - background: color-mix(in srgb, var(--mat-sys-error, #dc3545) 18%, transparent); - } - 100% { - background: transparent; - } - } - - .inline-time__affix { - white-space: nowrap; - user-select: none; - color: var(--editable-text-affix-color, var(--mat-sys-on-surface-variant, inherit)); - } - - /* The badge's anchor: the input's own box. */ - .inline-time__field { - position: relative; - display: inline-flex; - align-items: baseline; - } - - /* - The +n over-count perches on the input's TOP-RIGHT corner (the - airline-ticket look) — absolutely positioned, so it costs no line - space and nothing in the row can crowd or obscure it. The inline-end - overhang has the room it wants: no adornment follows the field. - */ - .time-day-badge { - position: absolute; - top: -0.8em; - inset-inline-end: -1.1em; - z-index: 1; - padding: 0 0.35em; - border-radius: var(--mat-sys-corner-small, 0.5rem); - background: var(--mat-sys-tertiary-container, #e8f0fe); - color: var(--mat-sys-on-tertiary-container, #174ea6); - font-size: 0.68em; - font-weight: 600; - line-height: 1.5; - white-space: nowrap; - pointer-events: none; - user-select: none; - } - - /* Focusable but invisible — display:none would break focus + showPicker anchoring */ - .inline-time__native { - position: absolute; - inset-inline-start: 0; - inset-block-end: 0; - width: 1px; - height: 1px; - opacity: 0; - border: 0; - padding: 0; - } - - .inline-time__sr { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - } - - .inline-time__panel { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px; - background: var(--editable-panel-container-color, var(--mat-sys-surface-container, #fff)); - color: var(--mat-sys-on-surface, inherit); - border-radius: var(--mat-sys-corner-medium, 0.75rem); - box-shadow: var( - --mat-sys-level2, - 0 1px 2px rgba(0, 0, 0, 0.3), - 0 2px 6px 2px rgba(0, 0, 0, 0.15) - ); - } - .inline-time__errors:not([hidden]) { - padding: 0 8px 4px; - font: var(--mat-sys-body-small, 0.8125rem/1.4 system-ui); - color: var(--mat-sys-error, #dc3545); - } - - @media (prefers-reduced-motion: reduce) { - .inline-time__input--reverted { - animation: none; - } - } - `, + styleUrl: './angular-inline-time.scss', host: { '[style.display]': 'hidden() ? "none" : null', }, @@ -396,8 +249,7 @@ export class AngularInlineTime implements FormValueControl { */ #anchorDay = linkedSignal({ source: () => - localDayOf(this.value(), this.effectiveZone()) ?? - todayIn(this.now()(), this.effectiveZone()), + localDayOf(this.value(), this.effectiveZone()) ?? todayIn(this.now()(), this.effectiveZone()), computation: (source, prev) => (this.#open() ? (prev?.value ?? source) : source), }); diff --git a/projects/angular-inline-select/temporal/src/public-api.ts b/projects/angular-inline-select/temporal/src/public-api.ts index 24e7eb9..6ddd4d6 100644 --- a/projects/angular-inline-select/temporal/src/public-api.ts +++ b/projects/angular-inline-select/temporal/src/public-api.ts @@ -10,7 +10,7 @@ export * from './datetime/zone'; export * from './leaf-state'; export * from './angular-inline-date/angular-inline-date'; export * from './angular-inline-date/date-codec'; -export * from './angular-inline-date/inline-calendar'; +export * from './angular-inline-date/calendar/calendar'; export * from './angular-inline-time/angular-inline-time'; export * from './angular-inline-time/time-codec'; export * from './angular-inline-time/day-offset'; From 1a02672a44bf97c433583c69d944c0c84f8e9b1e Mon Sep 17 00:00:00 2001 From: Hong Date: Tue, 7 Jul 2026 21:56:16 +0200 Subject: [PATCH 31/48] refactor(TimeBadge):simplyfied the look --- .../angular-inline-text.html | 2 +- .../angular-inline-text.scss | 51 +++++++++++++++++++ .../src/lib/styles/_editable.scss | 12 ++--- .../angular-inline-date.ts | 2 +- .../calendar/calendar.scss | 35 ++++++++++--- .../angular-inline-time.scss | 19 ++++--- 6 files changed, 95 insertions(+), 26 deletions(-) diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html index a3cde4d..e8f7e26 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -178,7 +178,7 @@ (mouseleave)="scheduleCloseBubble()" > -
-
+ + + + diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss index 2a565ed..aaabb51 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss @@ -1,51 +1,2 @@ -.action { - border: 0 solid; - background: transparent; - cursor: pointer; - font: var(--mat-sys-body-small, 0.875rem/calc(1.25 / 0.875) system-ui); - color: var(--mat-sys-on-surface, #1f1f1f); - - background-color: var( - --editable-text-action-background, - oklch(from var(--mat-sys-surface-container-highest, #eee) l c h / 0.75) - ); - color: var(--editable-text-action-color, var(--mat-sys-on-surface-variant, #5f6368)); - padding-inline: calc(var(--mat-sys-spacing, 0.25rem) * 2.5); - border-radius: var(--mat-sys-radius, 0.625rem); - height: calc(var(--mat-sys-spacing, 0.25rem) * 8); - - transition: scale 0.15s cubic-bezier(0.23, 1, 0.32, 1); - scale: 1; - - &:active { - scale: 0.98; - } - - &:hover, - :focus-visible { - background-color: var( - --editable-text-action-hover-background, - var(--mat-sys-surface-container-highest, #eee) - ); - - color: var(--editable-text-action-hover-color, var(--mat-sys-on-surface-variant, #5f6368)); - } -} - -.action-save { - min-width: calc(var(--mat-sys-spacing, 0.25rem) * 20); - background-color: var(--editable-text-action-save-background, var(--mat-sys-primary, #4285f4)); - color: var(--editable-text-action-save-color, var(--mat-sys-on-primary, #fff)); - &:hover, - :focus-visible { - background-color: var( - --editable-text-action-save-hover-background, - var(--mat-sys-primary, #4285f4) - ); - color: var(--editable-text-action-save-hover-color, var(--mat-sys-on-primary, #fff)); - } -} - -.action-clear { - height: calc(var(--mat-sys-spacing, 0.25rem) * 6); -} +// The panel action buttons (Save/Discard) and the bubble's projected clear +// button share the global `.editable-action*` chrome in styles/_editable.scss. diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index 17caef9..a142c60 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -13,6 +13,7 @@ import { contentChild, input, effect, + afterNextRender, afterRenderEffect, signal, untracked, @@ -29,6 +30,8 @@ import { getSelectionOffsets, setCaretOffset, replayEdit } from './caret'; import { EditablePrefix, EditableSuffix } from './editable-affix'; import { EditableHint } from './editable-hint'; import { EditableMenu, detectSlashToken, type SlashToken } from './editable-menu'; +import { BubbleMenu } from '../bubble-menu/bubble-menu'; +import { EditableClearButton } from '../bubble-menu/editable-clear'; interface ValueNormalizationDetails { value: string; @@ -110,18 +113,6 @@ function panelPositions(paddingX: number): ConnectedPosition[] { ]; } -/** - * Positions for the floating action bubble: prefers inline-end (right of the - * field, vertically centered), then falls back anticlockwise around the field. - * start/end are direction-aware, so RTL flips automatically. - */ -const BUBBLE_POSITIONS: ConnectedPosition[] = [ - { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 6 }, - { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -6 }, - { originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -6 }, - { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 6 }, -]; - /** * Inline text: a static in-flow text that elevates into a floating editor. * @@ -140,6 +131,9 @@ const BUBBLE_POSITIONS: ConnectedPosition[] = [ // CDK OverlayModule, A11yModule, + + BubbleMenu, + EditableClearButton, ], templateUrl: './angular-inline-text.html', styleUrl: './angular-inline-text.scss', @@ -148,8 +142,6 @@ const BUBBLE_POSITIONS: ConnectedPosition[] = [ '[class.editable-text--editing]': 'editing()', '[class.editable-text--invalid]': 'errorsVisible()', '[style.display]': 'hidden() ? "none" : null', - '(mouseenter)': 'openBubble()', - '(mouseleave)': 'scheduleCloseBubble()', '(focus)': 'focus()', }, }) @@ -157,7 +149,7 @@ export class AngularInlineText implements FormValueControl { /** The static in-flow text. Focusable, caret-able — but never mutated by typing. */ protected display = viewChild.required>('display'); - /** The in-flow field area: prefix + display + suffix. Anchors the action bubble. */ + /** The in-flow field area (prefix + display + suffix) — the bubble's anchor + measure box. */ protected fieldArea = viewChild.required>('fieldArea'); /** The contenteditable inside the elevated panel. Exists only while editing. */ @@ -901,47 +893,95 @@ export class AngularInlineText implements FormValueControl { }); // --------------------------------------------------------------------------- - // Floating action bubble (Notion-style, CDK overlay — never clipped) + // Clear affordance (the floating bubble lives in BubbleMenu) // --------------------------------------------------------------------------- - protected bubblePositions = BUBBLE_POSITIONS; - protected bubbleOrigin = computed(() => this.fieldArea()); - - /** Pointer intent: over the field or over the bubble itself. */ - #bubbleHover = signal(false); - #bubbleCloseTimer: ReturnType | null = null; + /** + * Whether the clear bubble may show — every term EXCEPT hover (the bubble + * owns that): never for empty/required/locked fields or while editing. + * `required()` keeps the bubble hidden — a guaranteed-doomed clear stays + * unavailable. + */ + protected bubbleMenuCanShow = computed( + () => + !this.required() && + !this.disabled() && + !this.readonly() && + !this.isEmpty() && + !this.editing(), + ); - /** The delayed close must not fire into a destroyed component. */ - #cancelBubbleTimerOnDestroy = inject(DestroyRef).onDestroy(() => { - if (this.#bubbleCloseTimer !== null) clearTimeout(this.#bubbleCloseTimer); - }); + /** + * The offset from the field box's inline-end/block-end CORNER to where the + * content actually ENDS — the last line's final glyph, vertically centred on + * that line. A multi-line field is a tall box whose inline-end sits far past + * a short final line ("…something." + surplus space); this pins the bubble + * right after the final word instead of out in the void. Single-line lands + * ≈(0, −½line) — so the anchor scheme is IDENTICAL single- or multi-line + * (no more center-vs-bottom drift), and it stays a delta on the ELEMENT + * origin so CDK re-resolves it on scroll — correct inside a scrolling table. + * + * Measured off the ONE reflow the field already allows — the commit — and on + * resize (a rewrap moves the last line), never per hover. + */ + #contentOffset = signal<{ x: number; y: number } | null>(null); + protected clearContentOffset = computed(() => this.#contentOffset()); - /** The bubble shows on hover intent — never for empty/required/locked fields or while editing. */ - protected showBubble = computed(() => { - if (this.required() || this.disabled() || this.readonly()) return false; - if (this.isEmpty() || this.editing()) return false; + /** Bumped by the ResizeObserver to re-run the measure after the next render. */ + #measureTick = signal(0); - return this.#bubbleHover(); - }); + #measureContentOffset() { + const el = this.fieldArea().nativeElement; + if (this.isEmpty()) { + this.#contentOffset.set(null); + return; + } - protected openBubble() { - if (this.#bubbleCloseTimer !== null) { - clearTimeout(this.#bubbleCloseTimer); - this.#bubbleCloseTimer = null; + // getClientRects yields one rect per line of the field's content; the last + // rect's inline-end is where the final line stops (past the suffix, if any). + const box = el.getBoundingClientRect(); + const range = el.ownerDocument.createRange(); + range.selectNodeContents(el); + const rects = range.getClientRects(); + if (rects.length === 0) { + this.#contentOffset.set(null); + return; } - this.#bubbleHover.set(true); + // Box-corner → content-end delta. Scroll-invariant: box and content shift + // together, so CDK re-applies it against the live element rect. + const last = rects[rects.length - 1]; + this.#contentOffset.set({ + x: last.right - box.right, + y: last.top + last.height / 2 - box.bottom, + }); } - /** Delayed close so the pointer can cross the gap between field and bubble. */ - protected scheduleCloseBubble() { - if (this.#bubbleCloseTimer !== null) clearTimeout(this.#bubbleCloseTimer); + // The commit is the one point the page reflows; `displayText` flips to the + // committed value then. Re-measuring in the render READ phase rides that + // settled layout instead of forcing a fresh one — and re-runs when the + // committed text or the wrap width (via the resize tick) changes. + #measureContentOffsetEffect = afterRenderEffect({ + read: () => { + this.displayText(); + this.isSingleLine(); + this.#measureTick(); + this.#measureContentOffset(); + }, + }); + + #resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => this.#measureTick.update((tick) => tick + 1)); - this.#bubbleCloseTimer = setTimeout(() => { - this.#bubbleCloseTimer = null; - this.#bubbleHover.set(false); - }, 150); - } + #observeForRemeasure = afterNextRender(() => + this.#resizeObserver?.observe(this.fieldArea().nativeElement), + ); + + #disconnectObserverOnDestroy = inject(DestroyRef).onDestroy(() => + this.#resizeObserver?.disconnect(), + ); /** * Clear is a commit *and* an interaction (mat-faithful): it always commits diff --git a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.html b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.html new file mode 100644 index 0000000..8abc9ac --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.html @@ -0,0 +1,21 @@ + + + + diff --git a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.scss b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.scss new file mode 100644 index 0000000..eaac917 --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.scss @@ -0,0 +1,12 @@ +// The host only DECLARES the overlay template — it portals into the CDK +// overlay container, so the host element itself renders nothing and must not +// disturb the field's layout (a stray flex item / inline gap). +// +// The bubble container (`.editable-bubble`) and the projected action buttons +// (`.editable-action*`) are styled globally in styles/_editable.scss — they +// render in the CDK overlay container, and the buttons come from the CONSUMER's +// template (a different encapsulation), so their chrome cannot be encapsulated +// here. +:host { + display: none; +} diff --git a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.spec.ts b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.spec.ts new file mode 100644 index 0000000..8dd4fef --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BubbleMenu } from './bubble-menu'; + +describe('BubbleMenu', () => { + let component: BubbleMenu; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BubbleMenu], + }).compileComponents(); + + fixture = TestBed.createComponent(BubbleMenu); + component = fixture.componentInstance; + // `origin` is a required input the hover effect reads on first CD. + fixture.componentRef.setInput('origin', document.createElement('div')); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts new file mode 100644 index 0000000..f73cd8c --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts @@ -0,0 +1,170 @@ +import { + Component, + DestroyRef, + ElementRef, + inject, + Renderer2, + + // Signals + computed, + effect, + input, + signal, +} from '@angular/core'; + +// CDK +import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; + +/** Which edge the bubble grows from — the sibling-aware side of a range pair. */ +export type BubbleMenuSide = 'start' | 'end'; + +/** + * Default side: grow toward inline-END, anchored to the field's BOTTOM + * (block-end) — so on a tall multi-line field the bubble lands at the END of + * the paragraph, where the eye already is, not floating at the vertical + * centre. Falls back to bottom-right (below the field, end-aligned) when there + * is no inline room. start/end are direction-aware — RTL flips for free. + * + * The inline offset is ZERO: the container TOUCHES the field so there is no + * dead zone for the pointer to cross. The visual gap is transparent + * field-facing padding on `.editable-bubble` (the hover bridge) — see + * styles/_editable.scss. + */ +const END_POSITIONS: ConnectedPosition[] = [ + { originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: 0 }, + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, +]; + +/** + * Start side (the inline-START field of a range): grow toward inline-START, + * bottom-anchored, falling back to bottom-left — the mirror of {@link END_POSITIONS}, + * so a range pair's two bubbles open outward and never collide. + */ +const START_POSITIONS: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: 0 }, + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, +]; + +/** + * Content-offset variant (end side) — used when the host feeds a measured + * `contentOffset`: the delta from the field's inline-end/block-end CORNER to + * where the content actually ends (a ragged multi-line field's last glyph, + * vertically centred on that last line). Still an ELEMENT origin, so CDK + * re-resolves it on scroll (correct inside scrolling tables); the offset just + * nudges from the corner to the content end. `offsetX`/`offsetY` are filled in + * per-instance from the measurement. + */ +function endOffsetPositions(offset: { x: number; y: number }): ConnectedPosition[] { + return [ + { + originX: 'end', + originY: 'bottom', + overlayX: 'start', + overlayY: 'center', + offsetX: offset.x, + offsetY: offset.y, + }, + // Fallback: below the field, end-aligned (rare — content-end near the edge). + { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, + ]; +} + +/** + * A Notion-style floating hover menu — a generic, action-agnostic container + * shared by every inline control (text, number, and the temporal family). It + * knows nothing about what its buttons do: the consumer PROJECTS them + * (`…buttons…`), so today's lone "clear" is just + * one possible action, not a baked-in assumption. + * + * It lives in a CDK overlay so it can never be clipped by a table cell or + * dialog, owns its own hover state machine (listeners on the origin AND the + * bubble, with a grace timer + hit-halo padding so the pointer can cross the + * gap), and handles positioning. + * + * The host decides WHEN it may appear (`canShow` — its "not required, not + * empty, not editing" verdict) and WHICH side it grows from (`side`); the + * bubble owns the hover term and the positioning. + */ +@Component({ + selector: 'bubble-menu', + imports: [OverlayModule], + templateUrl: './bubble-menu.html', + styleUrl: './bubble-menu.scss', +}) +export class BubbleMenu { + /** The element the bubble anchors to and watches for hover. */ + origin = input.required | HTMLElement>(); + + /** + * Optional measured offset — the delta from the origin box's inline-end/ + * block-end CORNER to where the content actually ends (a ragged multi-line + * field's last glyph, vertically centred on its last line). The overlay + * still anchors to the origin ELEMENT (CDK re-resolves it on scroll — correct + * inside scrolling tables); the offset just slides it from the corner to the + * content end. `null` (the default) anchors to the plain box corner. Only + * meaningful on the `'end'` side (the multi-line text case). + */ + contentOffset = input<{ x: number; y: number } | null>(null); + + /** The host's verdict on whether the bubble may show (hover is added here). */ + canShow = input(true); + + /** Which edge to grow from — `'end'` (default) or `'start'` for a range's left field. */ + side = input('end'); + + protected positions = computed(() => { + const offset = this.contentOffset(); + if (offset && this.side() === 'end') return endOffsetPositions(offset); + return this.side() === 'start' ? START_POSITIONS : END_POSITIONS; + }); + + /** The raw origin element (unwrapped from an ElementRef) — anchor AND hover target. */ + protected overlayOrigin = computed(() => { + const origin = this.origin(); + return origin instanceof ElementRef ? origin.nativeElement : origin; + }); + + /** Pointer intent: over the field or over the bubble itself. */ + #hover = signal(false); + protected visible = computed(() => this.canShow() && this.#hover()); + + #renderer = inject(Renderer2); + #closeTimer: ReturnType | null = null; + + constructor() { + // (Re)bind hover listeners whenever the origin element changes; the effect + // cleanup unlistens so a swapped origin never leaks a stale handler. + effect((onCleanup) => { + const el = this.overlayOrigin(); + const enter = this.#renderer.listen(el, 'mouseenter', () => this.open()); + const leave = this.#renderer.listen(el, 'mouseleave', () => this.scheduleClose()); + onCleanup(() => { + enter(); + leave(); + }); + }); + + // The delayed close must not fire into a destroyed component. + inject(DestroyRef).onDestroy(() => { + if (this.#closeTimer !== null) clearTimeout(this.#closeTimer); + }); + } + + protected open() { + if (this.#closeTimer !== null) { + clearTimeout(this.#closeTimer); + this.#closeTimer = null; + } + this.#hover.set(true); + } + + /** Delayed close so the pointer can cross the gap between field and bubble. */ + protected scheduleClose() { + if (this.#closeTimer !== null) clearTimeout(this.#closeTimer); + + this.#closeTimer = setTimeout(() => { + this.#closeTimer = null; + this.#hover.set(false); + }, 150); + } +} diff --git a/projects/angular-inline-select/src/lib/bubble-menu/editable-clear.ts b/projects/angular-inline-select/src/lib/bubble-menu/editable-clear.ts new file mode 100644 index 0000000..eae76cd --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/editable-clear.ts @@ -0,0 +1,61 @@ +import { Component, Directive, output } from '@angular/core'; + +/** + * The clear-button BEHAVIOR, detached from any styling — drop it on your own + * ` + * ``` + */ +@Directive({ + selector: 'button[editableClear]', + host: { + type: 'button', + '(mousedown)': 'onMousedown($event)', + '(click)': 'onClick($event)', + }, +}) +export class EditableClear { + /** Fired on click, after the focus-preserving mousedown guard. */ + clear = output(); + + onMousedown(event: Event) { + // Keep focus on the field: a blur would settle/close the edit session. + event.preventDefault(); + event.stopPropagation(); + } + + onClick(event: Event) { + this.clear.emit(event); + } +} + +/** + * The DEFAULT clear button — {@link EditableClear}'s behavior (composed as a + * host directive, so its `clear` output is exposed here) plus the shared pill + * chrome (`editable-action editable-action-clear`, global in + * styles/_editable.scss) and a "clear" label. Use it for the stock look; reach + * for the bare `[editableClear]` directive when you want your own button. + * + * ```html + * + * ``` + */ +@Component({ + selector: 'button[editableClearButton]', + hostDirectives: [{ directive: EditableClear, outputs: ['clear'] }], + host: { class: 'editable-action editable-action-clear' }, + template: 'clear', +}) +export class EditableClearButton {} diff --git a/projects/angular-inline-select/src/lib/styles/_editable.scss b/projects/angular-inline-select/src/lib/styles/_editable.scss index e6962de..e952e7d 100644 --- a/projects/angular-inline-select/src/lib/styles/_editable.scss +++ b/projects/angular-inline-select/src/lib/styles/_editable.scss @@ -192,16 +192,80 @@ // BUBBLE: floating quick actions beside the field (CDK overlay container) // ----------------------------------------------------------------------------- .editable-bubble { + // The hover BRIDGE + hit halo: the container TOUCHES the field (positional + // offset is 0) and carries an equal transparent pad on all four sides. The + // field-facing pad is the visual gap; the rest is a balanced, forgiving hit + // area around the button. All invisible — so the pointer never crosses a + // dead zone travelling from field to button (no lost-hover flicker). + --editable-bubble-pad: calc(var(--mat-sys-inner-spacing, 16px) * 0.75); + display: inline-flex; align-items: center; gap: calc(var(--mat-sys-inner-spacing, 16px) / 4); - padding: calc(var(--mat-sys-inner-spacing, 16px) / 4); + padding: var(--editable-bubble-pad); border: 0 solid; animation: editable-bubble-enter 0.15s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); } +// ----------------------------------------------------------------------------- +// ACTION BUTTONS: the shared pill buttons projected into the panel + bubble. +// Global (not component-encapsulated) so a CONSUMER-projected button — rendered +// in the overlay under the consumer's own encapsulation — still picks them up. +// ----------------------------------------------------------------------------- +.editable-action { + border: 0 solid; + background: transparent; + cursor: pointer; + font: var(--mat-sys-body-small, 0.875rem/calc(1.25 / 0.875) system-ui); + color: var(--mat-sys-on-surface, #1f1f1f); + + background-color: var( + --editable-text-action-background, + oklch(from var(--mat-sys-surface-container-highest, #eee) l c h / 0.75) + ); + color: var(--editable-text-action-color, var(--mat-sys-on-surface-variant, #5f6368)); + padding-inline: calc(var(--mat-sys-spacing, 0.25rem) * 2.5); + border-radius: var(--mat-sys-radius, 0.625rem); + height: calc(var(--mat-sys-spacing, 0.25rem) * 8); + + transition: scale 0.15s cubic-bezier(0.23, 1, 0.32, 1); + scale: 1; + + &:active { + scale: 0.98; + } + + &:hover, + :focus-visible { + background-color: var( + --editable-text-action-hover-background, + var(--mat-sys-surface-container-highest, #eee) + ); + + color: var(--editable-text-action-hover-color, var(--mat-sys-on-surface-variant, #5f6368)); + } +} + +.editable-action-save { + min-width: calc(var(--mat-sys-spacing, 0.25rem) * 20); + background-color: var(--editable-text-action-save-background, var(--mat-sys-primary, #4285f4)); + color: var(--editable-text-action-save-color, var(--mat-sys-on-primary, #fff)); + &:hover, + :focus-visible { + background-color: var( + --editable-text-action-save-hover-background, + var(--mat-sys-primary, #4285f4) + ); + color: var(--editable-text-action-save-hover-color, var(--mat-sys-on-primary, #fff)); + } +} + +.editable-action-clear { + height: calc(var(--mat-sys-spacing, 0.25rem) * 6); +} + @keyframes editable-bubble-enter { from { opacity: 0; diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts index 456e8cf..6ff5ef0 100644 --- a/projects/angular-inline-select/src/public-api.ts +++ b/projects/angular-inline-select/src/public-api.ts @@ -7,5 +7,7 @@ export * from './lib/angular-inline-text/editable-error'; export * from './lib/angular-inline-text/editable-affix'; export * from './lib/angular-inline-text/editable-hint'; export * from './lib/angular-inline-text/editable-menu'; +export * from './lib/bubble-menu/bubble-menu'; +export * from './lib/bubble-menu/editable-clear'; export * from './lib/angular-inline-text/caret'; export * from './lib/angular-inline-number/angular-inline-number'; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 9b673e1..370e568 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -7,6 +7,7 @@ class="inline-date" cdkOverlayOrigin #origin="cdkOverlayOrigin" + #dateField [class.inline-date--invalid]="errorsVisible()" > @if (prefixTpl(); as tpl) { @@ -65,6 +66,14 @@ (focusout)="handleFocusOut()" (keydown)="handleInputKeydown('end', $event)" /> + + + + + + + + } @if (consumerSuffixTpl(); as tpl) { @@ -88,6 +97,13 @@ {{ revertNotice() }} + +@if (!twoFields()) { + + + +} + + + + + { /** Accessible name for the field. */ ariaLabel = input(undefined); + /** + * Which edge the clear bubble grows from — `'end'` (default) or `'start'` + * for a range group's inline-START leaf, so the outer leaves open outward. + */ + clearBubbleSide = input('end'); + /** How colon notation reads and how committed values render. */ durationFormat = input('h:mm'); @@ -392,6 +404,38 @@ export class AngularInlineDuration implements FormValueControl { this.#panelDismissed.update((dismissed) => !dismissed); } + // -- Clear affordance (idle hover bubble) -------------------------------------- + + /** The clear bubble may show while idle and non-empty on an unlocked field. */ + protected clearCanShow = computed( + () => + !this.required() && + !this.effectiveDisabled() && + !this.effectiveReadonly() && + !this.editing() && + !this.isEmpty(), + ); + + /** + * Clears the field from the idle hover bubble — a commit AND an interaction + * (mat-faithful): writes `null`, marks the field touched, and settles once + * so a bound schema (and a range group) sees the clear. + */ + protected clearBubble() { + // Idle-only: the bubble is hidden while editing; guard anyway. + if (this.editing() || this.value() === null) return; + + this.value.set(null); + this.#baselineValue = null; + this.draft.set(this.display()); + this.#saveAttempted.set(false); + + this.#selfTouched.set(true); + this.touch.emit(); + this.savedModelChange.emit(null); + this.saved.emit({ value: null, changed: true }); + } + // -- Form Value Contract ------------------------------------------------------------------ focus(options?: FocusOptions) { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index b72aa6a..ef5435e 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -9,6 +9,7 @@ class="inline-time" cdkOverlayOrigin #origin="cdkOverlayOrigin" + #field [class.inline-time--invalid]="errorsVisible()" > @if (prefixTpl(); as tpl) { @@ -73,6 +74,11 @@ {{ revertNotice() }} + + + + + { /** Accessible name for the field. */ ariaLabel = input(undefined); + /** + * Which edge the clear bubble grows from — `'end'` (default) or `'start'` + * for a range group's inline-START leaf, so the outer leaves open outward. + */ + clearBubbleSide = input('end'); + /** Locale for the idle display (`Intl`); browser default when omitted. */ locale = input(undefined); @@ -585,6 +597,38 @@ export class AngularInlineTime implements FormValueControl { } } + // -- Clear affordance (idle hover bubble) -------------------------------------- + + /** The clear bubble may show while idle and non-empty on an unlocked field. */ + protected clearCanShow = computed( + () => + !this.required() && + !this.effectiveDisabled() && + !this.effectiveReadonly() && + !this.editing() && + !this.isEmpty(), + ); + + /** + * Clears the field from the idle hover bubble — a commit AND an interaction + * (mat-faithful): writes `null`, marks the field touched, and settles once + * so a bound schema (and a range group) sees the clear. + */ + protected clearBubble() { + // Idle-only: the bubble is hidden while editing; guard anyway. + if (this.editing() || this.value() === null) return; + + this.value.set(null); + this.#baselineValue = null; + this.draft.set(this.display()); + this.#saveAttempted.set(false); + + this.#selfTouched.set(true); + this.touch.emit(); + this.savedModelChange.emit(null); + this.saved.emit({ value: null, changed: true, dayOverflow: 0, explicitDay: false }); + } + // -- Form Value Contract ------------------------------------------------------------------ focus(options?: FocusOptions) { diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 6ca40b7..60c7b7e 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -354,6 +354,7 @@

The quartet in mat-form-fields (T4) — same con Date: Thu, 9 Jul 2026 13:45:19 +0200 Subject: [PATCH 33/48] feat(Time): added Range for editable time additionally consolidated time logic among time range and date range --- .claude/launch.json | 9 +- .../src/lib/bubble-menu/bubble-menu.ts | 18 +- .../angular-inline-date.html | 3 +- .../angular-inline-date.spec.ts | 15 + .../angular-inline-date.ts | 300 +++---- .../angular-inline-duration.html | 2 +- .../angular-inline-duration.ts | 15 +- .../angular-inline-time.html | 84 +- .../angular-inline-time.scss | 5 + .../angular-inline-time.spec.ts | 359 ++++++++- .../angular-inline-time.ts | 729 ++++++++++++------ .../src/angular-inline-time/time-codec.ts | 94 ++- .../temporal/src/datetime/db-entry.spec.ts | 76 ++ .../temporal/src/datetime/db-entry.ts | 23 + .../temporal/src/leaf-state.ts | 11 + .../temporal/src/range-group/range-group.ts | 58 +- .../temporal/src/side-session.ts | 226 ++++++ .../temporal-playground.html | 284 +++---- .../temporal-playground.scss | 21 + .../temporal-playground.ts | 113 ++- 20 files changed, 1753 insertions(+), 692 deletions(-) create mode 100644 projects/angular-inline-select/temporal/src/datetime/db-entry.spec.ts create mode 100644 projects/angular-inline-select/temporal/src/side-session.ts diff --git a/.claude/launch.json b/.claude/launch.json index c70703e..70f985c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,9 +3,12 @@ "configurations": [ { "name": "app", - "runtimeExecutable": "npx", - "runtimeArgs": ["ng", "serve", "app", "--port", "4300"], - "port": 4300 + "runtimeExecutable": "/Users/hongknop/.nvm/versions/node/v26.1.0/bin/node", + "runtimeArgs": [ + "/private/tmp/claude-501/-Users-hongknop-Documents-private-repo-angular-inline-select/3d95d03c-6aa5-4117-8eaa-15a6e648d7a6/scratchpad/static-server.mjs" + ], + "port": 4202, + "autoPort": true } ] } diff --git a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts index f73cd8c..634fa01 100644 --- a/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts @@ -19,11 +19,13 @@ import { OverlayModule, type ConnectedPosition } from '@angular/cdk/overlay'; export type BubbleMenuSide = 'start' | 'end'; /** - * Default side: grow toward inline-END, anchored to the field's BOTTOM - * (block-end) — so on a tall multi-line field the bubble lands at the END of - * the paragraph, where the eye already is, not floating at the vertical - * centre. Falls back to bottom-right (below the field, end-aligned) when there - * is no inline room. start/end are direction-aware — RTL flips for free. + * Default side: grow toward inline-END, vertically CENTRED on the field — the + * same vertical placement the text control's measured `contentOffset` resolves + * to on a single line, so every single-line field (the temporal family) sits + * on the line instead of riding high off the box's bottom corner. Multi-line + * text never lands here: it always feeds `contentOffset` (and hides the bubble + * while empty). Falls back to bottom-right (below the field, end-aligned) when + * there is no inline room. start/end are direction-aware — RTL flips for free. * * The inline offset is ZERO: the container TOUCHES the field so there is no * dead zone for the pointer to cross. The visual gap is transparent @@ -31,17 +33,17 @@ export type BubbleMenuSide = 'start' | 'end'; * styles/_editable.scss. */ const END_POSITIONS: ConnectedPosition[] = [ - { originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom', offsetX: 0 }, + { originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 0 }, { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, ]; /** * Start side (the inline-START field of a range): grow toward inline-START, - * bottom-anchored, falling back to bottom-left — the mirror of {@link END_POSITIONS}, + * centre-anchored, falling back to bottom-left — the mirror of {@link END_POSITIONS}, * so a range pair's two bubbles open outward and never collide. */ const START_POSITIONS: ConnectedPosition[] = [ - { originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom', offsetX: 0 }, + { originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: 0 }, { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 8 }, ]; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 370e568..cdde2f6 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -56,6 +56,7 @@ [attr.aria-expanded]="overlayOpen()" [attr.aria-label]="ariaLabelOf('end')" [attr.aria-invalid]="ariaInvalidOf('end') || null" + [attr.aria-required]="required() || null" [attr.size]="sizeOf('end')" [value]="endDraft()" [placeholder]="effectiveEndPlaceholder()" @@ -99,7 +100,7 @@ @if (!twoFields()) { - + } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index 71e03f6..39e9ed2 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -526,6 +526,21 @@ describe('AngularInlineDate two-field range', () => { expect(h.host.value()).toEqual({ start: null, end: null }); }); + it('a typed end BEFORE the start commits the SORTED pair (the calendar-pick law)', async () => { + h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); + h.fixture.detectChanges(); + + type(h, h.end()!, '2026-05-08'); + press(h, h.end()!, 'Enter'); + + // Days carry no overnight reading (that is the TIME control's roll) — + // backwards just sorts, exactly like an inverted calendar pick. + expect(h.host.value()).toEqual({ start: db('2026-05-08'), end: dbEnd('2026-05-12') }); + expect(h.start().value).toBe('May 8, 2026'); + expect(h.end()!.value).toBe('May 12, 2026'); + expect(h.host.sessions.at(-1)!.changed).toBe(true); + }); + it('a start edit in the one-key { start } shape moves the single-day range whole', async () => { h.host.value.set({ start: db('2026-05-12') }); h.fixture.detectChanges(); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index bf888f2..73f9058 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -1,6 +1,5 @@ import { Component, - DestroyRef, ElementRef, Injector, @@ -8,17 +7,13 @@ import { afterNextRender, computed, contentChild, - effect, inject, input, - linkedSignal, model, output, signal, type Signal, type TemplateRef, - type WritableSignal, - untracked, viewChild, } from '@angular/core'; import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; @@ -57,9 +52,20 @@ import { type DateValueShape, type InternalDateRange, } from './date-codec'; -import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; +import { INLINE_TEMPORAL_BUBBLE_SIDE, INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; +import { + makeSideSessionChrome, + makeClearBubbleVisibility, + makeShapeMemory, + makeSideCore, + sideAriaLabel, + sideSize, + wireEditingBridge, + type SideCore, + type SideKey, +} from '../side-session'; import { Calendar } from './calendar/calendar'; /** Payload of the `saved` output: one emission per settled edit session. */ @@ -70,38 +76,19 @@ export interface InlineDateSaved { changed: boolean; } -type SideKey = 'start' | 'end'; - /** - * Everything one field of the pair owns. A SESSION is a continuous stretch - * of focus on one side: it opens on focusin (capturing the baseline) and - * settles on Enter, Escape, or focus leaving the side. + * The date side: the shared session core (see `SideCore`; `committed` is + * this side's LOCAL day) plus what a DATE session must snapshot. */ -interface DateSide { - readonly key: SideKey; - /** This side's committed LOCAL day (the value boundary stays DB entries). */ - readonly committedDay: Signal; - /** Localized display of the committed day — what the input shows idle. */ - readonly display: Signal; - /** Whether a session is open on this side. */ - readonly open: WritableSignal; - /** - * The input's text: user-owned while a session is open (frozen linkedSignal - * — a value write mid-session never rewrites text under the caret), the - * committed display otherwise. - */ - readonly draft: WritableSignal; +interface DateSide extends SideCore { /** The committed day at session start — what Escape and snap-back restore. */ baselineDay: IsoDate | null; /** - * Whether the USER touched the draft since the last settlement. An - * untouched session settles WHERE THE VALUE STANDS — re-deriving it from - * the draft would undo external writes (a group re-anchoring this leaf) - * with stale session state. + * The draft's codec reading, CACHED per side (`null` empty, `undefined` + * unreadable) — the one parse per keystroke every consumer (live channel, + * grid, preview, parse gate, settlement) reads. */ - dirty: boolean; - /** Enter was pressed on an unreadable draft — reveals the parse-gate error. */ - readonly saveAttempted: WritableSignal; + readonly parsed: Signal; } /** @@ -203,11 +190,19 @@ export class AngularInlineDate implements FormValueControl { ariaLabel = input(undefined); /** - * Which edge a SINGLE field's clear bubble grows from — `'end'` (default) - * or `'start'` for a range group's inline-START leaf. Range fields ignore - * this: each side's bubble always opens outward (start→left, end→right). + * Which edge a SINGLE field's clear bubble grows from. Unset, the leaf + * ROLE decides (`INLINE_TEMPORAL_BUBBLE_SIDE` — `rangeDay`/`rangeStart` + * provide `'start'` so inline-START leaves open outward), else `'end'`. + * Range fields ignore this: each side's bubble always opens outward + * (start→left, end→right). */ - clearBubbleSide = input('end'); + clearBubbleSide = input(undefined); + + #bubbleSideDefault = inject(INLINE_TEMPORAL_BUBBLE_SIDE, { optional: true }); + + protected effectiveClearBubbleSide = computed( + () => this.clearBubbleSide() ?? this.#bubbleSideDefault ?? 'end', + ); /** Locale for display + parsing (`Intl`); browser default when omitted. */ locale = input(undefined); @@ -293,23 +288,19 @@ export class AngularInlineDate implements FormValueControl { /** Whether an edit session is open (= focus is within). Two-way bindable. */ editing = model(false); - /** - * `null` is the only shape-ambiguous value: this remembers the last shape - * a non-null value declared, so a cleared field keeps emitting the shape - * its consumer speaks. - */ - #lastShape = linkedSignal({ - source: this.value, - computation: (value, prev) => inferDateShape(value) ?? prev?.value ?? null, + #shapeMemory = makeShapeMemory({ + value: this.value, + infer: inferDateShape, + ranged: this.ranged, + singleShape: 'single', + rangeShape: 'range', }); /** The effective shape: last seen, or the `ranged` cold-start default. */ - readonly shape = computed( - () => this.#lastShape() ?? (this.ranged() ? 'range' : 'single'), - ); + readonly shape = this.#shapeMemory.shape; /** Object shapes render the start–end input pair; a string renders one field. */ - protected twoFields = computed(() => this.shape() !== 'single'); + protected twoFields = this.#shapeMemory.twoFields; /** * One canonical internal model, always: `{ start, end }` as LOCAL @@ -348,31 +339,26 @@ export class AngularInlineDate implements FormValueControl { } #makeSide(key: SideKey): DateSide { - const committedDay = computed(() => this.internalRange()[key]); - const display = computed(() => formatIsoDate(committedDay(), this.locale())); - const open = signal(false); - const draft = linkedSignal({ - source: display, - computation: (source, prev) => (open() ? (prev?.value ?? source) : source), - }); + const committed = computed(() => this.internalRange()[key]); + const display = computed(() => formatIsoDate(committed(), this.locale())); + const core = makeSideCore(key, committed, display); return { - key, - committedDay, - display, - open, - draft, + ...core, baselineDay: null, - dirty: false, - saveAttempted: signal(false), + parsed: computed(() => + parseDateInput(core.draft(), this.now()(), this.locale(), this.effectiveZone()), + ), }; } protected startDraft = computed(() => this.#startSide.draft()); protected endDraft = computed(() => this.#endSide.draft()); - /** Which side holds focus — the side the grid, preview and picks serve. */ - protected focusTarget = signal(null); + /** The shared session chrome: focus target, snap-back flash, focus timers. */ + #chrome = makeSideSessionChrome((key) => this.#inputOf(key)); + + protected focusTarget = this.#chrome.focusTarget; protected overlayOpen = signal(false); @@ -384,16 +370,12 @@ export class AngularInlineDate implements FormValueControl { protected calendar = viewChild(Calendar); protected panelRef = viewChild>('panel'); - /** The current draft's ISO reading (`null` empty, `undefined` unreadable). */ - readonly parsedDraft = computed(() => { - const key = this.focusTarget() ?? 'start'; - return parseDateInput( - this.#side(key).draft(), - this.now()(), - this.locale(), - this.effectiveZone(), - ); - }); + /** + * The current draft's ISO reading (`null` empty, `undefined` unreadable) + * — a SELECTION over the sides' cached parses, so a focus flip never + * re-parses an unchanged draft. + */ + readonly parsedDraft = computed(() => this.#side(this.focusTarget() ?? 'start').parsed()); /** The parse gate: whether the focused draft fails the codec. Public for consumers. */ readonly parseFailed = computed(() => this.parsedDraft() === undefined); @@ -432,11 +414,11 @@ export class AngularInlineDate implements FormValueControl { /** Live interpretation preview: `Tuesday, 12 May 2026` / `… raw`. */ protected preview = computed(() => { - const key = this.focusTarget() ?? 'start'; - const raw = this.#side(key).draft().trim(); + const side = this.#side(this.focusTarget() ?? 'start'); + const raw = side.draft().trim(); if (!raw) return ''; - const iso = parseDateInput(raw, this.now()(), this.locale(), this.effectiveZone()); + const iso = side.parsed(); if (iso === null || iso === undefined) return `… ${raw}`; return `${describeIsoDate(iso, this.locale())}`; @@ -444,16 +426,11 @@ export class AngularInlineDate implements FormValueControl { /** The grid's pending day: the focused side's parsed draft, else its committed day. */ protected pendingDay = computed(() => { - const key = this.focusTarget() ?? 'start'; - const draft = parseDateInput( - this.#side(key).draft(), - this.now()(), - this.locale(), - this.effectiveZone(), - ); + const side = this.#side(this.focusTarget() ?? 'start'); + const draft = side.parsed(); if (typeof draft === 'string') return draft; - return this.#side(key).committedDay() ?? this.internalRange().start; + return side.committed() ?? this.internalRange().start; }); protected selectedForGrid = computed(() => @@ -472,50 +449,33 @@ export class AngularInlineDate implements FormValueControl { { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, ]; - /** Snap-back flash target + the aria-live announcement text. */ - protected revertFlash = signal(null); - protected revertNotice = signal(''); - - #focusCheckTimer: ReturnType | null = null; - #flashTimer: ReturnType | null = null; + protected revertFlash = this.#chrome.revertFlash; + protected revertNotice = this.#chrome.revertNotice; constructor() { - inject(DestroyRef).onDestroy(() => { - if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); - if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); - }); - - // The editing bridge: external `editing.set(true)` focuses the start - // input (focusin opens the session); `set(false)` settles and blurs. - // Internal focus flow writes the model, so states already agree there. - effect(() => { - const editing = this.editing(); - untracked(() => { - const focused = this.focusTarget(); - if (editing && focused === null) { - this.#focusSide('start'); - } else if (!editing && focused !== null) { - this.#settle(focused); - this.overlayOpen.set(false); - this.focusTarget.set(null); - this.#inputOf(focused)?.blur(); - } - }); + wireEditingBridge({ + editing: this.editing, + focusTarget: this.focusTarget, + focusSide: (key) => this.#chrome.focusSide(key), + deactivate: (focused) => { + this.#settle(focused); + this.overlayOpen.set(false); + this.focusTarget.set(null); + this.#inputOf(focused)?.blur(); + }, }); } // -- Sizing (no layout shift: content-sized, placeholder-floored) ------------- protected sizeOf(key: SideKey): number { - const side = this.#side(key); const placeholder = key === 'end' ? this.effectiveEndPlaceholder() : this.effectivePlaceholder(); - return Math.max(1, (side.draft() || placeholder).length); + return sideSize(this.#side(key).draft(), placeholder); } protected ariaLabelOf(key: SideKey): string { - const base = this.ariaLabel() ?? 'Date'; - return this.twoFields() ? `${base} ${key}` : base; + return sideAriaLabel(this.ariaLabel() ?? 'Date', key, this.twoFields()); } protected ariaInvalidOf(key: SideKey): boolean { @@ -531,7 +491,7 @@ export class AngularInlineDate implements FormValueControl { // A settled-but-still-focused field (Enter, outside click) restarts its // session on the next keystroke. if (!side.open()) { - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.open.set(true); } @@ -540,7 +500,7 @@ export class AngularInlineDate implements FormValueControl { side.saveAttempted.set(false); this.overlayOpen.set(true); - const day = parseDateInput(raw, this.now()(), this.locale(), this.effectiveZone()); + const day = side.parsed(); if (day !== undefined) this.#writeSideDay(key, day); } @@ -568,7 +528,7 @@ export class AngularInlineDate implements FormValueControl { protected handleFocusIn(key: SideKey) { const side = this.#side(key); if (!side.open()) { - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.dirty = false; side.saveAttempted.set(false); side.open.set(true); @@ -585,12 +545,10 @@ export class AngularInlineDate implements FormValueControl { * settle + close), and that is only knowable a tick later. */ protected handleFocusOut() { - if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); - this.#focusCheckTimer = setTimeout(() => this.#onFocusSettled(), 0); + this.#chrome.scheduleFocusSettle(() => this.#onFocusSettled()); } #onFocusSettled() { - this.#focusCheckTimer = null; const active = this.#document.activeElement; const inStart = active !== null && active === this.startInput()?.nativeElement; const inEnd = active !== null && active === this.endInput()?.nativeElement; @@ -637,23 +595,29 @@ export class AngularInlineDate implements FormValueControl { let day: IsoDate | null; let snappedBack = false; if (untouched) { - day = side.committedDay(); + day = side.committed(); } else if (options.revert) { day = side.baselineDay; } else if (options.resolve !== undefined) { day = options.resolve; } else { - const parsed = parseDateInput( - side.draft(), - this.now()(), - this.locale(), - this.effectiveZone(), - ); + const parsed = side.parsed(); snappedBack = parsed === undefined; day = parsed === undefined ? side.baselineDay : parsed; } - if (!untouched) this.#writeSideDay(key, day); + if (!untouched) { + this.#writeSideDay(key, day); + + // A typed commit sorts like a calendar pick (iusta-style): a date + // pair never lands inverted — days carry no overnight reading (that + // is the TIME control's roll), so end-before-start is only ever + // backwards. Restorations (Escape, snap-back) stay literal. + if (!options.revert && !snappedBack) { + this.#sortIfInverted(); + day = side.committed(); + } + } const changed = !untouched && day !== side.baselineDay; side.dirty = false; @@ -667,7 +631,7 @@ export class AngularInlineDate implements FormValueControl { side.saveAttempted.set(false); } - if (snappedBack) this.#announceRevert(key, day); + if (snappedBack) this.#chrome.announceRevert(key, formatIsoDate(day, this.locale())); this.#selfTouched.set(true); this.touch.emit(); @@ -677,15 +641,6 @@ export class AngularInlineDate implements FormValueControl { this.saved.emit({ value, changed }); } - #announceRevert(key: SideKey, day: IsoDate | null) { - const restored = day === null ? 'empty' : formatIsoDate(day, this.locale()); - this.revertNotice.set(`Reverted to ${restored}`); - this.revertFlash.set(key); - - if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); - this.#flashTimer = setTimeout(() => this.revertFlash.set(null), 600); - } - // -- Keyboard ------------------------------------------------------------------- protected handleInputKeydown(key: SideKey, event: KeyboardEvent) { @@ -693,10 +648,7 @@ export class AngularInlineDate implements FormValueControl { case 'Enter': { event.preventDefault(); const side = this.#side(key); - if ( - parseDateInput(side.draft(), this.now()(), this.locale(), this.effectiveZone()) === - undefined - ) { + if (side.parsed() === undefined) { // The parse gate: the user ASKED for a commit — block and say why. side.saveAttempted.set(true); return; @@ -740,7 +692,7 @@ export class AngularInlineDate implements FormValueControl { const key = this.focusTarget() ?? 'start'; const side = this.#side(key); if (!side.open()) { - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.open.set(true); } @@ -750,14 +702,14 @@ export class AngularInlineDate implements FormValueControl { // commit it back, un-sorting the pair. this.#writeSideDay(key, day); this.#sortIfInverted(); - this.#settle(key, { resolve: side.committedDay(), keepOpen: true }); + this.#settle(key, { resolve: side.committed(), keepOpen: true }); const other: SideKey = key === 'start' ? 'end' : 'start'; - if (this.twoFields() && this.#side(other).committedDay() === null) { - this.#focusSide(other); + if (this.twoFields() && this.#side(other).committed() === null) { + this.#chrome.focusSide(other); } else { this.overlayOpen.set(false); - this.#focusSide(key); + this.#chrome.focusSide(key); } } @@ -780,7 +732,7 @@ export class AngularInlineDate implements FormValueControl { for (const key of ['start', 'end'] as const) { const side = this.#side(key); - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.draft.set(side.display()); side.dirty = false; side.saveAttempted.set(false); @@ -798,7 +750,7 @@ export class AngularInlineDate implements FormValueControl { protected commitDraggedRange(range: { start: IsoDate; end: IsoDate }) { this.#commitBothSides(range.start, range.end); this.overlayOpen.set(false); - this.#focusSide(this.focusTarget() ?? 'start'); + this.#chrome.focusSide(this.focusTarget() ?? 'start'); } /** @@ -813,12 +765,12 @@ export class AngularInlineDate implements FormValueControl { } this.#commitBothSides(day, null); - this.#focusSide('end'); + this.#chrome.focusSide('end'); } /** Escape in the grid hands control back to the focused input (session continues). */ protected escapeCalendar() { - this.#focusSide(this.focusTarget() ?? 'start'); + this.#chrome.focusSide(this.focusTarget() ?? 'start'); } /** @@ -835,7 +787,7 @@ export class AngularInlineDate implements FormValueControl { return; } - if (this.focusTarget() === null) this.#focusSide('start'); + if (this.focusTarget() === null) this.#chrome.focusSide('start'); this.overlayOpen.set(true); } @@ -866,35 +818,19 @@ export class AngularInlineDate implements FormValueControl { return (key === 'start' ? this.startInput() : this.endInput())?.nativeElement; } - #focusSide(key: SideKey) { - const element = this.#inputOf(key); - if (element) element.focus(); - else afterNextRender(() => this.#inputOf(key)?.focus(), { injector: this.#injector }); - } - // -- Clear affordance (idle hover bubble; per-side for a range) ---------------- - /** Guards every clear bubble EXCEPT hover and per-side emptiness. */ - #clearGuards = computed( - () => - !this.required() && - !this.effectiveDisabled() && - !this.effectiveReadonly() && - !this.editing(), - ); - - /** Single field: one bubble that clears the (only) value. */ - protected clearCanShowSingle = computed(() => this.#clearGuards() && !this.isEmpty()); - - /** Range: the start side's bubble — shown while the start holds a day. */ - protected clearCanShowStart = computed( - () => this.#clearGuards() && this.internalRange().start !== null, - ); + #clearVisibility = makeClearBubbleVisibility({ + required: this.required, + disabled: this.effectiveDisabled, + readonly: this.effectiveReadonly, + editing: this.editing, + range: this.internalRange, + }); - /** Range: the end side's bubble — shown while the end holds a day. */ - protected clearCanShowEnd = computed( - () => this.#clearGuards() && this.internalRange().end !== null, - ); + protected clearCanShowSingle = this.#clearVisibility.single; + protected clearCanShowStart = this.#clearVisibility.start; + protected clearCanShowEnd = this.#clearVisibility.end; /** * Clears one side from the idle hover bubble — a commit AND an interaction @@ -912,7 +848,7 @@ export class AngularInlineDate implements FormValueControl { this.#writeSideDay(key, null); for (const side of [this.#startSide, this.#endSide]) { - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.draft.set(side.display()); side.dirty = false; side.saveAttempted.set(false); @@ -944,7 +880,7 @@ export class AngularInlineDate implements FormValueControl { if (!side.open()) continue; this.#writeSideDay(key, side.baselineDay); - side.baselineDay = side.committedDay(); + side.baselineDay = side.committed(); side.draft.set(side.display()); side.saveAttempted.set(false); } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html index 99cccd1..613bdb5 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html @@ -49,7 +49,7 @@ - + diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index 1ecc1e9..e7e190a 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -31,7 +31,7 @@ import { EditableClearButton, } from 'angular-inline-select'; import { parseDuration, formatDuration, type DurationFormat } from './duration-codec'; -import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; +import { INLINE_TEMPORAL_BUBBLE_SIDE, INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; /** Payload of the `saved` output: one emission per settled edit session. */ export interface InlineDurationSaved { @@ -87,10 +87,17 @@ export class AngularInlineDuration implements FormValueControl { ariaLabel = input(undefined); /** - * Which edge the clear bubble grows from — `'end'` (default) or `'start'` - * for a range group's inline-START leaf, so the outer leaves open outward. + * Which edge the clear bubble grows from. Unset, the leaf ROLE decides + * (`INLINE_TEMPORAL_BUBBLE_SIDE` — inline-START leaves provide `'start'` + * so the outer leaves open outward), else `'end'`. */ - clearBubbleSide = input('end'); + clearBubbleSide = input(undefined); + + #bubbleSideDefault = inject(INLINE_TEMPORAL_BUBBLE_SIDE, { optional: true }); + + protected effectiveClearBubbleSide = computed( + () => this.clearBubbleSide() ?? this.#bubbleSideDefault ?? 'end', + ); /** How colon notation reads and how committed values render. */ durationFormat = input('h:mm'); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html index ef5435e..17f54e8 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -1,9 +1,11 @@ - @if (dayOffset() > 0) { + @if (!twoFields() && dayOffset() > 0) { +{{ dayOffset() }} } + @if (twoFields()) { + + + + + @if (dayOffset() > 0) { + +{{ dayOffset() }} + } + + + + + + + + + + } + @if (consumerSuffixTpl(); as tpl) { - - - - + +@if (!twoFields()) { + + + +} { press(h, 'Enter'); expect(h.host.saved).toEqual([at('21:05')]); - expect(h.host.sessions).toEqual([{ value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false }]); + expect(h.host.sessions).toEqual([ + { value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, + ]); expect(h.host.model()).toBe(at('21:05')); expect(localDayOf(h.host.model())).toBe(DAY); // the day survives the edit expect(h.input().value).toBe('21:05'); @@ -289,7 +291,9 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.field().value()).toBe(at('09:30')); expect(h.input().value).toBe('09:30'); expect(h.host.saved).toEqual([]); - expect(h.host.sessions).toEqual([{ value: at('09:30'), changed: false, dayOverflow: 0, explicitDay: false }]); + expect(h.host.sessions).toEqual([ + { value: at('09:30'), changed: false, dayOverflow: 0, explicitDay: false, side: 'start' }, + ]); }); it('blur with a readable draft COMMITS (navigation never traps)', async () => { @@ -297,7 +301,9 @@ describe('AngularInlineTime (input rehost)', () => { await blurAway(h); expect(h.host.saved).toEqual([at('21:05')]); - expect(h.host.sessions).toEqual([{ value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false }]); + expect(h.host.sessions).toEqual([ + { value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, + ]); }); it('Escape reverts to the session baseline', () => { @@ -347,7 +353,9 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.model()).toBe(at('14:45')); expect(h.host.saved).toEqual([at('14:45')]); - expect(h.host.sessions).toEqual([{ value: at('14:45'), changed: true, dayOverflow: 0, explicitDay: false }]); + expect(h.host.sessions).toEqual([ + { value: at('14:45'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, + ]); expect(h.input().value).toBe('14:45'); }); @@ -374,7 +382,13 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.model()).toBe(composeDbEntry('2026-07-22', '00:30')); expect(h.host.sessions).toEqual([ - { value: composeDbEntry('2026-07-22', '00:30'), changed: true, dayOverflow: 1, explicitDay: false }, + { + value: composeDbEntry('2026-07-22', '00:30'), + changed: true, + dayOverflow: 1, + explicitDay: false, + side: 'start', + }, ]); }); @@ -392,6 +406,7 @@ describe('AngularInlineTime (input rehost)', () => { changed: true, dayOverflow: 0, explicitDay: true, + side: 'start', }, ]); expect(localDayOf(h.host.model())).toBe('2026-07-25'); // the day CAME ALONG @@ -408,4 +423,334 @@ describe('AngularInlineTime (input rehost)', () => { expect(localTimeOf(value)).toBe('08:00'); expect(localDayOf(value)).toBe(localDayOf(new Date().toISOString())); }); + + it('an EMPTY-STRING bound value anchors like empty — the typed time is never dropped', () => { + // A raw DB default: '' is not null, but it is no instant either. + h.host.model.set(''); + h.fixture.detectChanges(); + + type(h, '9'); + press(h, 'Enter'); + + const value = h.host.model(); + expect(localTimeOf(value)).toBe('09:00'); + expect(localDayOf(value)).toBe(localDayOf(new Date().toISOString())); + }); +}); + +// ============================================================================= +// The two-field range — shape-echo, the overnight roll, per-side sessions +// ============================================================================= + +@Component({ + imports: [AngularInlineTime], + template: ` + + `, +}) +class TimeShapeHost { + // Seeded OVERNIGHT: the end instant is on the next day (the +1 badge). + value = signal({ + start: at('22:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }); + ranged = signal(false); + native = signal(false); + + saved: InlineTimeValue[] = []; + sessions: InlineTimeSaved[] = []; +} + +interface RangeHarness { + fixture: ComponentFixture; + host: TimeShapeHost; + inputs: () => HTMLInputElement[]; + start: () => HTMLInputElement; + end: () => HTMLInputElement | undefined; + badge: () => HTMLElement | null; +} + +function setupRange(): RangeHarness { + const fixture = TestBed.createComponent(TimeShapeHost); + fixture.detectChanges(); + + const inputs = () => + [...fixture.nativeElement.querySelectorAll('.inline-time__input')] as HTMLInputElement[]; + + return { + fixture, + host: fixture.componentInstance, + inputs, + start: () => inputs()[0], + end: () => inputs()[1], + badge: () => fixture.nativeElement.querySelector('.time-day-badge') as HTMLElement | null, + }; +} + +function typeInto(r: RangeHarness, input: HTMLInputElement, text: string) { + input.focus(); + r.fixture.detectChanges(); + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + r.fixture.detectChanges(); +} + +function pressOn(r: RangeHarness, input: HTMLInputElement, key: string) { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + r.fixture.detectChanges(); +} + +async function settleRange(r: RangeHarness) { + r.fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + r.fixture.detectChanges(); +} + +async function blurAwayRange(r: RangeHarness) { + (document.activeElement as HTMLElement | null)?.blur(); + await settleRange(r); +} + +describe('AngularInlineTime two-field range', () => { + let r: RangeHarness; + + beforeEach(() => { + r = setupRange(); + }); + + afterEach(async () => { + await blurAwayRange(r); + }); + + it('a string binding renders one field; object shapes render the pair with the +n badge', () => { + r.host.value.set(at('09:30')); + r.fixture.detectChanges(); + expect(r.inputs().length).toBe(1); + expect(r.badge()).toBeNull(); // standalone single: no group feed, no badge + + r.host.value.set({ start: at('22:00'), end: composeDbEntry('2026-07-22', '01:30') }); + r.fixture.detectChanges(); + expect(r.inputs().length).toBe(2); + expect(r.start().value).toBe('22:00'); + expect(r.end()!.value).toBe('01:30'); + expect(r.badge()!.textContent).toBe('+1'); // intrinsic — the pair's own days + }); + + it('null + ranged=true cold-starts as the pair and emits the range shape', () => { + r.host.value.set(null); + r.host.ranged.set(true); + r.fixture.detectChanges(); + + expect(r.inputs().length).toBe(2); + expect(r.start().placeholder).toBe('time'); // fully empty: both hint the placeholder + expect(r.end()!.placeholder).toBe('time'); + + typeInto(r, r.start(), '22'); + pressOn(r, r.start(), 'Enter'); + + const start = r.host.value(); + expect(typeof start).toBe('object'); // the range shape, not a bare string + expect((start as { start: string | null; end: string | null }).end).toBeNull(); + expect(r.end()!.placeholder).toBe('…'); // half-open: the end side hints continuation + }); + + it('a native pick lands on the side the picker was OPENED for, even after focus strayed', async () => { + r.host.native.set(true); + r.fixture.detectChanges(); + + const native = r.fixture.nativeElement.querySelector('.inline-time__native') as HTMLInputElement; + (native as HTMLInputElement & { showPicker: () => void }).showPicker = () => {}; + + // Open the picker FOR THE END (native mode: the field's own click). + r.end()!.focus(); + r.fixture.detectChanges(); + r.end()!.click(); + r.fixture.detectChanges(); + expect(native.value).toBe('01:30'); // seeded with the end's wall clock + + // Focus strays to the start before the picker's change lands. + r.start().focus(); + r.fixture.detectChanges(); + + native.value = '02:45'; + native.dispatchEvent(new Event('change', { bubbles: true })); + r.fixture.detectChanges(); + await settleRange(r); + + // The pick belongs to the END — the start must not swallow it. + const value = r.host.value() as { start: string | null; end: string | null }; + expect(value.start).toBe(at('22:00')); + expect(value.end).toBe(composeDbEntry('2026-07-22', '02:45')); + }); + + it('Tab-advance settles the departing side BEFORE the landing session baselines — Escape restores the rolled pair', async () => { + // A same-day pair, so typing a later start inverts it until the roll. + r.host.value.set({ start: at('22:00'), end: at('23:00') }); + r.fixture.detectChanges(); + + typeInto(r, r.start(), '23:30'); // live channel: {23:30, 23:00} — inverted, not yet rolled + r.end()!.focus(); // Tab lands in the end + r.fixture.detectChanges(); + + // Landing settled the start synchronously: the end rolled next-day. + const rolled = { start: at('23:30'), end: composeDbEntry('2026-07-22', '23:00') }; + expect(r.host.value()).toEqual(rolled); + expect(r.badge()!.textContent).toBe('+1'); + + pressOn(r, r.end()!, 'Escape'); + await settleRange(r); + + // The end session's baseline is the RECONCILED pair — Escape must never + // resurrect the inverted mid-session state. + expect(r.host.value()).toEqual(rolled); + expect(r.badge()!.textContent).toBe('+1'); + }); + + it('a typed end at-or-before the start ROLLS next-day on settlement (overnight law)', () => { + typeInto(r, r.end()!, '21:00'); + pressOn(r, r.end()!, 'Enter'); + + // 21:00 is before the 22:00 start → the end lands NEXT day 21:00. + expect(r.host.value()).toEqual({ + start: at('22:00'), + end: composeDbEntry('2026-07-22', '21:00'), + }); + expect(r.badge()!.textContent).toBe('+1'); + expect(r.host.sessions.at(-1)!.changed).toBe(true); + }); + + it('a typed end after the start stays same-day and drops the badge', () => { + typeInto(r, r.end()!, '23:30'); + pressOn(r, r.end()!, 'Enter'); + + expect(r.host.value()).toEqual({ start: at('22:00'), end: at('23:30') }); + expect(r.badge()).toBeNull(); + }); + + it('an overflow end draft anchors the over-count on the START day', () => { + typeInto(r, r.end()!, '25:15'); + pressOn(r, r.end()!, 'Enter'); + + expect(r.host.value()).toEqual({ + start: at('22:00'), + end: composeDbEntry('2026-07-22', '01:15'), + }); + expect(r.host.sessions.at(-1)).toEqual({ + value: { start: at('22:00'), end: composeDbEntry('2026-07-22', '01:15') }, + changed: true, + dayOverflow: 1, + explicitDay: false, + side: 'end', + }); + }); + + it('a pasted FULL ISO end is EXPLICIT — taken as-is, never re-anchored or rolled', () => { + typeInto(r, r.end()!, '2026-07-20 08:00'); + pressOn(r, r.end()!, 'Enter'); + + // Before the start — the paste stands (the decomposition law); no badge. + expect(r.host.value()).toEqual({ + start: at('22:00'), + end: composeDbEntry('2026-07-20', '08:00'), + }); + expect(r.badge()).toBeNull(); + expect(r.host.sessions.at(-1)!.explicitDay).toBe(true); + }); + + it('Escape reverts the PAIR — a side session can move the partner, so the whole value restores', () => { + typeInto(r, r.end()!, '05'); + // The live channel already moved the end (no roll until settlement). + expect(r.host.value()).toEqual({ start: at('22:00'), end: at('05:00') }); + + pressOn(r, r.end()!, 'Escape'); + + expect(r.host.value()).toEqual({ + start: at('22:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }); + expect(r.end()!.value).toBe('01:30'); + }); + + it('blur with an unreadable end draft SNAPS BACK to the baseline', async () => { + typeInto(r, r.end()!, '9:99'); + await blurAwayRange(r); + + expect(r.host.value()).toEqual({ + start: at('22:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }); + expect(r.end()!.value).toBe('01:30'); + expect(r.host.saved).toEqual([]); + }); + + it('Tab-advance: focus moving start → end settles the start; the pair keeps rolling', async () => { + typeInto(r, r.start(), '23:00'); + r.end()!.focus(); // what Tab does + await settleRange(r); + + // The start settled at 23:00; the end (next-day 01:30) still follows it. + expect(r.host.value()).toEqual({ + start: at('23:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }); + expect(r.host.sessions.length).toBe(1); + expect(r.host.sessions[0]!.changed).toBe(true); + }); + + it('a start settling PAST the end rolls the end forward (the pair stays ordered)', () => { + // End sits at next-day 01:30; move the start past it. + r.host.value.set({ start: at('22:00'), end: at('23:00') }); + r.fixture.detectChanges(); + + typeInto(r, r.start(), '23:30'); + pressOn(r, r.start(), 'Enter'); + + expect(r.host.value()).toEqual({ + start: at('23:30'), + end: composeDbEntry('2026-07-22', '23:00'), + }); + expect(r.badge()!.textContent).toBe('+1'); + }); + + it('each side owns its clear — the other side is NEVER nuked', () => { + typeInto(r, r.end()!, ''); + pressOn(r, r.end()!, 'Enter'); + expect(r.host.value()).toEqual({ start: at('22:00'), end: null }); + + typeInto(r, r.start(), ''); + pressOn(r, r.start(), 'Enter'); + expect(r.host.value()).toEqual({ start: null, end: null }); + }); + + it('the one-key { start } shape grows the end key only on an END edit', () => { + r.host.value.set({ start: at('22:00') }); + r.fixture.detectChanges(); + expect(r.inputs().length).toBe(2); // start-only IS a (half-open) range + + typeInto(r, r.start(), '21:00'); + pressOn(r, r.start(), 'Enter'); + expect(r.host.value()).toEqual({ start: at('21:00') }); // still one-key + + typeInto(r, r.end()!, '23:30'); + pressOn(r, r.end()!, 'Enter'); + expect(r.host.value()).toEqual({ start: at('21:00'), end: at('23:30') }); + }); + + it('null remembers the last seen shape: a cleared one-key field stays one-key', () => { + r.host.value.set({ start: at('22:00') }); + r.fixture.detectChanges(); + + typeInto(r, r.start(), ''); + pressOn(r, r.start(), 'Enter'); + + expect(r.host.value()).toEqual({ start: null }); + expect(r.inputs().length).toBe(2); + }); }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index 9409a48..d6774f1 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -1,18 +1,15 @@ import { Component, - DestroyRef, ElementRef, computed, contentChild, - effect, inject, input, - linkedSignal, model, output, signal, - untracked, viewChild, + type Signal, type TemplateRef, } from '@angular/core'; import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; @@ -30,15 +27,40 @@ import { EditableClearButton, type BubbleMenuSide, } from 'angular-inline-select'; -import { parseTime, parseTimeDraft, formatWallClock, type TimeDraft } from './time-codec'; +import { + parseTime, + parseTimeDraft, + formatWallClock, + inferTimeShape, + toInternalTimeRange, + echoTimeShape, + timeValuesEqual, + type InlineTimeValue, + type TimeDraft, + type TimeValueShape, + type InternalTimeRange, +} from './time-codec'; import { INLINE_TIME_DAY_OFFSET } from './day-offset'; -import { INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; +import { INLINE_TEMPORAL_BUBBLE_SIDE, INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; +import { + makeSideSessionChrome, + makeClearBubbleVisibility, + makeShapeMemory, + makeSideCore, + sideAriaLabel, + sideSize, + wireEditingBridge, + type SideCore, + type SideKey, +} from '../side-session'; import { addLocalDays, composeDbEntry, - localTimeOf, + localDayDiff, localDayOf, + localTimeOf, parseDbEntryDraft, + rollDbEntryForward, todayIn, type DbDateTime, } from '../datetime/db-entry'; @@ -46,8 +68,8 @@ import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; /** Payload of the `saved` output: one emission per settled edit session. */ export interface InlineTimeSaved { - /** The value the session settled on — a UTC ISO DB entry, or `null` for empty. */ - value: DbDateTime | null; + /** The value the session settled on, in the consumer's bound shape. */ + value: InlineTimeValue; /** Whether the settled value differs from the session baseline. */ changed: boolean; /** @@ -62,15 +84,60 @@ export interface InlineTimeSaved { * never re-anchor it onto the start's day. */ explicitDay: boolean; + /** + * WHICH side's session settled — always `'start'` in single mode. A + * range group's `rangeTimes` role dispatches on it (the pair replaces + * two single leaves, but propagation stays per-endpoint). + */ + side: SideKey; +} + +export type { SideKey }; + +/** + * The time side: the shared session core (see `SideCore`) plus what a TIME + * session must snapshot. A session opens on focusin — capturing the + * baseline and FREEZING the anchor day — and settles on Enter, Escape, or + * focus leaving. + */ +interface TimeSide extends SideCore { + /** + * The WHOLE value at session start — Escape and snap-back restore it. + * Whole on purpose: a side's settlement can move the PARTNER too (the + * overnight roll), so a per-side baseline could not undo a session. + */ + baselineValue: InlineTimeValue; + /** + * The day a typed wall-clock composes onto, FROZEN at session start — + * live writes move the instant's own day (overflow hours), and a + * drifting anchor would double-apply them. + */ + anchorDay: string; + /** + * The draft's codec reading, CACHED per side (`null` empty, `undefined` + * unreadable) — the one parse per keystroke every consumer (live channel, + * parse gate, settlement) reads. + */ + readonly parsed: Signal; + /** A pasted FULL ISO datetime (the decomposition gesture), cached per side. */ + readonly explicit: Signal; } /** - * Inline time on a NATIVE INPUT — the input rehost (see ROADMAP-DATETIME). - * A `FormValueControl` for times. Canonical value: a **UTC ISO DB entry** - * (`'2026-07-21T19:00:00.000Z'` — iusta's `toDBEntry`), `null` for empty; - * the DISPLAY is the local wall-clock reading, localized via `Intl`. The - * value carries its own date: typed `'HH:mm'` drafts set the local - * time-of-day on the value's existing day (or `now`'s day when empty). + * Inline time on NATIVE INPUTS — the input rehost (see ROADMAP-DATETIME). + * A `FormValueControl` for times and time RANGES. Canonical value: **UTC + * ISO DB entries** (`'2026-07-21T19:00:00.000Z'` — iusta's `toDBEntry`), + * SHAPE-ECHOED like the date control — a string binds ONE field, an object + * binds the start–end pair (`{ start }` is a HALF-OPEN range). The DISPLAY + * is the local wall-clock reading, localized via `Intl`. Each value carries + * its own date: typed `'HH:mm'` drafts set the local time-of-day on a + * frozen ANCHOR day (the value's existing day, or `now`'s when empty). + * + * Ranged, the pair keeps the range-group's house rules INSIDE the control: + * a typed END is wall-clock intent — it anchors on the START's day and an + * end at-or-before the start rolls forward by whole days on settlement + * (overnight lands as `+1`, worn by the badge on the end field). A pasted + * FULL ISO datetime is explicit and never re-anchored or rolled. * * Session semantics are GESTURE-TIERED (the family rule): Enter commits * (an unreadable draft BLOCKS with the error), Escape reverts to the @@ -78,9 +145,8 @@ export interface InlineTimeSaved { * back — never traps, never persists a draft error. * * - Drafts are TYPED (`'9'` → 09:00, `'930'`, `'21:05'`); overflow hours - * declare the day over-count by hand (`'24:30'` → next day 00:30, the - * `+1` badge perching on the field). - * - **The picker is the OS's own, opt-in via `native`**: the field's own + * declare the day over-count by hand (`'24:30'` → next day 00:30). + * - **The picker is the OS's own, opt-in via `native`**: a side's own * click drives a visually-hidden `` — `showPicker()` * where the platform supports it, falling back to focusing the input * (mobile opens its wheels on focus). There is NO trigger button; typing @@ -89,18 +155,34 @@ export interface InlineTimeSaved { */ @Component({ selector: 'angular-inline-time', - imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet, BubbleMenu, EditableClearButton], + imports: [ + CdkConnectedOverlay, + CdkOverlayOrigin, + NgTemplateOutlet, + BubbleMenu, + EditableClearButton, + ], templateUrl: './angular-inline-time.html', styleUrl: './angular-inline-time.scss', host: { '[style.display]': 'hidden() ? "none" : null', }, }) -export class AngularInlineTime implements FormValueControl { +export class AngularInlineTime implements FormValueControl { #document = inject(DOCUMENT); - /** The committed value channel: a UTC ISO DB entry, or `null`. */ - value = model(null); + /** + * The committed value channel — polymorphic UTC ISO DB entries: a single + * string binds a single time, `{ start, end? }` binds a range, and the + * control ECHOES whichever shape it received. + */ + value = model(null); + + /** + * Cold-start shape default: which shape a `null`-bound field emits before + * any non-null value has declared one. Ignored once a shape has been seen. + */ + ranged = input(false); /** Reference clock — anchors the day of a time typed into an EMPTY field. */ now = input<() => Date>(() => new Date()); @@ -115,15 +197,36 @@ export class AngularInlineTime implements FormValueControl { hidden = input(false); placeholder = input('time'); + /** + * End-field placeholder override. Unset, a FULLY EMPTY range shows the + * placeholder on both sides; once a start exists the end side switches + * to the half-open display (`'21:00 – …'`). + */ + endPlaceholder = input(undefined); - /** Accessible name for the field. */ + protected effectiveEndPlaceholder = computed(() => { + const explicit = this.endPlaceholder(); + if (explicit !== undefined) return explicit; + return this.internalRange().start === null ? this.placeholder() : '…'; + }); + + /** Accessible base name; ranged fields append " start" / " end". */ ariaLabel = input(undefined); /** - * Which edge the clear bubble grows from — `'end'` (default) or `'start'` - * for a range group's inline-START leaf, so the outer leaves open outward. + * Which edge a SINGLE field's clear bubble grows from. Unset, the leaf + * ROLE decides (`INLINE_TEMPORAL_BUBBLE_SIDE` — `rangeDay`/`rangeStart` + * provide `'start'` so inline-START leaves open outward), else `'end'`. + * Range fields ignore this: each side's bubble always opens outward + * (start→left, end→right). */ - clearBubbleSide = input('end'); + clearBubbleSide = input(undefined); + + #bubbleSideDefault = inject(INLINE_TEMPORAL_BUBBLE_SIDE, { optional: true }); + + protected effectiveClearBubbleSide = computed( + () => this.clearBubbleSide() ?? this.#bubbleSideDefault ?? 'end', + ); /** Locale for the idle display (`Intl`); browser default when omitted. */ locale = input(undefined); @@ -151,11 +254,11 @@ export class AngularInlineTime implements FormValueControl { pickerMax = input(undefined); /** - * NATIVE mode — the one picker affordance: a click on the input opens the - * OS time picker (the date control's calendar-on-edit convention). Typing - * stays fully available; the picker is an assist, never the only road. - * T3's support matrix: `showPicker()` feature-detected, focus fallback - * (mobile opens its wheels on focus). + * NATIVE mode — the one picker affordance: a click on a side's input + * opens the OS time picker for THAT side (the date control's + * calendar-on-edit convention). Typing stays fully available; the picker + * is an assist, never the only road. T3's support matrix: `showPicker()` + * feature-detected, focus fallback (mobile opens its wheels on focus). */ native = input(false); @@ -197,8 +300,21 @@ export class AngularInlineTime implements FormValueControl { () => this.invalid() || (this.#leafState?.invalid() ?? false), ); - /** Days the composed end overflows past the start's calendar day (`+1` badge). */ - readonly dayOffset = computed(() => this.#groupDayOffset?.() ?? 0); + /** + * Days the end overflows past the start's calendar day (the `+n` badge). + * Ranged, it is INTRINSIC — the local day difference of the pair's own + * instants; single, it is the group-fed offset (the leaf role's token). + */ + readonly dayOffset = computed(() => { + if (this.twoFields()) { + const { start, end } = this.internalRange(); + if (start === null || end === null) return 0; + + return Math.max(0, localDayDiff(start, end, this.effectiveZone()) ?? 0); + } + + return this.#groupDayOffset?.() ?? 0; + }); protected dayBadgeAria = computed(() => this.dayOffset() === 1 ? 'plus one day' : `plus ${this.dayOffset()} days`, @@ -207,8 +323,8 @@ export class AngularInlineTime implements FormValueControl { /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per changed settlement — a DB entry or `null`. */ - savedModelChange = output(); + /** Hard commit event: fires once per changed settlement, in the bound shape. */ + savedModelChange = output(); /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ saved = output(); @@ -216,73 +332,86 @@ export class AngularInlineTime implements FormValueControl { /** Whether an edit session is open (= focus is within). Two-way bindable. */ editing = model(false); - /** The value's DISPLAY-ZONE wall-clock reading — the user-facing side of the split. */ - readonly localTime = computed(() => localTimeOf(this.value(), this.effectiveZone())); + #shapeMemory = makeShapeMemory({ + value: this.value, + infer: inferTimeShape, + ranged: this.ranged, + singleShape: 'single', + rangeShape: 'range', + }); - protected display = computed(() => formatWallClock(this.localTime(), this.locale())); + /** The effective shape: last seen, or the `ranged` cold-start default. */ + readonly shape = this.#shapeMemory.shape; - // -- The session (one field, the date control's side pattern) ------------------ + /** Object shapes render the start–end input pair; a string renders one field. */ + protected twoFields = this.#shapeMemory.twoFields; - /** Whether a session is open on this field. */ - #open = signal(false); + /** One canonical internal model, always: per-side DB-entry instants. */ + readonly internalRange = computed(() => toInternalTimeRange(this.value())); - /** - * The input's text: user-owned while the session is open (frozen - * linkedSignal — a value write mid-session never rewrites text under the - * caret), the committed display otherwise. - */ - protected draft = linkedSignal({ - source: this.display, - computation: (source, prev) => (this.#open() ? (prev?.value ?? source) : source), - }); + /** The value boundary, outbound: per-side instants → the echoed shape. */ + #writeInstants(start: DbDateTime | null, end: DbDateTime | null) { + const echoed = echoTimeShape({ start, end }, this.shape()); + if (!timeValuesEqual(echoed, this.value())) this.value.set(echoed); + } - /** The committed VALUE at session start — what Escape and snap-back restore. */ - #baselineValue: DbDateTime | null = null; + // -- The two sides ----------------------------------------------------------- - /** - * Whether the USER touched the draft since the last settlement. An - * untouched session settles WHERE THE VALUE STANDS — re-composing it from - * the draft would undo external writes (the group re-anchoring an end - * instant onto the start's day) with a stale frozen anchor. - */ - #dirty = false; + readonly #startSide = this.#makeSide('start'); + readonly #endSide = this.#makeSide('end'); - /** Enter was pressed on an unreadable draft — reveals the parse-gate error. */ - #saveAttempted = signal(false); + #side(key: SideKey): TimeSide { + return key === 'start' ? this.#startSide : this.#endSide; + } - /** Enter/Escape hide the panel until the next keystroke or session. */ - #panelDismissed = signal(false); + #makeSide(key: SideKey): TimeSide { + const committed = computed(() => this.internalRange()[key]); + const display = computed(() => + formatWallClock(localTimeOf(committed(), this.effectiveZone()), this.locale()), + ); + const core = makeSideCore(key, committed, display); + + return { + ...core, + baselineValue: null, + anchorDay: '', + parsed: computed(() => parseTimeDraft(core.draft(), this.locale())), + explicit: computed(() => parseDbEntryDraft(core.draft(), this.effectiveZone())), + }; + } - /** - * The day anchoring a commit: the value's own local day, else `now`'s. - * FROZEN while a session is open (the linkedSignal freeze pattern) — the - * live channel writes overflow days into the value, and a drifting - * anchor would apply them twice. - */ - #anchorDay = linkedSignal({ - source: () => - localDayOf(this.value(), this.effectiveZone()) ?? todayIn(this.now()(), this.effectiveZone()), - computation: (source, prev) => (this.#open() ? (prev?.value ?? source) : source), - }); + protected startDraft = computed(() => this.#startSide.draft()); + protected endDraft = computed(() => this.#endSide.draft()); + + /** The shared session chrome: focus target, snap-back flash, focus timers. */ + #chrome = makeSideSessionChrome((key) => this.#inputOf(key)); + + protected focusTarget = this.#chrome.focusTarget; /** - * Composes a typed draft onto the anchor day — the ONE outbound path. - * Overflow hours shift the day (`'24:30'` → anchor + 1 at 00:30). + * The day anchoring a side's typed wall clock: the START's day (a typed + * END is intent relative to it; the start side is its own), then the + * partner's, then `now`'s — the same chain for both sides. Falls THROUGH + * `localDayOf`, so an unreadable bound value (an empty-string DB default) + * drops to the next anchor instead of poisoning the compose with `null`. */ - #toValue(draft: TimeDraft | null): DbDateTime | null { - if (draft === null) return null; - - const day = draft.days === 0 ? this.#anchorDay() : addLocalDays(this.#anchorDay(), draft.days); - return composeDbEntry(day, draft.time, this.effectiveZone()); + #anchorDay(): string { + const zone = this.effectiveZone(); + const { start, end } = this.internalRange(); + return localDayOf(start, zone) ?? localDayOf(end, zone) ?? todayIn(this.now()(), zone); } - /** The current draft's canonical reading (`null` empty, `undefined` unreadable). */ - readonly parsedDraft = computed(() => parseTimeDraft(this.draft(), this.locale())); + /** + * The current draft's canonical reading (`null` empty, `undefined` + * unreadable) — a SELECTION over the sides' cached parses, so a focus + * flip never re-parses an unchanged draft. + */ + readonly parsedDraft = computed(() => this.#side(this.focusTarget() ?? 'start').parsed()); /** A pasted FULL ISO datetime — the decomposition gesture carries its own day. */ - readonly explicitDraft = computed(() => parseDbEntryDraft(this.draft(), this.effectiveZone())); + readonly explicitDraft = computed(() => this.#side(this.focusTarget() ?? 'start').explicit()); - /** The parse gate: whether the current draft fails the codec. Public for consumers. */ + /** The parse gate: whether the focused draft fails the codec. Public for consumers. */ readonly parseFailed = computed( () => this.parsedDraft() === undefined && this.explicitDraft() === undefined, ); @@ -305,16 +434,26 @@ export class AngularInlineTime implements FormValueControl { () => this.isInvalid() && (this.effectiveTouched() || this.#selfTouched()), ); - /** Public: whether the field holds no value. */ - readonly isEmpty = computed(() => this.value() === null); + /** Public: whether the field holds no value at all (both sides empty). */ + readonly isEmpty = computed(() => { + const { start, end } = this.internalRange(); + return start === null && end === null; + }); - protected parseGateVisible = computed(() => this.#saveAttempted() && this.parseFailed()); + /** Enter/Escape hide the panel until the next keystroke or session. */ + #panelDismissed = signal(false); + + /** The parse-gate reveal: Enter was attempted on an unreadable draft. */ + protected parseGateVisible = computed(() => { + const key = this.focusTarget(); + return key !== null && this.#side(key).saveAttempted() && this.parseFailed(); + }); protected errorSlotVisible = computed(() => this.errorsVisible() || this.parseGateVisible()); /** The panel appears only to carry an error — there is no live preview. */ protected panelOpen = computed( - () => this.#open() && !this.#panelDismissed() && this.errorSlotVisible(), + () => this.editing() && !this.#panelDismissed() && this.errorSlotVisible(), ); /** Public: whether the panel is showing (hosting containers coordinate on it). */ @@ -330,197 +469,241 @@ export class AngularInlineTime implements FormValueControl { { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 }, ]; - protected revertFlash = signal(false); - protected revertNotice = signal(''); + protected revertFlash = this.#chrome.revertFlash; + protected revertNotice = this.#chrome.revertNotice; - protected timeInput = viewChild>('timeInput'); + protected startInput = viewChild>('startInput'); + protected endInput = viewChild>('endInput'); protected nativeInput = viewChild.required>('nativeInput'); protected panelRef = viewChild>('panel'); - #focusCheckTimer: ReturnType | null = null; - #flashTimer: ReturnType | null = null; - constructor() { - inject(DestroyRef).onDestroy(() => { - if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); - if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); + wireEditingBridge({ + editing: this.editing, + focusTarget: this.focusTarget, + focusSide: (key) => this.#chrome.focusSide(key), + deactivate: (focused) => { + this.#settle(focused); + this.focusTarget.set(null); + this.#inputOf(focused)?.blur(); + }, }); + } - // The editing bridge: external `editing.set(true)` focuses the input; - // `set(false)` settles and blurs. Internal focus flow writes the model, - // so states already agree there. - effect(() => { - const editing = this.editing(); - untracked(() => { - const open = this.#open(); - if (editing && !open) { - this.timeInput()?.nativeElement.focus(); - } else if (!editing && open) { - this.#settle(); - this.timeInput()?.nativeElement.blur(); - } - }); - }); + // -- Sizing (no layout shift: content-sized, placeholder-floored) ------------- + + protected sizeOf(key: SideKey): number { + const placeholder = key === 'end' ? this.effectiveEndPlaceholder() : this.placeholder(); + return sideSize(this.#side(key).draft(), placeholder); } - protected sizeOf(): number { - return Math.max(1, (this.draft() || this.placeholder()).length); + protected ariaLabelOf(key: SideKey): string { + return sideAriaLabel(this.ariaLabel() ?? 'Time', key, this.twoFields()); } - protected ariaInvalid(): boolean { - return this.errorsVisible() || (this.#open() && this.#saveAttempted() && this.parseFailed()); + protected ariaInvalidOf(key: SideKey): boolean { + const side = this.#side(key); + return this.errorsVisible() || (side.open() && side.saveAttempted() && this.parseFailed()); } // -- The live channel ----------------------------------------------------------- - #openSession() { - if (this.#open()) return; - this.#baselineValue = this.value(); - this.#dirty = false; - this.#saveAttempted.set(false); + #openSession(key: SideKey) { + const side = this.#side(key); + if (side.open()) return; + + side.baselineValue = this.value(); + side.anchorDay = this.#anchorDay(); + side.dirty = false; + side.saveAttempted.set(false); this.#panelDismissed.set(false); - this.#open.set(true); + side.open.set(true); } - /** Every keystroke: readable drafts flow into the model live. */ - protected handleInput(raw: string) { - this.#openSession(); - this.draft.set(raw); - this.#dirty = true; - this.#saveAttempted.set(false); - this.#panelDismissed.set(false); + /** + * A side's CURRENT draft as an instant: explicit ISO paste, else + * wall-clock on the frozen anchor. Reads the side's cached parses — call + * after `draft.set(...)`, never with a raw string of its own. + */ + #resolveDraft( + key: SideKey, + ): { instant: DbDateTime | null; days: number; explicit: boolean } | undefined { + const side = this.#side(key); + + const explicit = side.explicit(); + if (explicit !== undefined) return { instant: explicit, days: 0, explicit: true }; + + const draft = side.parsed(); + if (draft === undefined) return undefined; + if (draft === null) return { instant: null, days: 0, explicit: false }; + + const day = draft.days === 0 ? side.anchorDay : addLocalDays(side.anchorDay, draft.days); + return { + instant: composeDbEntry(day, draft.time, this.effectiveZone()), + days: draft.days, + explicit: false, + }; + } - // A pasted full ISO datetime is an EXPLICIT instant — no anchor day. - const explicit = parseDbEntryDraft(raw, this.effectiveZone()); - if (explicit !== undefined) { - if (explicit !== this.value()) this.value.set(explicit); - return; - } + /** Every keystroke: readable drafts flow into the model live (no roll — that is settlement's). */ + protected handleInput(key: SideKey, raw: string) { + this.#openSession(key); + const side = this.#side(key); + side.draft.set(raw); + side.dirty = true; + side.saveAttempted.set(false); + this.#panelDismissed.set(false); - const draft = parseTimeDraft(raw, this.locale()); - if (draft === undefined) return; + const resolved = this.#resolveDraft(key); + if (resolved === undefined) return; - const value = this.#toValue(draft); - if (value !== this.value()) this.value.set(value); + const current = this.internalRange(); + if (key === 'start') this.#writeInstants(resolved.instant, current.end); + else this.#writeInstants(current.start, resolved.instant); } // -- Focus flow ------------------------------------------------------------------- - protected handleFocusIn() { - this.#openSession(); + protected handleFocusIn(key: SideKey) { + // Tab-advance: focus landing HERE ends the partner's session. It settles + // NOW — before this session snapshots its baseline/anchor — so Escape and + // snap-back restore the reconciled (rolled) pair, never the un-rolled + // mid-session state the deferred focusout timer would still be holding. + const partner = this.#side(key === 'start' ? 'end' : 'start'); + if (partner.open()) this.#settle(partner.key); + + this.#openSession(key); + this.focusTarget.set(key); this.editing.set(true); } /** - * Focusout settles ASYNCHRONOUSLY: focus landing on the native picker - * input or the panel stays inside the session; anywhere else settles — - * commit-if-readable, snap-back if not. Never trap. + * Focusout settles ASYNCHRONOUSLY: where focus LANDS decides what happens + * (the other input = Tab-advance, the native picker or panel = same + * session, outside = settle), and that is only knowable a tick later. */ protected handleFocusOut() { - if (this.#focusCheckTimer !== null) clearTimeout(this.#focusCheckTimer); - this.#focusCheckTimer = setTimeout(() => this.#onFocusSettled(), 0); + this.#chrome.scheduleFocusSettle(() => this.#onFocusSettled()); } #onFocusSettled() { - this.#focusCheckTimer = null; const active = this.#document.activeElement; - const inField = active !== null && active === this.timeInput()?.nativeElement; + const inStart = active !== null && active === this.startInput()?.nativeElement; + const inEnd = active !== null && active === this.endInput()?.nativeElement; const inNative = active !== null && active === this.nativeInput().nativeElement; const inPanel = (active !== null && this.panelRef()?.nativeElement.contains(active)) ?? false; - if (!inField && !inNative && !inPanel) { - this.#settle(); + // A side that lost focus to anywhere outside the session settles NOW: + // commit-if-readable, snap-back if not. Never trap, never block. + if (!inNative && !inPanel) { + if (this.#startSide.open() && !inStart) this.#settle('start'); + if (this.#endSide.open() && !inEnd) this.#settle('end'); + } + + if (!inStart && !inEnd && !inNative && !inPanel) { + this.focusTarget.set(null); this.editing.set(false); + } else if (inStart) { + this.focusTarget.set('start'); + } else if (inEnd) { + this.focusTarget.set('end'); } } // -- Settlement (ONE per session — commit, snap-back, Escape, clear) -------------- - #settle(options: { revert?: boolean; keepOpen?: boolean } = {}) { - if (!this.#open()) return; + /** + * The settled side lands and the pair reconciles — the range house rule + * (`rollDbEntryForward`, shared with the range group): an end at-or-before + * the start rolls forward by whole LOCAL days (a typed end is wall-clock + * intent; overnight lands as +1, DST never drifts the reading). An + * EXPLICIT end (a pasted full instant) is taken as-is — the decomposition + * law: never re-anchor, never roll. + */ + #reconcile(key: SideKey, instant: DbDateTime | null, explicit: boolean) { + const current = this.internalRange(); + const start = key === 'start' ? instant : current.start; + let end = key === 'end' ? instant : current.end; + + const skipRoll = explicit && key === 'end'; + if (this.twoFields() && !skipRoll && start !== null && end !== null) { + end = rollDbEntryForward(start, end, this.effectiveZone()); + } + + this.#writeInstants(start, end); + } + + #settle(key: SideKey, options: { revert?: boolean; keepOpen?: boolean } = {}) { + const side = this.#side(key); + if (!side.open()) return; - // An untouched session settles where the value stands (see #dirty). - const untouched = !options.revert && !this.#dirty; + // An untouched session settles where the value stands (see TimeSide.dirty). + const untouched = !options.revert && !side.dirty; - let value: DbDateTime | null; let dayOverflow = 0; let explicitDay = false; let snappedBack = false; if (untouched) { - value = this.value(); + // Nothing to derive — the value stands. } else if (options.revert) { - value = this.#baselineValue; + if (!timeValuesEqual(side.baselineValue, this.value())) this.value.set(side.baselineValue); } else { - const explicit = parseDbEntryDraft(this.draft(), this.effectiveZone()); - if (explicit !== undefined) { - // The decomposition gesture: the instant carries its own day. - value = explicit; - explicitDay = true; + const resolved = this.#resolveDraft(key); + if (resolved === undefined) { + // Snap-back: an unreadable draft reverts to the session baseline. + snappedBack = true; + if (!timeValuesEqual(side.baselineValue, this.value())) this.value.set(side.baselineValue); } else { - const draft = parseTimeDraft(this.draft(), this.locale()); - if (draft === undefined) { - // Snap-back: an unreadable draft reverts to the session baseline. - snappedBack = true; - value = this.#baselineValue; - } else { - value = this.#toValue(draft); - dayOverflow = draft?.days ?? 0; - } + dayOverflow = resolved.days; + explicitDay = resolved.explicit; + this.#reconcile(key, resolved.instant, resolved.explicit); } } - if (!untouched && value !== this.value()) this.value.set(value); - const changed = !untouched && value !== this.#baselineValue; - this.#dirty = false; + const changed = !untouched && !timeValuesEqual(this.value(), side.baselineValue); + side.dirty = false; if (options.keepOpen) { - this.#baselineValue = value; - this.draft.set(this.display()); - this.#saveAttempted.set(false); + side.baselineValue = this.value(); + side.anchorDay = this.#anchorDay(); + side.draft.set(side.display()); + side.saveAttempted.set(false); } else { - this.#open.set(false); - this.#saveAttempted.set(false); + side.open.set(false); + side.saveAttempted.set(false); } - if (snappedBack) this.#announceRevert(value); + if (snappedBack) this.#chrome.announceRevert(key, this.#side(key).display()); this.#selfTouched.set(true); this.touch.emit(); + const value = this.value(); if (changed) this.savedModelChange.emit(value); - this.saved.emit({ value, changed, dayOverflow, explicitDay }); - } - - #announceRevert(value: DbDateTime | null) { - const restored = value === null ? 'empty' : formatWallClock(localTimeOf(value), this.locale()); - this.revertNotice.set(`Reverted to ${restored}`); - this.revertFlash.set(true); - - if (this.#flashTimer !== null) clearTimeout(this.#flashTimer); - this.#flashTimer = setTimeout(() => this.revertFlash.set(false), 600); + this.saved.emit({ value, changed, dayOverflow, explicitDay, side: key }); } // -- Keyboard ----------------------------------------------------------------------- - protected handleKeydown(event: KeyboardEvent) { + protected handleKeydown(key: SideKey, event: KeyboardEvent) { switch (event.key) { case 'Enter': { event.preventDefault(); if (this.parseFailed()) { // The parse gate: the user ASKED for a commit — block and say why. - this.#saveAttempted.set(true); + this.#side(key).saveAttempted.set(true); return; } - this.#settle({ keepOpen: true }); + this.#settle(key, { keepOpen: true }); this.#panelDismissed.set(true); return; } case 'Escape': { event.preventDefault(); event.stopPropagation(); - this.#settle({ revert: true, keepOpen: true }); + this.#settle(key, { revert: true, keepOpen: true }); this.#panelDismissed.set(true); return; } @@ -540,25 +723,34 @@ export class AngularInlineTime implements FormValueControl { // -- The OS picker --------------------------------------------------------------------- /** - * Opens the OS time picker. T3's support matrix: `showPicker()` where the - * platform ships it (Chrome/Edge/Android; feature-DETECTED — Safari - * desktop lacks the method entirely) and may still throw without a user - * gesture or in cross-origin iframes — both roads fall back to focusing - * the input (iOS opens its wheels on focus). - */ - /** - * Native mode: the input's own click is the picker affordance. The click - * has already focused the field (the session is open), so a pick lands as - * a draft replacement — the calendar-on-edit convention. + * Native mode: a side's own click is the picker affordance. The click has + * already focused that side (its session is open), so a pick lands as a + * draft replacement — the calendar-on-edit convention. */ - protected handleFieldClick() { + protected handleFieldClick(key: SideKey) { if (!this.native() || this.effectiveDisabled() || this.effectiveReadonly()) return; - this.#showOsPicker(); + this.#showOsPicker(key); } - #showOsPicker() { + /** + * The side the shared native input is currently serving — recorded at + * open time, because the pick's `change` event may fire AFTER focus has + * already strayed to the other side (`focusTarget` is live, the picker + * is not). + */ + #pickerSide: SideKey | null = null; + + /** + * Opens the OS time picker seeded with a side's committed wall clock. + * T3's support matrix: `showPicker()` where the platform ships it + * (feature-DETECTED — Safari desktop lacks the method entirely) and may + * still throw without a user gesture — both roads fall back to focusing + * the input (iOS opens its wheels on focus). + */ + #showOsPicker(key: SideKey) { + this.#pickerSide = key; const native = this.nativeInput().nativeElement; - native.value = this.localTime() ?? ''; + native.value = localTimeOf(this.#side(key).committed(), this.effectiveZone()) ?? ''; if (typeof native.showPicker !== 'function') { native.focus(); @@ -573,66 +765,101 @@ export class AngularInlineTime implements FormValueControl { } /** - * A pick from the OS picker: replaces the draft while a session is open, - * commits immediately while idle (the flag-picker convention). + * A pick from the OS picker: replaces the focused side's draft while a + * session is open, commits immediately while idle (the flag-picker + * convention). */ protected handleNativePick(raw: string) { const time = parseTime(raw); if (time === undefined) return; - if (this.#open()) { - this.draft.set(raw); - this.#dirty = true; + // The pick belongs to the side the picker was OPENED for — focus may + // have strayed since (the change fires on close/scrub, not on gesture). + const key = this.#pickerSide ?? this.focusTarget() ?? 'start'; + const side = this.#side(key); + + if (side.open()) { + side.draft.set(raw); + side.dirty = true; this.#panelDismissed.set(false); - const value = time === null ? null : this.#toValue({ time, days: 0 }); - if (value !== this.value()) this.value.set(value); + + const resolved = this.#resolveDraft(key); + if (resolved !== undefined) { + const current = this.internalRange(); + if (key === 'start') this.#writeInstants(resolved.instant, current.end); + else this.#writeInstants(current.start, resolved.instant); + } return; } - const value = time === null ? null : this.#toValue({ time, days: 0 }); - if (value !== this.value()) { - this.value.set(value); + // Idle: one whole commit — anchor like an idle session would. + const instant = + time === null ? null : composeDbEntry(this.#anchorDay(), time, this.effectiveZone()); + const before = this.value(); + this.#reconcile(key, instant, false); + if (!timeValuesEqual(this.value(), before)) { + const value = this.value(); this.savedModelChange.emit(value); - this.saved.emit({ value, changed: true, dayOverflow: 0, explicitDay: false }); + this.saved.emit({ value, changed: true, dayOverflow: 0, explicitDay: false, side: key }); } } - // -- Clear affordance (idle hover bubble) -------------------------------------- + // -- Clear affordance (idle hover bubble; per-side for a range) -------------- - /** The clear bubble may show while idle and non-empty on an unlocked field. */ - protected clearCanShow = computed( - () => - !this.required() && - !this.effectiveDisabled() && - !this.effectiveReadonly() && - !this.editing() && - !this.isEmpty(), - ); + #clearVisibility = makeClearBubbleVisibility({ + required: this.required, + disabled: this.effectiveDisabled, + readonly: this.effectiveReadonly, + editing: this.editing, + range: this.internalRange, + }); + + protected clearCanShowSingle = this.#clearVisibility.single; + protected clearCanShowStart = this.#clearVisibility.start; + protected clearCanShowEnd = this.#clearVisibility.end; /** - * Clears the field from the idle hover bubble — a commit AND an interaction - * (mat-faithful): writes `null`, marks the field touched, and settles once - * so a bound schema (and a range group) sees the clear. + * Clears one side from the idle hover bubble — a commit AND an interaction + * (mat-faithful): writes `null` into that side (the OTHER side is never + * nuked, shape-echoed — half-open ranges are real states), re-baselines + * both sides so a later focus can't re-commit a stale draft, marks the + * field touched, and settles once. In the single shape `key` is `'start'` + * and the whole value clears. */ - protected clearBubble() { - // Idle-only: the bubble is hidden while editing; guard anyway. - if (this.editing() || this.value() === null) return; - - this.value.set(null); - this.#baselineValue = null; - this.draft.set(this.display()); - this.#saveAttempted.set(false); + protected clearBubble(key: SideKey) { + // Idle-only: the bubble is hidden while editing; guard anyway so a stray + // clear can't strand a frozen draft mid-session. + if (this.editing()) return; + + const before = this.value(); + const current = this.internalRange(); + if (key === 'start') this.#writeInstants(null, current.end); + else this.#writeInstants(current.start, null); + + for (const side of [this.#startSide, this.#endSide]) { + side.baselineValue = this.value(); + side.draft.set(side.display()); + side.dirty = false; + side.saveAttempted.set(false); + } this.#selfTouched.set(true); this.touch.emit(); - this.savedModelChange.emit(null); - this.saved.emit({ value: null, changed: true, dayOverflow: 0, explicitDay: false }); + + const value = this.value(); + const changed = !timeValuesEqual(value, before); + if (changed) this.savedModelChange.emit(value); + this.saved.emit({ value, changed, dayOverflow: 0, explicitDay: false, side: key }); + } + + #inputOf(key: SideKey): HTMLInputElement | undefined { + return (key === 'start' ? this.startInput() : this.endInput())?.nativeElement; } // -- Form Value Contract ------------------------------------------------------------------ focus(options?: FocusOptions) { - this.timeInput()?.nativeElement.focus(options); + this.#inputOf('start')?.focus(options); } /** @@ -641,11 +868,17 @@ export class AngularInlineTime implements FormValueControl { * stealing. */ reset() { - if (!this.#open()) return; + for (const key of ['start', 'end'] as const) { + const side = this.#side(key); + if (!side.open()) continue; + + if (!timeValuesEqual(side.baselineValue, this.value())) this.value.set(side.baselineValue); + side.baselineValue = this.value(); + side.draft.set(side.display()); + side.dirty = false; + side.saveAttempted.set(false); + } - if (this.#baselineValue !== this.value()) this.value.set(this.#baselineValue); - this.draft.set(this.display()); - this.#saveAttempted.set(false); this.#panelDismissed.set(true); } } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts index 9a34c84..c4cedb8 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts @@ -2,11 +2,84 @@ * Time codec — the canonical value is a 24 h wall-clock string * (`'HH:mm' | null`): locale/timezone-free, the time analogue of the date * control's ISO string. Display localizes through `Intl`. + * + * The `InlineTimeValue` shapes below speak DB entries (full instants) — + * they are the control's VALUE boundary, the time mirror of the date + * codec's `InlineDateValue` machinery. */ +import type { DbDateTime } from '../datetime/db-entry'; + /** `'HH:mm'`. */ export type WallClockTime = string; +/** The object shapes of `InlineTimeValue`: a missing `end` key is a HALF-OPEN range. */ +export interface DbTimeRange { + start: DbDateTime | null; + end?: DbDateTime | null; +} + +/** + * The polymorphic bound value. The consumer's binding shape IS the mode + * declaration: a string binds a single time field, an object binds the + * start–end pair. The control echoes the shape it received and never + * invents another one. + */ +export type InlineTimeValue = DbDateTime | DbTimeRange | null; + +/** The shape a non-null value declares; `null` declares nothing (shape-ambiguous). */ +export type TimeValueShape = 'single' | 'start-only' | 'range'; + +export function inferTimeShape(value: InlineTimeValue): TimeValueShape | null { + if (value === null) return null; + if (typeof value === 'string') return 'single'; + + return 'end' in value ? 'range' : 'start-only'; +} + +/** One canonical internal model, always — whatever shape came in. */ +export interface InternalTimeRange { + start: DbDateTime | null; + end: DbDateTime | null; +} + +/** + * Unlike a single DATE (the day-range `[start, start]`), a single time is + * one instant and a zero-length range is meaningless — so `{ start }` is a + * HALF-OPEN range (`end: null`), never a mirror. + */ +export function toInternalTimeRange(value: InlineTimeValue): InternalTimeRange { + if (value === null) return { start: null, end: null }; + if (typeof value === 'string') return { start: value, end: null }; + + return { start: value.start ?? null, end: value.end ?? null }; +} + +/** + * The echo: renders the internal range back in the consumer's shape. + * `start-only` keeps its one-key form until the data actually has an end — + * only then does it grow the `end` key. + */ +export function echoTimeShape(internal: InternalTimeRange, shape: TimeValueShape): InlineTimeValue { + switch (shape) { + case 'single': + return internal.start; + case 'start-only': + return internal.end === null + ? { start: internal.start } + : { start: internal.start, end: internal.end }; + case 'range': + return { start: internal.start, end: internal.end }; + } +} + +/** Structural equality over the polymorphic value — echo writes must not loop. */ +export function timeValuesEqual(a: InlineTimeValue, b: InlineTimeValue): boolean { + if (a === null || b === null || typeof a === 'string' || typeof b === 'string') return a === b; + + return a.start === b.start && a.end === b.end; +} + const pad = (value: number) => String(value).padStart(2, '0'); function timeIfValid(hours: number, minutes: number): WallClockTime | undefined { @@ -48,7 +121,8 @@ function dayPeriods(locale: string | string[] | undefined) { const period = (hour: number) => format .formatToParts(new Date(2024, 0, 1, hour)) - .find((part) => part.type === 'dayPeriod')?.value.toLowerCase(); + .find((part) => part.type === 'dayPeriod') + ?.value.toLowerCase(); const localAm = period(9); const localPm = period(21); @@ -136,15 +210,27 @@ export function parseTime( return draft.days === 0 ? draft.time : undefined; } +// Formatter construction is the expensive part of Intl — cache per locale +// (the dayPeriodCache pattern): the display computeds re-run per keystroke. +const wallClockFormatCache = new Map(); + +function wallClockFormat(locale?: string | string[]): Intl.DateTimeFormat { + const key = JSON.stringify(locale ?? ''); + const cached = wallClockFormatCache.get(key); + if (cached) return cached; + + const format = new Intl.DateTimeFormat(locale, { timeStyle: 'short' }); + wallClockFormatCache.set(key, format); + return format; +} + /** Localized display (`'9:30 AM'` under `en`, `'09:30'` under `de`). */ export function formatWallClock(time: WallClockTime | null, locale?: string | string[]): string { if (time === null) return ''; const [hours, minutes] = time.split(':').map(Number); try { - return new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format( - new Date(2000, 0, 1, hours, minutes), - ); + return wallClockFormat(locale).format(new Date(2000, 0, 1, hours, minutes)); } catch { return time; } diff --git a/projects/angular-inline-select/temporal/src/datetime/db-entry.spec.ts b/projects/angular-inline-select/temporal/src/datetime/db-entry.spec.ts new file mode 100644 index 0000000..7d81915 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.spec.ts @@ -0,0 +1,76 @@ +import { + composeDbEntry, + localDayOf, + localTimeOf, + rollDbEntryForward, +} from './db-entry'; + +// All expectations pin an explicit display zone, so the specs are +// machine-independent — DST cases NEED a zone that actually observes it. +const ZONE = 'Europe/Berlin'; +const at = (day: string, time: string) => composeDbEntry(day, time, ZONE); + +describe('rollDbEntryForward', () => { + it('leaves an end strictly after the start untouched', () => { + const start = at('2026-07-21', '09:00'); + const end = at('2026-07-21', '17:30'); + expect(rollDbEntryForward(start, end, ZONE)).toBe(end); + }); + + it('rolls a same-day at-or-before end to the next day, wall clock preserved', () => { + const start = at('2026-07-21', '22:00'); + + const overnight = rollDbEntryForward(start, at('2026-07-21', '06:00'), ZONE); + expect(localDayOf(overnight, ZONE)).toBe('2026-07-22'); + expect(localTimeOf(overnight, ZONE)).toBe('06:00'); + + // Equal instants are at-or-before too — the +1 seed. + const equal = rollDbEntryForward(start, start, ZONE); + expect(localDayOf(equal, ZONE)).toBe('2026-07-22'); + expect(localTimeOf(equal, ZONE)).toBe('22:00'); + }); + + it('crosses a multi-day gap in one roll', () => { + const start = at('2026-07-30', '08:00'); + + // Ten days behind, later wall clock: lands on the start's own day. + const sameDay = rollDbEntryForward(start, at('2026-07-20', '20:00'), ZONE); + expect(localDayOf(sameDay, ZONE)).toBe('2026-07-30'); + expect(localTimeOf(sameDay, ZONE)).toBe('20:00'); + + // Earlier wall clock: the start-day landing is still at-or-before, so + // the roll nudges once more — next morning. + const nextDay = rollDbEntryForward(start, at('2026-07-29', '07:00'), ZONE); + expect(localDayOf(nextDay, ZONE)).toBe('2026-07-31'); + expect(localTimeOf(nextDay, ZONE)).toBe('07:00'); + }); + + it('preserves the wall clock across the spring-forward DST transition', () => { + // Berlin springs forward in the night of 2026-03-28 → 2026-03-29. + const start = at('2026-03-28', '22:00'); + const rolled = rollDbEntryForward(start, at('2026-03-28', '21:00'), ZONE); + + // A typed end is wall-clock intent: still 21:00, now on the 29th — + // a fixed 86 400 s shift would read 22:00 (the DST drift). + expect(localDayOf(rolled, ZONE)).toBe('2026-03-29'); + expect(localTimeOf(rolled, ZONE)).toBe('21:00'); + expect(rolled).toBe('2026-03-29T19:00:00.000Z'); + }); + + it('preserves the wall clock across the fall-back DST transition', () => { + // Berlin falls back in the night of 2026-10-24 → 2026-10-25. + const start = at('2026-10-24', '22:00'); + const rolled = rollDbEntryForward(start, at('2026-10-24', '21:00'), ZONE); + + expect(localDayOf(rolled, ZONE)).toBe('2026-10-25'); + expect(localTimeOf(rolled, ZONE)).toBe('21:00'); + expect(rolled).toBe('2026-10-25T20:00:00.000Z'); + }); + + it('returns the end unchanged when either side is unreadable', () => { + expect(rollDbEntryForward('not-a-date', at('2026-07-21', '06:00'), ZONE)).toBe( + at('2026-07-21', '06:00'), + ); + expect(rollDbEntryForward(at('2026-07-21', '22:00'), 'not-a-date', ZONE)).toBe('not-a-date'); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts index c9a0789..82ad168 100644 --- a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts @@ -118,6 +118,29 @@ export function diffDbEntrySeconds(start: DbDateTime, end: DbDateTime): number | return Math.round(to.diff(from, 'seconds').seconds); } +/** + * Rolls `end` forward by whole LOCAL days until it strictly follows `start` — + * the range house rule, shared by the ranged time control and the range + * group (a typed end is WALL-CLOCK intent: at-or-before the start it means a + * later day — `23:30` the same evening, `06:00` overnight). Calendar-day + * math in the display zone, so the end's wall-clock reading survives DST + * transitions (a fixed 86 400 s shift would drift it an hour). Unreadable + * inputs return `end` unchanged. + */ +export function rollDbEntryForward(start: DbDateTime, end: DbDateTime, zone?: ZoneId): DbDateTime { + const from = toDateTime(start, zone); + let to = toDateTime(end, zone); + if (from === null || to === null || to > from) return end; + + // Jump the local-day gap in ONE calendar shift, then nudge over the rare + // DST-length wobble — never a per-day walk. + const dayGap = Math.round(from.startOf('day').diff(to.startOf('day'), 'days').days); + if (dayGap > 0) to = to.plus({ days: dayGap }); + while (to <= from) to = to.plus({ days: 1 }); + + return fromDateTime(to); +} + /** * Display-zone calendar days from `start`'s day to `end`'s day — the end * field's `+n` over-count, now intrinsic to the values. diff --git a/projects/angular-inline-select/temporal/src/leaf-state.ts b/projects/angular-inline-select/temporal/src/leaf-state.ts index 2859b72..7fe2f7a 100644 --- a/projects/angular-inline-select/temporal/src/leaf-state.ts +++ b/projects/angular-inline-select/temporal/src/leaf-state.ts @@ -1,5 +1,6 @@ import { InjectionToken, type Signal } from '@angular/core'; import type { ValidationError } from '@angular/forms/signals'; +import type { BubbleMenuSide } from 'angular-inline-select'; /** * Contract state a field-bound range group forwards DOWN to its leaves — @@ -20,3 +21,13 @@ export interface TemporalLeafState { export const INLINE_TEMPORAL_LEAF_STATE = new InjectionToken( 'INLINE_TEMPORAL_LEAF_STATE', ); + +/** + * The clear-bubble side a leaf ROLE dictates (the day-offset pattern): the + * pair's inline-START leaves (`rangeDay`, `rangeStart`) provide `'start'` + * so their bubbles open OUTWARD without every consumer hand-wiring + * `clearBubbleSide="start"`. The control's own input, when set, overrides. + */ +export const INLINE_TEMPORAL_BUBBLE_SIDE = new InjectionToken( + 'INLINE_TEMPORAL_BUBBLE_SIDE', +); diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.ts index 88d668d..60424ea 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.ts @@ -15,9 +15,14 @@ import { FormField, type ValidationError } from '@angular/forms/signals'; import { AngularInlineDate } from '../angular-inline-date/angular-inline-date'; import { toInternalRange } from '../angular-inline-date/date-codec'; import { AngularInlineTime } from '../angular-inline-time/angular-inline-time'; +import { toInternalTimeRange } from '../angular-inline-time/time-codec'; import { INLINE_TIME_DAY_OFFSET } from '../angular-inline-time/day-offset'; import { AngularInlineDuration } from '../angular-inline-duration/angular-inline-duration'; -import { INLINE_TEMPORAL_LEAF_STATE, type TemporalLeafState } from '../leaf-state'; +import { + INLINE_TEMPORAL_BUBBLE_SIDE, + INLINE_TEMPORAL_LEAF_STATE, + type TemporalLeafState, +} from '../leaf-state'; import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; import { addLocalDays, @@ -28,12 +33,11 @@ import { localDayDiff, localDayOf, localTimeOf, + rollDbEntryForward, shiftDbEntry, type DbDateTime, } from '../datetime/db-entry'; -const DAY_SECONDS = 86_400; - /** * The group's composed DATE value: the stay's day boundaries as DB entries * (`startOf('day')` … `endOf('day')`, over-count intrinsic to the end). @@ -209,9 +213,19 @@ export class DateTimeRangeGroup { return []; }); - /** The endpoint instants and duration, read live off the controls. */ - readonly start = computed(() => this.#start()?.value() ?? null); - readonly end = computed(() => this.#end()?.value() ?? null); + /** + * The endpoint instants and duration, read live off the controls. The + * time leaves are SINGLE-shape here (the group composes the range from + * two of them) — read through the internal model, like `rangeDay` does. + */ + readonly start = computed(() => { + const control = this.#start(); + return control ? toInternalTimeRange(control.value()).start : null; + }); + readonly end = computed(() => { + const control = this.#end(); + return control ? toInternalTimeRange(control.value()).start : null; + }); readonly length = computed(() => this.#length()?.value() ?? null); /** @@ -411,16 +425,12 @@ export class DateTimeRangeGroup { // -- Commit propagation ------------------------------------------------------ - /** Rolls `end` forward by whole days until it strictly follows `start`, then induces. */ + /** Rolls `end` forward by whole LOCAL days until it strictly follows `start`, then induces. */ #induceFrom(start: DbDateTime, end: DbDateTime) { - let diff = diffDbEntrySeconds(start, end)!; - while (diff <= 0) { - end = shiftDbEntry(end, DAY_SECONDS); - diff += DAY_SECONDS; - } + end = rollDbEntryForward(start, end, this.effectiveZone()); this.#writeEnd(end); - this.#writeLength(diff); + this.#writeLength(diffDbEntrySeconds(start, end)!); } /** @@ -574,8 +584,15 @@ function provideLeafState(withErrors: boolean) { /** Whether THIS leaf element carries its own `[formField]` (legacy per-leaf mode). */ const leafHasOwnField = () => inject(FormField, { optional: true, self: true }) !== null; -/** Marks the group's date control: ``. */ -@Directive({ selector: 'angular-inline-date[rangeDay]', providers: [provideLeafState(false)] }) +/** + * Marks the group's date control: ``. The + * pair's inline-START leaf — its clear bubble opens outward (leftward) by + * default via `INLINE_TEMPORAL_BUBBLE_SIDE`. + */ +@Directive({ + selector: 'angular-inline-date[rangeDay]', + providers: [provideLeafState(false), { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }], +}) export class RangeDay { constructor() { const group = inject(DateTimeRangeGroup); @@ -589,8 +606,15 @@ export class RangeDay { } } -/** Marks the group's start time: ``. */ -@Directive({ selector: 'angular-inline-time[rangeStart]', providers: [provideLeafState(false)] }) +/** + * Marks the group's start time: ``. An + * inline-START leaf — its clear bubble opens outward (leftward) by default + * via `INLINE_TEMPORAL_BUBBLE_SIDE`. + */ +@Directive({ + selector: 'angular-inline-time[rangeStart]', + providers: [provideLeafState(false), { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }], +}) export class RangeStart { constructor() { const group = inject(DateTimeRangeGroup); diff --git a/projects/angular-inline-select/temporal/src/side-session.ts b/projects/angular-inline-select/temporal/src/side-session.ts new file mode 100644 index 0000000..0828540 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/side-session.ts @@ -0,0 +1,226 @@ +import { + DestroyRef, + Injector, + afterNextRender, + computed, + effect, + inject, + linkedSignal, + signal, + untracked, + type Signal, + type WritableSignal, +} from '@angular/core'; + +/** + * The shared per-side session machinery of the temporal family. The date + * and time controls are the same creature wearing different codecs: two + * native inputs, a SESSION per side (opens on focusin, settles on Enter/ + * Escape/blur), a frozen draft, snap-back with an aria-live announcement, + * and per-side clear bubbles. Everything payload-agnostic lives HERE — + * the controls keep only what genuinely differs (parsing, composition, + * the calendar panel vs the OS picker). + */ + +/** Which endpoint of a range pair a side/session belongs to. */ +export type SideKey = 'start' | 'end'; + +/** + * Everything one side of the pair owns, payload-generic. A SESSION is a + * continuous stretch of focus on one side: it opens on focusin (capturing + * the control's baseline) and settles on Enter, Escape, or focus leaving. + * Controls extend this with their own session snapshot (the date's + * per-side baseline day; the time's whole-value baseline + frozen anchor). + */ +export interface SideCore { + readonly key: SideKey; + /** This side's committed reading (the value boundary stays DB entries). */ + readonly committed: Signal; + /** Localized display of the committed reading — what the input shows idle. */ + readonly display: Signal; + /** Whether a session is open on this side. */ + readonly open: WritableSignal; + /** + * The input's text: user-owned while a session is open (frozen linkedSignal + * — a value write mid-session never rewrites text under the caret), the + * committed display otherwise. + */ + readonly draft: WritableSignal; + /** + * Whether the USER touched the draft since the last settlement. An + * untouched session settles WHERE THE VALUE STANDS — re-deriving it from + * the draft would undo external writes (a group re-anchoring this leaf) + * with stale session state. + */ + dirty: boolean; + /** Enter was pressed on an unreadable draft — reveals the parse-gate error. */ + readonly saveAttempted: WritableSignal; +} + +/** Builds a side's shared core — the frozen-draft discipline included. */ +export function makeSideCore( + key: SideKey, + committed: Signal, + display: Signal, +): SideCore { + const open = signal(false); + const draft = linkedSignal({ + source: display, + computation: (source, prev) => (open() ? (prev?.value ?? source) : source), + }); + + return { key, committed, display, open, draft, dirty: false, saveAttempted: signal(false) }; +} + +/** Content-sized input width, placeholder-floored — no layout shift. */ +export function sideSize(draft: string, placeholder: string): number { + return Math.max(1, (draft || placeholder).length); +} + +/** Ranged fields suffix the side onto the accessible name. */ +export function sideAriaLabel(base: string, key: SideKey, twoFields: boolean): string { + return twoFields ? `${base} ${key}` : base; +} + +/** + * The `null`-shape memory: `null` is the only shape-ambiguous value, so a + * cleared field keeps emitting the shape its consumer last spoke — + * `ranged` only seeds the cold start. + */ +export function makeShapeMemory(options: { + value: Signal; + infer: (value: V) => S | null; + ranged: Signal; + singleShape: S; + rangeShape: S; +}): { shape: Signal; twoFields: Signal } { + const last = linkedSignal({ + source: options.value, + computation: (value, prev) => options.infer(value) ?? prev?.value ?? null, + }); + + const shape = computed( + () => last() ?? (options.ranged() ? options.rangeShape : options.singleShape), + ); + return { shape, twoFields: computed(() => shape() !== options.singleShape) }; +} + +/** + * The clear-bubble policy: never on required/disabled/readonly fields, + * never mid-edit; the single bubble needs ANY value, a range side its own. + */ +export function makeClearBubbleVisibility(options: { + required: Signal; + disabled: Signal; + readonly: Signal; + editing: Signal; + range: Signal<{ start: unknown; end: unknown }>; +}): { single: Signal; start: Signal; end: Signal } { + const guards = computed( + () => + !options.required() && !options.disabled() && !options.readonly() && !options.editing(), + ); + + return { + single: computed(() => { + const { start, end } = options.range(); + return guards() && !(start === null && end === null); + }), + start: computed(() => guards() && options.range().start !== null), + end: computed(() => guards() && options.range().end !== null), + }; +} + +/** + * The editing bridge: external `editing.set(true)` focuses the start input + * (focusin opens the session); `set(false)` deactivates — the control + * settles the focused side and drops its chrome. Internal focus flow + * writes the model, so states already agree there. + */ +export function wireEditingBridge(options: { + editing: Signal; + focusTarget: Signal; + focusSide: (key: SideKey) => void; + deactivate: (focused: SideKey) => void; +}): void { + effect(() => { + const editing = options.editing(); + untracked(() => { + const focused = options.focusTarget(); + if (editing && focused === null) options.focusSide('start'); + else if (!editing && focused !== null) options.deactivate(focused); + }); + }); +} + +/** + * The session chrome one control instance owns: the focus target, the + * snap-back flash + aria-live announcement, the deferred focus-settlement + * timer, and focus routing to the side inputs. + */ +export interface SideSessionChrome { + /** Which side holds focus — the side the panel, picker and preview serve. */ + readonly focusTarget: WritableSignal; + /** Snap-back flash target + the aria-live announcement text. */ + readonly revertFlash: WritableSignal; + readonly revertNotice: WritableSignal; + /** Focuses a side's input — retrying after render when it isn't there yet. */ + focusSide(key: SideKey): void; + /** + * Focusout settles ASYNCHRONOUSLY: where focus LANDS decides what happens + * (the other input = Tab-advance, the panel/picker = same session, + * outside = settle), and that is only knowable a tick later. + */ + scheduleFocusSettle(onSettled: () => void): void; + /** Snap-back is silent data-restoration — the announcement is not. */ + announceRevert(key: SideKey, restored: string): void; +} + +/** + * Builds a control's session chrome. Timers are cleaned up on destroy — + * call in an injection context (a field initializer). + */ +export function makeSideSessionChrome( + inputOf: (key: SideKey) => HTMLInputElement | undefined, +): SideSessionChrome { + const focusTarget = signal(null); + const revertFlash = signal(null); + const revertNotice = signal(''); + + let focusTimer: ReturnType | null = null; + let flashTimer: ReturnType | null = null; + const injector = inject(Injector); + + inject(DestroyRef).onDestroy(() => { + if (focusTimer !== null) clearTimeout(focusTimer); + if (flashTimer !== null) clearTimeout(flashTimer); + }); + + return { + focusTarget, + revertFlash, + revertNotice, + + focusSide(key) { + const element = inputOf(key); + if (element) element.focus(); + else afterNextRender(() => inputOf(key)?.focus(), { injector }); + }, + + scheduleFocusSettle(onSettled) { + if (focusTimer !== null) clearTimeout(focusTimer); + focusTimer = setTimeout(() => { + focusTimer = null; + onSettled(); + }, 0); + }, + + announceRevert(key, restored) { + revertNotice.set(`Reverted to ${restored === '' ? 'empty' : restored}`); + revertFlash.set(key); + + if (flashTimer !== null) clearTimeout(flashTimer); + flashTimer = setTimeout(() => revertFlash.set(null), 600); + }, + }; +} diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 60c7b7e..7be470c 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -14,12 +14,17 @@

Inline temporal editables

-

Date — what the user sees vs the model

+

Date & date range — what the user sees vs the model

- One [formField]. Type “24.12.” (year auto-completes), an ISO date, or a month name — focus - opens the calendar panel without stealing the caret (ArrowDown enters the grid; yesterday/today/tomorrow - chips are the quick-picks, labels via Intl). The model behind the local calendar day is the - UTC ISO DB entry of its local startOf('day'). + The SAME date control twice — the binding shape IS the mode. Deadline is one + [formField]: type “24.12.” (year auto-completes), an ISO date, or a month name — focus opens + the calendar panel without stealing the caret (ArrowDown enters the grid; yesterday/today/tomorrow chips + are the quick-picks, labels via Intl); the model is the UTC ISO DB entry of the local + startOf('day'). Vacation binds {{ '{' }} start, end {{ '}' }} — the + shape-echo renders the TWO-FIELD pair: start = local startOf('day'), + end = endOf('day'). Tab commits the start and lands in the end; each side owns + its clear (half-open ranges are real states). On the calendar: PRESS-HOLD-DRAG paints the range, + Ctrl/Cmd+click restarts it half-open. The toggles below apply to BOTH fields.

@@ -36,7 +41,7 @@

Date — what the user sees vs the model

- + + + + + +
Date — what the user sees vs the model {{ deadlineModel().due ?? '∅' }}{{ dateModel().due ?? '∅' }}
Vacation + + + {{ dateModel().vacation?.start ?? '∅' }} + → + {{ dateModel().vacation?.end ?? '∅' }} +
-
+
- - + +
-

Time — what the user sees vs the model

+

Time & time range — what the user sees vs the model

- One [formField]. Type “930”, “9”, “21:05” — or overflow hours (“24:30” reads as next-day - 00:30); in native mode a click on the field opens the platform’s own picker. The local wall-clock display hides a FULL UTC instant that - carries its own day. + The SAME time control twice. Starts at is one [formField]: type “930”, “9”, “21:05” — + the local wall-clock display hides a FULL UTC instant that carries its own day. Shift binds + {{ '{' }} start, end {{ '}' }} — the shape-echo renders the TWO-FIELD pair (no group, no + leaves). The range house rules live INSIDE the control: a typed end is wall-clock intent anchored on the + START's day — at-or-before the start it rolls next-day (the overnight seed wears the intrinsic + +{{ shiftTime.dayOffset() }} badge), overflow hours like “25:15” type the over-count by hand, + and a pasted full ISO datetime stands as-is, never re-anchored. Tab commits the start and lands in the end; + Escape reverts the PAIR. The native-picker toggle below applies to BOTH fields.

@@ -97,12 +124,31 @@

Time — what the user sees vs the model

+ + + + +
{{ timeModel().starts ?? '∅' }}
Shift + + + {{ timeModel().shift?.start ?? '∅' }} + → + {{ timeModel().shift?.end ?? '∅' }} +
@@ -147,101 +193,6 @@

Duration — what the user sees vs the model

-
-

Date range — the binding shape IS the mode

-

- The SAME date control, bound with {{ '{' }} start, end {{ '}' }} — the shape-echo renders the - TWO-FIELD pair and echoes commits in that shape: start = local startOf('day'), - end = endOf('day'). Tab commits the start and lands in the end; each side owns - its clear (half-open ranges are real states); an unreadable draft snaps back on blur — Enter is the only - gesture the parse gate blocks. On the calendar: PRESS-HOLD-DRAG paints the range, Ctrl/Cmd+click restarts - it half-open, and a pasted full ISO datetime reads as its local day. -

- - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (UTC, SQL-friendly)
Vacation - - - {{ dateRangeModel().vacation?.start ?? '∅' }} - → - {{ dateRangeModel().vacation?.end ?? '∅' }} -
-
- -
-

Time range — two time leaves, {{ '{' }} start, end {{ '}' }}

-

- The same DateTimeRangeGroup with ONLY rangeStart/rangeEnd registered - — no day, no duration leaf. The model binds {{ '{' }} start, end {{ '}' }} and the shape-echo - never grows a duration key. Overnight seed: the end wears the - +{{ shiftGroup.endDayOffset() }} badge; a typed end is wall-clock intent (at-or-before the - start rolls next-day, overflow hours like “25:15” type the over by hand). -

- - - - - - - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (UTC, SQL-friendly)
Shift starts - - {{ shiftModel()?.start ?? '∅' }}
Shift ends - - {{ shiftModel()?.end ?? '∅' }}
-
-
The quartet — ONE form field, the group is the

+
+

The quartet in a table — each ROW is one control

+

+ Five shifts, one dateTimeRangeGroup per <tr>, its + {{ '{' }} start, end, duration {{ '}' }} value two-way bound per row — day, time range and + length are the group's leaves in the row's cells. Night is seeded overnight, so its end wears the + +1 badge; On-call runs a day and a half. Type overflow hours (“25:15”) into any + Ends to roll its day by hand — a length commit moves the end, a day commit shifts the whole row. +

+ + + + + + + + + + + + @for (row of stayRows; track row.label) { + + + + + + + } + +
ShiftDayTimeLength
{{ row.label }} + + + + + + + + + +
+
+
The quartet in mat-form-fields (T4) — same con Baseline — stock Material datepickers in mat-f
-
-

Timezones (T6) — one instant, three walls

-

- Values NEVER carry the zone — they stay UTC ISO DB entries. The DISPLAY ZONE is configuration: a - zone input per field, or app-wide via provideInlineTemporalZone (the - ServerSideDatetimeConfiguration analogue). All three fields below bind THE SAME instant; - editing any wall re-composes the instant in THAT zone. -

- - - - - - - - - - - - - - - - - - - - - - - - -
WallDisplayModel (UTC, SQL-friendly)
Machine zone - - {{ zonedInstant() ?? '∅' }}
New York - -
Tokyo day - -
-
- @if (emittedEvents().length > 0) {
diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss index 6662791..950e6f0 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss @@ -32,6 +32,27 @@ } } +// The quartet-per-row table: the time-range pair rides in ONE column (the +// ranged control's look — start – end); the label column hugs its content +// like the other model tables. +.quartet-table { + td { + width: 25%; + white-space: nowrap; + } +} + +.time-range-pair { + display: inline-flex; + align-items: baseline; + gap: 0.35em; +} + +.time-range-pair__separator { + user-select: none; + color: var(--mat-sys-on-surface-variant); +} + // The T4 card: four labeled mat boxes in one row, wrapping on narrow screens. .mat-quartet { display: flex; diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index e72f350..03b764a 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -5,6 +5,7 @@ import { // Signals signal, computed, + type WritableSignal, } from '@angular/core'; import { FormField, form, required } from '@angular/forms/signals'; @@ -29,6 +30,7 @@ import { composeDbEntry, dayToDbEntry, dayEndToDbEntry, + type DbTimeRange, type DurationFormat, type TemporalRangeValue, type IsoDateRange, @@ -66,30 +68,48 @@ import { InlineMatFormField } from 'angular-inline-select/temporal-mat'; }) export class TemporalPlayground { // --------------------------------------------------------------------------- - // Date — signal form, ISO value, /today slash menu + // Date & date range — ONE form: the single deadline and the ranged vacation + // live in the same card, so the card's toggles (required, locale, touched, + // reset) apply to BOTH fields. // --------------------------------------------------------------------------- protected fieldRequired = signal(true); protected dateLocale = signal<'de' | 'en'>('en'); - protected deadlineModel = signal<{ due: string | null }>({ due: dayToDbEntry('2026-07-20') }); + protected dateModel = signal<{ due: string | null; vacation: IsoDateRange | null }>({ + due: dayToDbEntry('2026-07-20'), + vacation: { start: dayToDbEntry('2026-07-21'), end: dayEndToDbEntry('2026-07-24') }, + }); - protected deadlineForm = form(this.deadlineModel, (path) => { + protected dateForm = form(this.dateModel, (path) => { required(path.due, { when: () => this.fieldRequired() }); + required(path.vacation, { when: () => this.fieldRequired() }); }); protected dueMissing = computed(() => - this.deadlineForm.due().errors().some((error) => error.kind === 'required'), + this.dateForm.due().errors().some((error) => error.kind === 'required'), ); + protected resetDateFields() { + this.dateForm.due().reset(); + this.dateForm.vacation().reset(); + } + // --------------------------------------------------------------------------- - // Time — form-driven: the model is a full UTC instant carrying its day + // Time & time range — ONE form: the single instant and the ranged shift + // share the card's native-picker toggle. Models are full UTC instants + // carrying their day; the shift is seeded OVERNIGHT so the end instant is + // next-day and wears the intrinsic +1 badge. // --------------------------------------------------------------------------- - protected timeModel = signal<{ starts: string | null }>({ + protected timeModel = signal<{ starts: string | null; shift: DbTimeRange | null }>({ starts: composeDbEntry('2026-07-20', '09:30'), + shift: { + start: composeDbEntry('2026-07-21', '22:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }, }); protected timeForm = form(this.timeModel); - /** Native mode: the field itself opens the OS picker — no 🕐 suffix. */ + /** Native mode: the fields themselves open the OS picker — no 🕐 suffix. */ protected nativeTimePicker = signal(false); // --------------------------------------------------------------------------- @@ -99,26 +119,6 @@ export class TemporalPlayground { protected durationModel = signal<{ estimate: number | null }>({ estimate: 5400 }); protected durationForm = form(this.durationModel); - // --------------------------------------------------------------------------- - // Date range — shape-echo: the OBJECT binding turns the ONE date control - // ranged; model start = startOf('day'), end = endOf('day') in UTC. - // --------------------------------------------------------------------------- - protected dateRangeModel = signal<{ vacation: IsoDateRange | null }>({ - vacation: { start: dayToDbEntry('2026-07-21'), end: dayEndToDbEntry('2026-07-24') }, - }); - protected dateRangeForm = form(this.dateRangeModel); - - // --------------------------------------------------------------------------- - // Time range — the group with ONLY rangeStart/rangeEnd registered; the - // model binds {start, end} WITHOUT a duration key (shape-echoed away). - // Seeded overnight: the end instant is next-day, so it wears the +1 badge. - // --------------------------------------------------------------------------- - protected shiftModel = signal({ - start: composeDbEntry('2026-07-21', '22:00'), - end: composeDbEntry('2026-07-22', '01:30'), - }); - protected shiftForm = form(this.shiftModel); - // --------------------------------------------------------------------------- // The quartet — T5b: the GROUP is the form control. ONE field, the domain // shape ({start, end, duration} — DB entries + seconds); the four leaves @@ -133,6 +133,58 @@ export class TemporalPlayground { protected stayForm = form(this.stayModel); + // --------------------------------------------------------------------------- + // The quartet in a TABLE — five rows, each ROW is one control: the group + // directive sits on the , its value two-way bound per row. A plain + // table, not mat-table: the leaves inject their group through the element + // injector, so they must be template children of the row — matColumnDef + // cell templates are declared on the table, not the row, and would all + // resolve the same group. The night shift is seeded overnight (+1 badge); + // typed overflow hours ("25:15") roll an end the same way. + // --------------------------------------------------------------------------- + protected stayRows: { label: string; value: WritableSignal }[] = [ + { + label: 'Early', + value: signal({ + start: composeDbEntry('2026-07-20', '06:00'), + end: composeDbEntry('2026-07-20', '14:00'), + duration: 28_800, + }), + }, + { + label: 'Core', + value: signal({ + start: composeDbEntry('2026-07-21', '09:00'), + end: composeDbEntry('2026-07-21', '17:30'), + duration: 30_600, + }), + }, + { + label: 'Late', + value: signal({ + start: composeDbEntry('2026-07-22', '13:15'), + end: composeDbEntry('2026-07-22', '21:45'), + duration: 30_600, + }), + }, + { + label: 'Night', + value: signal({ + start: composeDbEntry('2026-07-23', '22:00'), + end: composeDbEntry('2026-07-24', '06:00'), + duration: 28_800, + }), + }, + { + label: 'On-call', + value: signal({ + start: composeDbEntry('2026-07-24', '08:00'), + end: composeDbEntry('2026-07-25', '20:00'), + duration: 129_600, + }), + }, + ]; + // --------------------------------------------------------------------------- // The quartet in MAT-FORM-FIELDS (T4): same group, same unbound leaves — // each hosted by via the temporal-mat adapter. The @@ -147,13 +199,6 @@ export class TemporalPlayground { protected matStayForm = form(this.matStayModel); - // --------------------------------------------------------------------------- - // T6 — the display zone is CONFIGURATION, the value is not: one UTC - // instant, three walls. `zone` per field here; app-wide via - // `provideInlineTemporalZone` (iusta's ServerSideDatetimeConfiguration). - // --------------------------------------------------------------------------- - protected zonedInstant = signal(composeDbEntry('2026-07-21', '21:00')); - /** The page's locale toggle, pinned to 24 h — military time survives `en`. */ protected militaryLocale = computed(() => `${this.dateLocale()}-u-hc-h23`); From c5221837409d9e4dc4e27f798396cea782888fef Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Thu, 9 Jul 2026 19:07:06 +0200 Subject: [PATCH 34/48] feat(Absorption): upstream porting to be on same level with main consumer --- ROADMAP.md | 22 +++++++++++++------ .../src/mat-form-field-adapter.ts | 10 ++++----- .../angular-inline-date.spec.ts | 17 ++++++++++++++ .../angular-inline-date.ts | 14 ++++++++++++ .../angular-inline-duration.ts | 7 ++++++ .../angular-inline-time.ts | 7 ++++++ .../temporal/src/datetime/db-entry.ts | 6 +++-- 7 files changed, 69 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 91ab217..8164773 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,13 +103,21 @@ reset value, per design. ## Remaining -### Complete Phase 3 — remove the legacy outputs - -Delete `savedModelChange` and `reverted` (breaking; pre-1.0), migrate the -demo bindings to `(saved)` (`$event.value`), drop the comparison entries from -the event console. Do this once the `saved` payload has proven itself in use. -Note `reverted` is the only carrier of the *discarded draft text* — confirm -nothing needs it before deleting. +### ~~Complete Phase 3 — remove the legacy outputs~~ — REVERSED (2026-07-09) + +**USER DECISION: `savedModelChange` is PERMANENT — the DNA of the +library.** It is part of every editable, here and in iusta, and will never +be deprecated. `saved` (the session payload `{value, changed, …}`) +coexists as the richer sibling, not the successor. The two emit together +on every settled change; consumers pick the shape they want. (`reverted`'s +fate stays open — it is the only carrier of the discarded draft text; +decide separately if it ever matters.) + +Open convergence note: iusta's temporal `savedModelChange` payloads are +richer *details* objects (Luxon `DateSavedDetails`/`TimeSavedDetails`) +while the sandbox emits the raw value — same DNA, different plumage. +Whether the details shape upstreams into the sandbox (Luxon is already +contained in `/temporal`) is an open Phase-6 question. ## Next up — editable-number & multi-page demo diff --git a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts index 1ae0277..22e733e 100644 --- a/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts @@ -117,7 +117,9 @@ export class InlineMatFormField implements MatFormFieldControl, OnDestr const destroyRef = inject(DestroyRef); afterNextRender( () => { - if (formField !== null && this.#control instanceof AngularInlineDate) { + // Duck-typed: only panel-floating controls (the date's calendar) + // carry the seam; the adapter never branches on the concrete class. + if (formField !== null && 'overlayOrigin' in this.#control) { this.#control.overlayOrigin.set(formField.getConnectedOverlayOrigin()); } @@ -158,11 +160,9 @@ export class InlineMatFormField implements MatFormFieldControl, OnDestr return this.#control.value(); } - /** Date resolves its own default (the locale pattern) — read the verdict, not the input. */ + /** Every control resolves its own default — read the uniform verdict, never the input. */ #placeholder(): string { - return this.#control instanceof AngularInlineDate - ? this.#control.effectivePlaceholder() - : this.#control.placeholder(); + return this.#control.placeholderText(); } get placeholder(): string { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index 39e9ed2..02b0006 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -541,6 +541,23 @@ describe('AngularInlineDate two-field range', () => { expect(h.host.sessions.at(-1)!.changed).toBe(true); }); + it('Tab-advance settles the departing side BEFORE the landing session baselines — Escape never resurrects a pre-sort pair', async () => { + h.host.value.set({ start: db('2026-05-12'), end: dbEnd('2026-05-15') }); + h.fixture.detectChanges(); + + type(h, h.end()!, '2026-05-08'); // live channel: inverted, not yet sorted + focusInput(h, h.start()); // what Tab does — the end settles NOW and sorts + h.fixture.detectChanges(); + + const sorted = { start: db('2026-05-08'), end: dbEnd('2026-05-12') }; + expect(h.host.value()).toEqual(sorted); + + // The start session's baseline is the POST-sort day — Escape is a no-op. + press(h, h.start(), 'Escape'); + await settle(h); + expect(h.host.value()).toEqual(sorted); + }); + it('a start edit in the one-key { start } shape moves the single-day range whole', async () => { h.host.value.set({ start: db('2026-05-12') }); h.fixture.detectChanges(); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 73f9058..6246488 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -180,6 +180,13 @@ export class AngularInlineDate implements FormValueControl { readonly effectivePlaceholder = computed( () => this.placeholder() ?? localeDatePlaceholder(this.locale()), ); + + /** + * The UNIFORM adapter surface (every temporal control exposes it): the + * resolved placeholder text, so hosting containers never branch on the + * concrete control. + */ + readonly placeholderText = computed(() => this.effectivePlaceholder()); protected effectiveEndPlaceholder = computed(() => { const explicit = this.endPlaceholder(); if (explicit !== undefined) return explicit; @@ -526,6 +533,13 @@ export class AngularInlineDate implements FormValueControl { // -- Focus flow ---------------------------------------------------------------- protected handleFocusIn(key: SideKey) { + // Tab-advance: focus landing HERE ends the partner's session. It settles + // NOW — before this session snapshots its baseline — so Escape and + // snap-back never resurrect a pre-SORT pair (a settle can move the + // partner: the typed-commit sort). + const partner = this.#side(key === 'start' ? 'end' : 'start'); + if (partner.open()) this.#settle(partner.key); + const side = this.#side(key); if (!side.open()) { side.baselineDay = side.committed(); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index e7e190a..0105149 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -83,6 +83,13 @@ export class AngularInlineDuration implements FormValueControl { placeholder = input('0:00'); + /** + * The UNIFORM adapter surface (every temporal control exposes it): the + * resolved placeholder text, so hosting containers never branch on the + * concrete control. + */ + readonly placeholderText = computed(() => this.placeholder()); + /** Accessible name for the field. */ ariaLabel = input(undefined); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index d6774f1..3c8f880 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -210,6 +210,13 @@ export class AngularInlineTime implements FormValueControl { return this.internalRange().start === null ? this.placeholder() : '…'; }); + /** + * The UNIFORM adapter surface (every temporal control exposes it): the + * resolved placeholder text, so hosting containers never branch on the + * concrete control. + */ + readonly placeholderText = computed(() => this.placeholder()); + /** Accessible base name; ranged fields append " start" / " end". */ ariaLabel = input(undefined); diff --git a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts index 82ad168..9215d4e 100644 --- a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts @@ -99,8 +99,10 @@ export function dayEndToDbEntry(day: string, zone?: ZoneId): DbDateTime { * preserved time). */ export function composeDbEntry(day: string, time: string, zone?: ZoneId): DbDateTime { - const [hour, minute] = time.split(':').map(Number); - return fromDateTime(dayIn(day, zone).set({ hour, minute, second: 0, millisecond: 0 })); + // `'HH:mm:ss'` composes with its seconds (iusta's HOUR_MINUTE_SECOND + // format); bare `'HH:mm'` stays second-less. + const [hour, minute, second = 0] = time.split(':').map(Number); + return fromDateTime(dayIn(day, zone).set({ hour, minute, second, millisecond: 0 })); } /** Shifts a DB entry by whole seconds (`shiftFromDuration`'s primitive) — zone-free. */ From 9d473f4124c852e8738d127fe5f52cbbdd438afd Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Fri, 10 Jul 2026 12:42:23 +0200 Subject: [PATCH 35/48] refactor(core): align naming convention also on absorber side --- ROADMAP.md | 19 +++- .../angular-inline-time.spec.ts | 69 ++++++++++++ .../angular-inline-time.ts | 28 ++++- .../src/angular-inline-time/time-codec.ts | 28 +++-- .../temporal/src/datetime/db-entry.ts | 22 ++-- .../src/range-group/range-group.spec.ts | 104 ++++++++++++++++++ .../temporal/src/range-group/range-group.ts | 65 +++++++++-- projects/app/src/app/login/login.ts | 4 +- 8 files changed, 301 insertions(+), 38 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8164773..431732c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -519,7 +519,24 @@ typed event console. 93/93 sandbox tests. `` + `showPicker()` with a focus fallback; idle picks commit immediately, in-session picks replace the draft). -**The temporal program continues in [ROADMAP-DATETIME.md](ROADMAP-DATETIME.md):** +**Upstream re-sync batch 2 (2026-07-10, suite at 210):** the range group +gained the **`rangeTimes` role** mirrored back from iusta — ONE ranged time +control carrying both endpoints (``), replacing the two single `rangeStart`/`rangeEnd` leaves; +propagation stays per-endpoint (dispatches on `saved.side`; the pair's own +roll is idempotent under the group's — what `dayOverflow`/`explicitDay` are +carried for). Trio spec block ported. And the **time seconds story**: the +codec's optional `:ss` parse tail (meridiem-free — seconds and day-periods +stay apart) + a `format` input (`'HH:mm' | 'HH:mm:ss'`) — the seconds +format displays the RAW format string (24 h; its own display must parse +back), the default keeps the Intl-localized display; `composeDbEntry` +already composed seconds. Still open with iusta: the details-payload +`savedModelChange` convergence question. + +**The temporal program's upstream design record was ROADMAP-DATETIME.md +(retired — recover via `git show 8063fb6:ROADMAP-DATETIME.md`); the LIVE +absorption log is iusta's `EDITABLES-ABSORPTION-ROADMAP.md`. That record +covered:** calendar overlay picker on `@angular/aria` Grid + `DateAdapter` (required), mat-form-field hosting for all three controls (via iusta's `MatFormFieldAdapterContract` pattern), the two-field date range with diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts index fdb51a1..ff17a9b 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -78,6 +78,12 @@ describe('time codec', () => { expect(formatWallClock('21:05', 'de')).toBe('21:05'); expect(formatWallClock(null)).toBe(''); }); + + it("an optional :ss tail parses (the 'HH:mm:ss' format round-trip)", () => { + expect(parseTimeDraft('21:30:15')).toEqual({ time: '21:30:15', days: 0 }); + expect(parseTimeDraft('21:30:75')).toBeUndefined(); // bad seconds gate + expect(parseTimeDraft('9:30:00 PM', 'en')).toBeUndefined(); // seconds + meridiem stay apart + }); }); // ============================================================================= @@ -754,3 +760,66 @@ describe('AngularInlineTime two-field range', () => { expect(r.inputs().length).toBe(2); }); }); + +// ============================================================================= +// The seconds format ('HH:mm:ss') — displays, parses and composes seconds +// ============================================================================= + +@Component({ + imports: [AngularInlineTime], + template: ``, +}) +class SecondsHost { + value = signal(composeDbEntry(DAY, '21:30:15')); +} + +describe('AngularInlineTime with the seconds format', () => { + function setupSeconds() { + const fixture = TestBed.createComponent(SecondsHost); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + input: () => fixture.nativeElement.querySelector('.inline-time__input') as HTMLInputElement, + }; + } + + it('displays the RAW format string — meridiem-free even under an Intl 12 h locale', () => { + const s = setupSeconds(); + expect(s.input().value).toBe('21:30:15'); + }); + + it('a typed :ss tail commits seconds into the DB entry; plain HH:mm reads back :00', async () => { + const s = setupSeconds(); + const input = s.input(); + input.focus(); + s.fixture.detectChanges(); + + input.value = '9:05:30'; + input.dispatchEvent(new Event('input', { bubbles: true })); + s.fixture.detectChanges(); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + s.fixture.detectChanges(); + + expect(s.host.value()).toBe(composeDbEntry(DAY, '09:05:30')); + expect(input.value).toBe('09:05:30'); + + input.value = '9:30'; + input.dispatchEvent(new Event('input', { bubbles: true })); + s.fixture.detectChanges(); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + s.fixture.detectChanges(); + + expect(s.host.value()).toBe(composeDbEntry(DAY, '09:30')); + expect(input.value).toBe('09:30:00'); + + input.blur(); + await new Promise((resolve) => setTimeout(resolve)); + s.fixture.detectChanges(); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index 3c8f880..355af78 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -61,6 +61,7 @@ import { localTimeOf, parseDbEntryDraft, rollDbEntryForward, + toDateTime, todayIn, type DbDateTime, } from '../datetime/db-entry'; @@ -187,6 +188,15 @@ export class AngularInlineTime implements FormValueControl { /** Reference clock — anchors the day of a time typed into an EMPTY field. */ now = input<() => Date>(() => new Date()); + /** + * Wall-clock format: `'HH:mm:ss'` displays, parses and composes SECONDS — + * rendered as the RAW format string (24 h, meridiem-free), because the + * format's own display must parse back and the codec keeps seconds and + * day-periods apart. The default `'HH:mm'` keeps the Intl-localized + * display. + */ + format = input<'HH:mm' | 'HH:mm:ss'>('HH:mm'); + /** Form Value Contract. */ errors = input([]); disabled = input(false); @@ -362,6 +372,20 @@ export class AngularInlineTime implements FormValueControl { if (!timeValuesEqual(echoed, this.value())) this.value.set(echoed); } + /** + * A side's wall-clock display. The default format is Intl-localized; + * `'HH:mm:ss'` renders the RAW format string (meridiem-free — the + * format's own display must parse back). + */ + #wallClockOf(instant: DbDateTime | null): string { + if (this.format() === 'HH:mm:ss') { + const dateTime = toDateTime(instant, this.effectiveZone()); + return dateTime === null ? '' : dateTime.toFormat(this.format()); + } + + return formatWallClock(localTimeOf(instant, this.effectiveZone()), this.locale()); + } + // -- The two sides ----------------------------------------------------------- readonly #startSide = this.#makeSide('start'); @@ -373,9 +397,7 @@ export class AngularInlineTime implements FormValueControl { #makeSide(key: SideKey): TimeSide { const committed = computed(() => this.internalRange()[key]); - const display = computed(() => - formatWallClock(localTimeOf(committed(), this.effectiveZone()), this.locale()), - ); + const display = computed(() => this.#wallClockOf(committed())); const core = makeSideCore(key, committed, display); return { diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts index c4cedb8..55ac1cb 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts @@ -142,11 +142,13 @@ function dayPeriods(locale: string | string[] | undefined) { * `undefined` (raises the parse gate). * * Accepted shapes: `'9'` → 09:00, `'21'` → 21:00, `'930'`/`'0930'` → 09:30, - * `'2105'` → 21:05, `'9:30'`, `'09.30'` — OVERFLOW hours declaring the day - * over-count by hand (`'24:30'`/`'2430'` → next day 00:30, `'240:30'` → - * +10 days 00:30; bare 1–2 digit hours stay strict, `'99'` is a typo) — - * and, per the round-trip law, the display's own day-period formats: - * `'9:30 AM'`, `'12:00 AM'` → 00:00, `'9 PM'` → 21:00. + * `'2105'` → 21:05, `'9:30'`, `'09.30'` — an optional `:ss` tail + * (`'21:30:15'`, meridiem-free — the seconds format's own display parses + * back) — OVERFLOW hours declaring the day over-count by hand + * (`'24:30'`/`'2430'` → next day 00:30, `'240:30'` → +10 days 00:30; bare + * 1–2 digit hours stay strict, `'99'` is a typo) — and, per the round-trip + * law, the display's own day-period formats: `'9:30 AM'`, `'12:00 AM'` → + * 00:00, `'9 PM'` → 21:00. */ export function parseTimeDraft( raw: string, @@ -171,16 +173,24 @@ export function parseTimeDraft( if (draft === undefined || meridiem === undefined) return draft; if (draft.days > 0) return undefined; // overflow + AM/PM is nonsense - const [hours, minutes] = draft.time.split(':').map(Number); + const [hours] = draft.time.split(':').map(Number); if (hours > 12 || hours === 0) return undefined; const shifted = meridiem === 'pm' ? (hours % 12) + 12 : hours % 12; return { time: `${String(shifted).padStart(2, '0')}:${draft.time.slice(-2)}`, days: 0 }; }; - // Separated: H:mm / H.mm — hours may overflow into days (up to 3 digits). - let match = /^(\d{1,3})[:.](\d{2})$/.exec(trimmed); - if (match) return applyMeridiem(draftIfValid(Number(match[1]), Number(match[2]))); + // Separated: H:mm / H.mm — hours may overflow into days (up to 3 digits) — + // plus an optional `:ss` tail (the seconds format's own display must parse + // back) — carried in the time string, meridiem-free. + const match = /^(\d{1,3})[:.](\d{2})(?::(\d{2}))?$/.exec(trimmed); + if (match) { + const draft = applyMeridiem(draftIfValid(Number(match[1]), Number(match[2]))); + if (draft === undefined || match[3] === undefined) return draft; + if (meridiem !== undefined || Number(match[3]) > 59) return undefined; + + return { time: `${draft.time}:${match[3]}`, days: draft.days }; + } // Compact digits: H / HH / Hmm / HHmm if (/^\d{1,4}$/.test(trimmed)) { diff --git a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts index 9215d4e..361bc5c 100644 --- a/projects/angular-inline-select/temporal/src/datetime/db-entry.ts +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts @@ -19,7 +19,7 @@ import { DateTime } from 'luxon'; * `INLINE_TEMPORAL_ZONE`). Instant math (shift/diff) is zone-free. * * Luxon itself is CONTAINED here (and consumed via the - * `toDateTime`/`fromDateTime` bridge) — values stay plain strings, and the + * `toDateTime`/`toDBEntry` bridge) — values stay plain strings, and the * engine ships only with the temporal entry point, exactly like * libphonenumber ships only with `/phone`. */ @@ -38,8 +38,8 @@ export function toDateTime(value: DbDateTime | null, zone?: ZoneId): DateTime | return parsed.isValid ? parsed : null; } -/** The Luxon bridge, outbound: iusta's `toDBEntry`, verbatim. */ -export function fromDateTime(dateTime: DateTime): DbDateTime { +/** The Luxon bridge, outbound — THE house function (iusta naming wins for utils). */ +export function toDBEntry(dateTime: DateTime): DbDateTime { return dateTime.toUTC().toISO()!; } @@ -49,8 +49,8 @@ export function parseDbEntry(value: DbDateTime | null): Date | null { } /** The wire format from a JS `Date` — `toDBEntry(DateTime.fromJSDate(date))`. */ -export function toDbEntry(date: Date): DbDateTime { - return fromDateTime(DateTime.fromJSDate(date)); +export function dateToDbEntry(date: Date): DbDateTime { + return toDBEntry(DateTime.fromJSDate(date)); } /** @@ -66,7 +66,7 @@ export function parseDbEntryDraft(raw: string, zone?: ZoneId): DbDateTime | unde const iso = trimmed.replace(' ', 'T'); const parsed = zone ? DateTime.fromISO(iso, { zone }) : DateTime.fromISO(iso); - return parsed.isValid ? fromDateTime(parsed) : undefined; + return parsed.isValid ? toDBEntry(parsed) : undefined; } /** The display-zone calendar day of a DB entry: `'yyyy-MM-dd'`. */ @@ -85,12 +85,12 @@ function dayIn(day: string, zone?: ZoneId): DateTime { /** Display-zone midnight of a `'yyyy-MM-dd'` day, as a DB entry (`startOf('day')`). */ export function dayToDbEntry(day: string, zone?: ZoneId): DbDateTime { - return fromDateTime(dayIn(day, zone).startOf('day')); + return toDBEntry(dayIn(day, zone).startOf('day')); } /** Display-zone end-of-day of a `'yyyy-MM-dd'` day, as a DB entry (`endOf('day')`). */ export function dayEndToDbEntry(day: string, zone?: ZoneId): DbDateTime { - return fromDateTime(dayIn(day, zone).endOf('day')); + return toDBEntry(dayIn(day, zone).endOf('day')); } /** @@ -102,13 +102,13 @@ export function composeDbEntry(day: string, time: string, zone?: ZoneId): DbDate // `'HH:mm:ss'` composes with its seconds (iusta's HOUR_MINUTE_SECOND // format); bare `'HH:mm'` stays second-less. const [hour, minute, second = 0] = time.split(':').map(Number); - return fromDateTime(dayIn(day, zone).set({ hour, minute, second, millisecond: 0 })); + return toDBEntry(dayIn(day, zone).set({ hour, minute, second, millisecond: 0 })); } /** Shifts a DB entry by whole seconds (`shiftFromDuration`'s primitive) — zone-free. */ export function shiftDbEntry(value: DbDateTime, seconds: number): DbDateTime { const dateTime = toDateTime(value); - return dateTime === null ? value : fromDateTime(dateTime.plus({ seconds })); + return dateTime === null ? value : toDBEntry(dateTime.plus({ seconds })); } /** Whole seconds between two DB entries (`induceFromTimeRange`'s primitive) — zone-free. */ @@ -140,7 +140,7 @@ export function rollDbEntryForward(start: DbDateTime, end: DbDateTime, zone?: Zo if (dayGap > 0) to = to.plus({ days: dayGap }); while (to <= from) to = to.plus({ days: 1 }); - return fromDateTime(to); + return toDBEntry(to); } /** diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts index ea93627..b86b468 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts @@ -12,6 +12,7 @@ import { RangeEndDay, RangeStart, RangeEnd, + RangeTimes, RangeLength, type ComposedDateRange, type ComposedTimeRange, @@ -524,3 +525,106 @@ describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { }).toThrowError(/bind EITHER the group/); }); }); + +// ============================================================================= +// The TRIO — date · ONE ranged time pair · duration (the add-dialog shape) +// ============================================================================= + +@Component({ + imports: [ + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + DateTimeRangeGroup, + RangeDay, + RangeTimes, + RangeLength, + FormField, + ], + template: ` +
+ + + +
+ `, +}) +class TrioHost { + group = viewChild.required(DateTimeRangeGroup); + + model = signal({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-22', '06:00'), + duration: 32_400, + }); + field = form(this.model); + + commits: (TemporalRangeValue | null)[] = []; + + now = () => NOW; +} + +describe('DateTimeRangeGroup with the rangeTimes pair (the trio)', () => { + // Trio input order: 0 day · 1 pair start · 2 pair end · 3 length. + it('the bound value flows DOWN into the pair', async () => { + const h = boundSetup(TrioHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(h.inputs().map((input) => input.value)).toEqual([ + 'Jul 21, 2026', + '21:00', + '06:00', + '09:00', + ]); + }); + + it('a typed pair END commits ONE composed model — duration follows', async () => { + const h = boundSetup(TrioHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, h.inputs, 2, '23:30'); + + expect(h.host.model()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:30'), + duration: 2.5 * 3600, + }); + }); + + it('a duration commit MOVES the pair end', async () => { + const h = boundSetup(TrioHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, h.inputs, 3, '2:00'); + + expect(h.host.model()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:00'), + duration: 2 * 3600, + }); + expect(h.inputs()[2].value).toBe('23:00'); // the pair's end re-rendered + }); + + it('a day commit shifts BOTH pair instants, wall clocks preserved', async () => { + const h = boundSetup(TrioHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, h.inputs, 0, '24.7.2026'); + + expect(h.host.model()).toEqual({ + start: at('2026-07-24', '21:00'), + end: at('2026-07-25', '06:00'), + duration: 32_400, + }); + expect(h.inputs().map((input) => input.value)).toEqual([ + 'Jul 24, 2026', + '21:00', + '06:00', + '09:00', + ]); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.ts index 60424ea..5bdbe7a 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.ts @@ -15,7 +15,6 @@ import { FormField, type ValidationError } from '@angular/forms/signals'; import { AngularInlineDate } from '../angular-inline-date/angular-inline-date'; import { toInternalRange } from '../angular-inline-date/date-codec'; import { AngularInlineTime } from '../angular-inline-time/angular-inline-time'; -import { toInternalTimeRange } from '../angular-inline-time/time-codec'; import { INLINE_TIME_DAY_OFFSET } from '../angular-inline-time/day-offset'; import { AngularInlineDuration } from '../angular-inline-duration/angular-inline-duration'; import { @@ -116,6 +115,8 @@ export class DateTimeRangeGroup { #endDay = signal(null); #start = signal(null); #end = signal(null); + /** ONE ranged time control carrying BOTH endpoints (the `rangeTimes` role). */ + #times = signal(null); #length = signal(null); /** Present when the GROUP carries the `[formField]` — form-bound mode. */ @@ -214,18 +215,16 @@ export class DateTimeRangeGroup { }); /** - * The endpoint instants and duration, read live off the controls. The - * time leaves are SINGLE-shape here (the group composes the range from - * two of them) — read through the internal model, like `rangeDay` does. + * The endpoint instants and duration, read live off the controls. Two + * SINGLE-shape time leaves (`rangeStart`/`rangeEnd`) or ONE ranged pair + * (`rangeTimes`) — read through the internal model, like `rangeDay` does. */ - readonly start = computed(() => { - const control = this.#start(); - return control ? toInternalTimeRange(control.value()).start : null; - }); - readonly end = computed(() => { - const control = this.#end(); - return control ? toInternalTimeRange(control.value()).start : null; - }); + readonly start = computed( + () => this.#start()?.internalRange().start ?? this.#times()?.internalRange().start ?? null, + ); + readonly end = computed( + () => this.#end()?.internalRange().start ?? this.#times()?.internalRange().end ?? null, + ); readonly length = computed(() => this.#length()?.value() ?? null); /** @@ -327,6 +326,8 @@ export class DateTimeRangeGroup { this.#start()?.value.set(start); this.#end()?.value.set(end); + // The ranged pair speaks the object shape — both endpoints in one value. + this.#times()?.value.set(start === null && end === null ? null : { start, end }); this.#length()?.value.set(duration); this.#day()?.value.set(start === null ? null : dayToDbEntry(localDayOf(start, this.effectiveZone())!, this.effectiveZone())); this.#endDay()?.value.set(end === null ? null : dayToDbEntry(localDayOf(end, this.effectiveZone())!, this.effectiveZone())); @@ -418,6 +419,10 @@ export class DateTimeRangeGroup { this.#registerBinding(leafBound, 'rangeEnd'); this.#end.set(control); } + attachTimes(control: AngularInlineTime, leafBound = false) { + this.#registerBinding(leafBound, 'rangeTimes'); + this.#times.set(control); + } attachLength(control: AngularInlineDuration, leafBound = false) { this.#registerBinding(leafBound, 'rangeLength'); this.#length.set(control); @@ -544,11 +549,21 @@ export class DateTimeRangeGroup { #writeStart(value: DbDateTime) { const control = this.#start(); if (control && control.value() !== value) control.value.set(value); + + const times = this.#times(); + if (times && times.internalRange().start !== value) { + times.value.set({ start: value, end: times.internalRange().end }); + } } #writeEnd(value: DbDateTime) { const control = this.#end(); if (control && control.value() !== value) control.value.set(value); + + const times = this.#times(); + if (times && times.internalRange().end !== value) { + times.value.set({ start: times.internalRange().start, end: value }); + } } #writeLength(value: number | null) { @@ -656,6 +671,32 @@ export class RangeEnd { } } +/** + * Marks ONE ranged time control carrying BOTH endpoints: + * `` — the pair replaces + * the two single `rangeStart`/`rangeEnd` leaves (the add-dialog / table + * TIME-column shape). Propagation stays per-endpoint: the control's + * `saved.side` dispatches to the same start/end commit laws. The control + * rolls and badges internally already — the group's re-anchor/roll is + * idempotent over a settled pair (that is what `dayOverflow`/ + * `explicitDay` are carried FOR). Receives the group's range errors. + */ +@Directive({ selector: 'angular-inline-time[rangeTimes]', providers: [provideLeafState(true)] }) +export class RangeTimes { + constructor() { + const group = inject(DateTimeRangeGroup); + const control = inject(AngularInlineTime); + + group.attachTimes(control, leafHasOwnField()); + control.touch.subscribe(() => group.touch.emit()); + control.saved.subscribe((session) => { + if (!session.changed) return; + if (session.side === 'start') group.startCommitted(); + else group.endCommitted(session.dayOverflow, session.explicitDay); + }); + } +} + /** * Marks the group's END-DAY control (the maximal five-field form): * ``. Receives the ordering errors — diff --git a/projects/app/src/app/login/login.ts b/projects/app/src/app/login/login.ts index ac981c9..6775166 100644 --- a/projects/app/src/app/login/login.ts +++ b/projects/app/src/app/login/login.ts @@ -30,7 +30,7 @@ import { AngularInlineDuration, composeDbEntry, localDayOf, - toDbEntry, + dateToDbEntry, } from 'angular-inline-select/temporal'; const phoneCodec = createLibphonenumberCodec(metadata, examples); @@ -95,7 +95,7 @@ export class Login { telephone: '+49301234567', mobile: null, dateOfBirth: null, - dayStart: composeDbEntry(localDayOf(toDbEntry(new Date()))!, '06:30'), + dayStart: composeDbEntry(localDayOf(dateToDbEntry(new Date()))!, '06:30'), focusTime: null, }); From db7eeca2acc5301edd9cb0e7a1f2732e88feb258 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Fri, 10 Jul 2026 13:58:37 +0200 Subject: [PATCH 36/48] refactor(core): further concept aligment between sandbox and absorber --- ROADMAP.md | 25 +++++++++++ .../phone/src/angular-inline-phone.spec.ts | 8 ++-- .../phone/src/angular-inline-phone.ts | 18 ++++---- .../angular-inline-number.spec.ts | 6 +-- .../angular-inline-number.ts | 16 ++++--- .../angular-inline-text.spec.ts | 6 +-- .../angular-inline-text.ts | 22 ++++----- .../angular-inline-date.spec.ts | 19 +++++--- .../angular-inline-date.ts | 37 ++++++++++++--- .../src/angular-inline-date/date-codec.ts | 12 +++++ .../angular-inline-duration.spec.ts | 15 +++++-- .../angular-inline-duration.ts | 34 +++++++++++--- .../angular-inline-duration/duration-codec.ts | 35 +++++++++++++++ .../angular-inline-time.spec.ts | 27 ++++++++--- .../angular-inline-time.ts | 45 +++++++++++++++---- .../src/angular-inline-time/time-codec.ts | 14 ++++++ projects/app/src/app/app.html | 2 +- .../text-playground/text-playground.html | 8 ++-- 18 files changed, 271 insertions(+), 78 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 431732c..306b752 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -533,6 +533,31 @@ back), the default keeps the Intl-localized display; `composeDbEntry` already composed seconds. Still open with iusta: the details-payload `savedModelChange` convergence question. +**THE savedModelChange STANDARD (user decisions 2026-07-10, sandbox +reference implementation shipped same day — 210 lib + 2 app specs, both +builds clean, Luxon still lazy-chunk-only):** `savedModelChange` is the +consumer DNA — it emits THE MODEL as an OBJECT, accept-timed and +change-gated, on EVERY changed settlement (the sandbox cadence won: +half-open range states are real commits; iusta's only-when-complete +date cadence was a per-control accident, migrated at its next sweep). +Scalar controls emit `{ value: T }` (text `{value: string}`, number +`{value: number|null}`, phone `{value: E.164|null}`); temporal controls +emit the iusta details models VERBATIM — `TimeSavedDetails` +`{start, end, duration}` and `DateSavedDetails` `{start, end}` (Luxon, +sides nullable — widened for the cadence), `DurationSavedDetails` +(`.duration` + clock decomposition, empty IS zero) — types live beside +their codecs, derived in `#emitSavedModel()` per control. The value +channel stays plain strings (form-serializable); the event is the Luxon +rendering. **`saved` is the MACHINERY channel** — one emission per +settled session, changed or not, carrying commit intent +(`side`/`dayOverflow`/`explicitDay`); range groups and hosting adapters +bind it, app consumers bind `savedModelChange`. NEXT (iusta): the +consumer migration — ~98 scalar sites `$event` → `$event.value` +(compiler-guided), temporal `#emitLegacyDetails` unmarks into the shared +implementation, date-v2 adopts the every-changed-settlement cadence +(manual audit of the few ranged consumers — `updateAttribute(…, any)` +hides null-hazards from the compiler). + **The temporal program's upstream design record was ROADMAP-DATETIME.md (retired — recover via `git show 8063fb6:ROADMAP-DATETIME.md`); the LIVE absorption log is iusta's `EDITABLES-ABSORPTION-ROADMAP.md`. That record diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts index 489c757..5da030c 100644 --- a/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts @@ -33,7 +33,7 @@ class PhoneValueHost { codec = codec; value = signal('+491712345678'); - saved: (string | null)[] = []; + saved: { value: string | null }[] = []; sessions: InlinePhoneSaved[] = []; touchCount = 0; } @@ -140,7 +140,7 @@ describe('AngularInlinePhone — [(value)] binding', () => { accept(h); expect(h.host.value()).toBe('+491709876543'); - expect(h.host.saved).toEqual(['+491709876543']); + expect(h.host.saved).toEqual([{ value: '+491709876543' }]); expect(h.display().textContent).toBe('+49 170 9876543'); }); @@ -162,7 +162,7 @@ describe('AngularInlinePhone — [(value)] binding', () => { accept(h); expect(h.inner().editing()).toBe(false); - expect(h.host.saved).toEqual(['+49017']); + expect(h.host.saved).toEqual([{ value: '+49017' }]); expect(h.host.sessions).toEqual([{ value: '+49017', changed: true }]); }); @@ -227,7 +227,7 @@ describe('AngularInlinePhone — [(value)] binding', () => { // National number kept, calling code swapped, committed immediately expect(h.host.value()).toBe('+433049781234'); - expect(h.host.saved).toEqual(['+433049781234']); + expect(h.host.saved).toEqual([{ value: '+433049781234' }]); expect(h.host.sessions).toEqual([{ value: '+433049781234', changed: true }]); }); diff --git a/projects/angular-inline-select/phone/src/angular-inline-phone.ts b/projects/angular-inline-select/phone/src/angular-inline-phone.ts index e169693..0b2c00f 100644 --- a/projects/angular-inline-select/phone/src/angular-inline-phone.ts +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.ts @@ -197,14 +197,16 @@ export class AngularInlinePhone implements FormValueControl { touch = output(); /** - * Hard commit event: fires once per accepted edit session — always E.164 - * or `null`, never raw input. - * - * Roadmap Phase 3: superseded by `saved` — kept during the transition. + * THE consumer commit event — fires once per changed settlement with the + * MODEL: `{ value }`, always E.164 or `null` inside, never raw input. */ - savedModelChange = output(); + savedModelChange = output<{ value: string | null }>(); - /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + /** + * The MACHINERY channel: exactly one emission per settled edit session + * (Save, Discard, clear — changed or not). Adapters/wrappers bind this; + * app consumers should bind `savedModelChange`. + */ saved = output(); /** The canonical (E.164) reading of the model. */ @@ -331,7 +333,7 @@ export class AngularInlinePhone implements FormValueControl { if (session.changed) { this.value.set(value); - this.savedModelChange.emit(value); + this.savedModelChange.emit({ value }); } this.saved.emit({ value, changed: session.changed }); @@ -501,7 +503,7 @@ export class AngularInlinePhone implements FormValueControl { const e164 = `+${option.dialCode}${nsn}`; if (e164 !== base) { this.value.set(e164); - this.savedModelChange.emit(e164); + this.savedModelChange.emit({ value: e164 }); this.saved.emit({ value: e164, changed: true }); } } else { diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts index 3a1c8e0..9200384 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts @@ -29,7 +29,7 @@ import { EditableSuffix } from '../angular-inline-text/editable-affix'; class NumberValueHost { value = signal(42); - saved: (number | null)[] = []; + saved: { value: number | null }[] = []; sessions: InlineNumberSaved[] = []; touchCount = 0; } @@ -178,7 +178,7 @@ describe('AngularInlineNumber — [(value)] binding', () => { accept(h); expect(h.host.value()).toBeNull(); - expect(h.host.saved).toEqual([null]); + expect(h.host.saved).toEqual([{ value: null }]); expect(h.host.sessions).toEqual([{ value: null, changed: true }]); }); @@ -187,7 +187,7 @@ describe('AngularInlineNumber — [(value)] binding', () => { accept(h); expect(h.host.value()).toBe(12.5); - expect(h.host.saved).toEqual([12.5]); + expect(h.host.saved).toEqual([{ value: 12.5 }]); // The display shows the canonical formatting, not the raw draft expect(h.display().textContent).toBe('12.5'); }); diff --git a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts index ced8fd3..564ce7b 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts @@ -119,14 +119,16 @@ export class AngularInlineNumber implements FormValueControl(); /** - * Hard commit event: fires once per accepted edit session — always - * `number | null`, never a string. - * - * Roadmap Phase 3: superseded by `saved` — kept during the transition. + * THE consumer commit event — fires once per changed settlement with the + * MODEL: `{ value }`, always `number | null` inside, never a string. */ - savedModelChange = output(); + savedModelChange = output<{ value: number | null }>(); - /** Emitted exactly once per settled edit session (Save, Discard, clear). */ + /** + * The MACHINERY channel: exactly one emission per settled edit session + * (Save, Discard, clear — changed or not). Adapters/wrappers bind this; + * app consumers should bind `savedModelChange`. + */ saved = output(); /** The numeric reading of the (possibly string-typed) model. */ @@ -192,7 +194,7 @@ export class AngularInlineNumber implements FormValueControl { // Edges trimmed, interior spacing and line breaks preserved expect(h.host.value()).toBe('new value \n here'); - expect(h.host.saved).toEqual(['new value \n here']); + expect(h.host.saved).toEqual([{ value: 'new value \n here' }]); expect(h.editable().editing()).toBe(false); }); @@ -362,7 +362,7 @@ describe('AngularInlineText — [(value)] binding', () => { h.fixture.detectChanges(); expect(h.host.value()).toBe(''); - expect(h.host.saved).toEqual(['']); + expect(h.host.saved).toEqual([{ value: '' }]); expect(h.host.sessions).toEqual([{ value: '', changed: true }]); expect(h.host.touchCount).toBe(1); }); diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index a142c60..f32194e 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -206,17 +206,19 @@ export class AngularInlineText implements FormValueControl { reverted = output(); /** - * Hard commit event: fires once per accepted edit session. - * - * Roadmap Phase 3: superseded by `saved` — kept during the transition. + * THE consumer commit event — the family DNA: fires once per changed + * settlement (accept-timed, change-gated) with the MODEL. Scalar controls + * emit `{ value }` — the uniform object payload every editable's + * `savedModelChange` speaks (temporal siblings emit their details models). */ - savedModelChange = output(); + savedModelChange = output<{ value: string }>(); /** - * Emitted exactly once per settled edit session — Save, Discard, and clear - * alike. `changed` says whether the settled value differs from the session - * baseline, so consumers persist iff `changed`. Emitted after - * `savedModelChange`/`reverted`. + * The MACHINERY channel: exactly one emission per settled edit session — + * Save, Discard, and clear alike, changed or not (`changed` says whether + * the settled value differs from the baseline). Wrapping controls and + * hosting adapters bind this; app consumers should bind + * `savedModelChange`. Emitted after `savedModelChange`/`reverted`. */ saved = output(); @@ -696,7 +698,7 @@ export class AngularInlineText implements FormValueControl { // baseline follows on close — `previous` unfreezes with the session. this.value.set(value); - this.savedModelChange.emit(value); + this.savedModelChange.emit({ value }); this.saved.emit({ value, changed: true }); this.close(); } @@ -999,7 +1001,7 @@ export class AngularInlineText implements FormValueControl { if (this.editing()) return; this.value.set(''); - this.savedModelChange.emit(''); + this.savedModelChange.emit({ value: '' }); this.saved.emit({ value: '', changed: true }); this.#selfTouched.set(true); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index 02b0006..88574b3 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -14,6 +14,7 @@ import { echoDateShape, dateValuesEqual, localeDatePlaceholder, + type DateSavedDetails, type InlineDateValue, } from './date-codec'; import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; @@ -22,6 +23,10 @@ import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry' // localized calendar days in front. Expectations compose through the same // helpers, so specs are TZ-independent. const db = dayToDbEntry; + +/** The commit payloads' start sides, back as local days (spec convenience). */ +const savedStartDays = (details: DateSavedDetails[]) => + details.map((d) => d.start?.toFormat('yyyy-MM-dd') ?? null); const dbEnd = dayEndToDbEntry; // A fixed "now" so the specs are deterministic: Tuesday, 12 May 2026. @@ -193,7 +198,7 @@ class DateFormHost { field = form(this.model); now = () => NOW; - saved: InlineDateValue[] = []; + saved: DateSavedDetails[] = []; sessions: InlineDateSaved[] = []; } @@ -217,7 +222,7 @@ class DateShapeHost { placeholder = signal(undefined); now = () => NOW; - saved: InlineDateValue[] = []; + saved: DateSavedDetails[] = []; sessions: InlineDateSaved[] = []; } @@ -319,7 +324,7 @@ describe('AngularInlineDate (input rehost)', () => { press(h, h.start(), 'Enter'); - expect(h.host.saved).toEqual([db('2026-12-24')]); + expect(savedStartDays(h.host.saved)).toEqual(['2026-12-24']); expect(h.host.sessions).toEqual([{ value: db('2026-12-24'), changed: true }]); expect(h.start().value).toBe('Dec 24, 2026'); expect(h.panel()).toBeNull(); @@ -356,7 +361,7 @@ describe('AngularInlineDate (input rehost)', () => { type(h, h.start(), '24.12.2026'); await blurAway(h); - expect(h.host.saved).toEqual([db('2026-12-24')]); + expect(savedStartDays(h.host.saved)).toEqual(['2026-12-24']); expect(h.host.sessions).toEqual([{ value: db('2026-12-24'), changed: true }]); }); @@ -375,7 +380,7 @@ describe('AngularInlineDate (input rehost)', () => { press(h, h.start(), 'Enter'); expect(h.host.field().value()).toBeNull(); - expect(h.host.saved).toEqual([null]); + expect(savedStartDays(h.host.saved)).toEqual([null]); }); it('ArrowDown hands focus to the grid; a pick COMMITS; grid Escape hands it back', async () => { @@ -395,7 +400,7 @@ describe('AngularInlineDate (input rehost)', () => { cell.click(); await settle(h); - expect(h.host.saved).toEqual([db('2026-05-20')]); + expect(savedStartDays(h.host.saved)).toEqual(['2026-05-20']); expect(h.start().value).toBe('May 20, 2026'); expect(h.panel()).toBeNull(); expect(document.activeElement).toBe(h.start()); @@ -410,7 +415,7 @@ describe('AngularInlineDate (input rehost)', () => { (chips[2] as HTMLElement).click(); // tomorrow await settle(h); - expect(h.host.saved).toEqual([db('2026-05-13')]); + expect(savedStartDays(h.host.saved)).toEqual(['2026-05-13']); }); }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts index 6246488..496b6b5 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -47,13 +47,14 @@ import { dateValuesEqual, localeDatePlaceholder, type DateCommand, + type DateSavedDetails, type IsoDate, type InlineDateValue, type DateValueShape, type InternalDateRange, } from './date-codec'; import { INLINE_TEMPORAL_BUBBLE_SIDE, INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; -import { dayToDbEntry, dayEndToDbEntry, localDayOf } from '../datetime/db-entry'; +import { dayToDbEntry, dayEndToDbEntry, localDayOf, toDateTime } from '../datetime/db-entry'; import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; import { makeSideSessionChrome, @@ -286,10 +287,19 @@ export class AngularInlineDate implements FormValueControl { /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per changed settlement, in the bound shape. */ - savedModelChange = output(); + /** + * THE consumer commit event — the family DNA: fires once per changed + * settlement (accept-timed, change-gated) with the date MODEL as Luxon + * details (`DateSavedDetails`; single mode always carries `end: null`). + * App code binds this; the raw bound value still flows through `value`. + */ + savedModelChange = output(); - /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ + /** + * The MACHINERY channel: exactly one emission per settled session (commit, + * snap-back, Escape, clear — changed or not). Range groups and hosting + * adapters bind this; app consumers should bind `savedModelChange`. + */ saved = output(); /** Whether an edit session is open (= focus is within). Two-way bindable. */ @@ -651,10 +661,23 @@ export class AngularInlineDate implements FormValueControl { this.touch.emit(); const value = this.value(); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed }); } + /** + * The commit payload — the date MODEL as Luxon days (iusta's house + * derivation; local midnights). Single mode always carries `end: null`; + * the start-only shape reports its single-day range `[start, start]`. + */ + #emitSavedModel() { + const { start, end } = this.internalRange(); + this.savedModelChange.emit({ + start: toDateTime(start), + end: this.twoFields() ? toDateTime(end) : null, + }); + } + // -- Keyboard ------------------------------------------------------------------- protected handleInputKeydown(key: SideKey, event: KeyboardEvent) { @@ -756,7 +779,7 @@ export class AngularInlineDate implements FormValueControl { const changed = !dateValuesEqual(value, before); this.#selfTouched.set(true); this.touch.emit(); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed }); } @@ -873,7 +896,7 @@ export class AngularInlineDate implements FormValueControl { const value = this.value(); const changed = !dateValuesEqual(value, before); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed }); } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts index 361e6b7..30588f6 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts @@ -23,6 +23,18 @@ export interface IsoDateRange { */ export type InlineDateValue = IsoDate | IsoDateRange | null; +/** + * The `savedModelChange` payload — the date MODEL (iusta's house shape, + * sides widened to nullable: the change-gated cadence reports EVERY model + * change, so half-open and cleared states carry `null` sides). Single mode + * always carries `end: null`. The value channel stays plain strings — this + * event is the Luxon rendering. + */ +export interface DateSavedDetails { + start: DateTime | null; + end: DateTime | null; +} + /** The shape a non-null value declares; `null` declares nothing (shape-ambiguous). */ export type DateValueShape = 'single' | 'start-only' | 'range'; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts index d8c709d..13dfb7f 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts @@ -3,7 +3,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormField, form } from '@angular/forms/signals'; import { AngularInlineDuration, type InlineDurationSaved } from './angular-inline-duration'; -import { parseDuration, formatDuration, describeDuration } from './duration-codec'; +import { + parseDuration, + formatDuration, + describeDuration, + type DurationSavedDetails, +} from './duration-codec'; // ============================================================================= // Codec @@ -63,7 +68,7 @@ class DurationFormHost { model = signal(5400); field = form(this.model); - saved: (number | null)[] = []; + saved: DurationSavedDetails[] = []; sessions: InlineDurationSaved[] = []; } @@ -130,7 +135,11 @@ describe('AngularInlineDuration (input rehost)', () => { type(h, '2h 15m'); press(h, 'Enter'); - expect(h.host.saved).toEqual([8100]); + expect(h.host.saved.map((d) => d.duration)).toEqual([8100]); + // The details decomposition rides along (2 h 15 min, zero-padded). + expect(h.host.saved[0]).toEqual( + expect.objectContaining({ hour: 2, minute: 15, second: 0, hourString: '02' }), + ); expect(h.host.sessions).toEqual([{ value: 8100, changed: true }]); expect(h.input().value).toBe('02:15'); // commits round-trip the codec }); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts index 0105149..7ba534d 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -30,7 +30,13 @@ import { BubbleMenu, EditableClearButton, } from 'angular-inline-select'; -import { parseDuration, formatDuration, type DurationFormat } from './duration-codec'; +import { + parseDuration, + formatDuration, + timeDetailsFromSeconds, + type DurationFormat, + type DurationSavedDetails, +} from './duration-codec'; import { INLINE_TEMPORAL_BUBBLE_SIDE, INLINE_TEMPORAL_LEAF_STATE } from '../leaf-state'; /** Payload of the `saved` output: one emission per settled edit session. */ @@ -145,10 +151,20 @@ export class AngularInlineDuration implements FormValueControl { /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per changed settlement — seconds or `null`. */ - savedModelChange = output(); + /** + * THE consumer commit event — the family DNA: fires once per changed + * settlement (accept-timed, change-gated) with the duration MODEL as a + * details object (`DurationSavedDetails` — consumers read `.duration`; + * empty/cleared reports zero, iusta's house law). The raw seconds still + * flow through `value`. + */ + savedModelChange = output(); - /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ + /** + * The MACHINERY channel: exactly one emission per settled session (commit, + * snap-back, Escape, clear — changed or not). Range groups and hosting + * adapters bind this; app consumers should bind `savedModelChange`. + */ saved = output(); /** Whether an edit session is open (= focus is within). Two-way bindable. */ @@ -369,10 +385,16 @@ export class AngularInlineDuration implements FormValueControl { this.#selfTouched.set(true); this.touch.emit(); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed }); } + /** The commit payload — total seconds + clock decomposition (empty IS zero). */ + #emitSavedModel() { + const seconds = this.value() ?? 0; + this.savedModelChange.emit({ ...timeDetailsFromSeconds(seconds), duration: seconds }); + } + #announceRevert(value: number | null) { const restored = value === null ? 'empty' : formatDuration(value, this.durationFormat()); this.revertNotice.set(`Reverted to ${restored}`); @@ -446,7 +468,7 @@ export class AngularInlineDuration implements FormValueControl { this.#selfTouched.set(true); this.touch.emit(); - this.savedModelChange.emit(null); + this.#emitSavedModel(); this.saved.emit({ value: null, changed: true }); } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts index d6819d1..6d44fc0 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts @@ -4,9 +4,44 @@ * (empty), unparseable → `undefined` (raises the parse gate). */ +import { Duration } from 'luxon'; + /** How colon notation reads and how values render. */ export type DurationFormat = 'h:mm' | 'h:mm:ss' | 'mm:ss'; +/** + * The `savedModelChange` payload — the duration MODEL (iusta's house shape): + * total seconds plus the clock decomposition, numeric and zero-padded. + * Consumers read `.duration`. An empty/cleared field reports zero. + */ +export interface DurationSavedDetails { + duration: number; + hour: number; + minute: number; + second: number; + hourString: string; + minuteString: string; + secondString: string; +} + +/** The clock decomposition of a second count (iusta's house helper, verbatim). */ +export function timeDetailsFromSeconds(durationInSeconds: number) { + const duration = Duration.fromObject({ seconds: durationInSeconds }).shiftTo( + 'hours', + 'minutes', + 'seconds', + ); + + return { + hour: duration.hours, + minute: duration.minutes, + second: duration.seconds, + hourString: duration.hours.toString().padStart(2, '0'), + minuteString: duration.minutes.toString().padStart(2, '0'), + secondString: duration.seconds.toString().padStart(2, '0'), + }; +} + const UNIT_SECONDS: Record = { h: 3600, m: 60, diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts index ff17a9b..f2d522c 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -3,7 +3,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormField, form } from '@angular/forms/signals'; import { AngularInlineTime, type InlineTimeSaved } from './angular-inline-time'; -import { parseTime, parseTimeDraft, formatWallClock, type InlineTimeValue } from './time-codec'; +import { + parseTime, + parseTimeDraft, + formatWallClock, + type InlineTimeValue, + type TimeSavedDetails, +} from './time-codec'; + +/** The commit payloads' start instants, back as DB entries (spec convenience). */ +const savedStarts = (details: TimeSavedDetails[]) => + details.map((d) => d.start?.toUTC().toISO() ?? null); import { composeDbEntry, dayToDbEntry, @@ -201,7 +211,7 @@ class TimeFormHost { field = form(this.model); native = signal(false); - saved: InlineTimeValue[] = []; + saved: TimeSavedDetails[] = []; sessions: InlineTimeSaved[] = []; } @@ -271,7 +281,10 @@ describe('AngularInlineTime (input rehost)', () => { type(h, '2105'); press(h, 'Enter'); - expect(h.host.saved).toEqual([at('21:05')]); + expect(savedStarts(h.host.saved)).toEqual([at('21:05')]); + // Single mode pins the details shape: Luxon start, no end, zero duration. + expect(h.host.saved[0].end).toBeNull(); + expect(h.host.saved[0].duration).toBe(0); expect(h.host.sessions).toEqual([ { value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, ]); @@ -306,7 +319,7 @@ describe('AngularInlineTime (input rehost)', () => { type(h, '2105'); await blurAway(h); - expect(h.host.saved).toEqual([at('21:05')]); + expect(savedStarts(h.host.saved)).toEqual([at('21:05')]); expect(h.host.sessions).toEqual([ { value: at('21:05'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, ]); @@ -358,7 +371,7 @@ describe('AngularInlineTime (input rehost)', () => { h.fixture.detectChanges(); expect(h.host.model()).toBe(at('14:45')); - expect(h.host.saved).toEqual([at('14:45')]); + expect(savedStarts(h.host.saved)).toEqual([at('14:45')]); expect(h.host.sessions).toEqual([ { value: at('14:45'), changed: true, dayOverflow: 0, explicitDay: false, side: 'start' }, ]); @@ -379,7 +392,7 @@ describe('AngularInlineTime (input rehost)', () => { expect(h.host.field().value()).toBe(at('10:15')); // live channel press(h, 'Enter'); - expect(h.host.saved).toEqual([at('10:15')]); + expect(savedStarts(h.host.saved)).toEqual([at('10:15')]); }); it('an overflow draft commits onto the anchor day + n', () => { @@ -470,7 +483,7 @@ class TimeShapeHost { ranged = signal(false); native = signal(false); - saved: InlineTimeValue[] = []; + saved: TimeSavedDetails[] = []; sessions: InlineTimeSaved[] = []; } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts index 355af78..0b00c9f 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -37,6 +37,7 @@ import { timeValuesEqual, type InlineTimeValue, type TimeDraft, + type TimeSavedDetails, type TimeValueShape, type InternalTimeRange, } from './time-codec'; @@ -56,6 +57,7 @@ import { import { addLocalDays, composeDbEntry, + diffDbEntrySeconds, localDayDiff, localDayOf, localTimeOf, @@ -340,10 +342,21 @@ export class AngularInlineTime implements FormValueControl { /** Form Value Contract: touch — emitted whenever a session settles. */ touch = output(); - /** Hard commit event: fires once per changed settlement, in the bound shape. */ - savedModelChange = output(); + /** + * THE consumer commit event — the family DNA: fires once per changed + * settlement (accept-timed, change-gated) with the time MODEL as Luxon + * details (`TimeSavedDetails`). App code binds this; the raw bound value + * still flows through `value`. + */ + savedModelChange = output(); - /** Emitted exactly once per settled session (commit, snap-back, Escape, clear). */ + /** + * The MACHINERY channel: exactly one emission per settled session (commit, + * snap-back, Escape, clear — changed or not), carrying the session's + * commit intent (`side`, `dayOverflow`, `explicitDay`). Range groups and + * hosting adapters bind this; app consumers should bind + * `savedModelChange`. + */ saved = output(); /** Whether an edit session is open (= focus is within). Two-way bindable. */ @@ -709,10 +722,21 @@ export class AngularInlineTime implements FormValueControl { this.touch.emit(); const value = this.value(); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed, dayOverflow, explicitDay, side: key }); } + /** The commit payload — Luxon instants + the settled duration (iusta's house derivation). */ + #emitSavedModel() { + const { start, end } = this.internalRange(); + const diff = start !== null && end !== null ? (diffDbEntrySeconds(start, end) ?? 0) : 0; + this.savedModelChange.emit({ + start: toDateTime(start), + end: toDateTime(end), + duration: Math.max(0, diff), + }); + } + // -- Keyboard ----------------------------------------------------------------------- protected handleKeydown(key: SideKey, event: KeyboardEvent) { @@ -827,9 +851,14 @@ export class AngularInlineTime implements FormValueControl { const before = this.value(); this.#reconcile(key, instant, false); if (!timeValuesEqual(this.value(), before)) { - const value = this.value(); - this.savedModelChange.emit(value); - this.saved.emit({ value, changed: true, dayOverflow: 0, explicitDay: false, side: key }); + this.#emitSavedModel(); + this.saved.emit({ + value: this.value(), + changed: true, + dayOverflow: 0, + explicitDay: false, + side: key, + }); } } @@ -877,7 +906,7 @@ export class AngularInlineTime implements FormValueControl { const value = this.value(); const changed = !timeValuesEqual(value, before); - if (changed) this.savedModelChange.emit(value); + if (changed) this.#emitSavedModel(); this.saved.emit({ value, changed, dayOverflow: 0, explicitDay: false, side: key }); } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts index 55ac1cb..a929c16 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts @@ -8,11 +8,25 @@ * codec's `InlineDateValue` machinery. */ +import type { DateTime } from 'luxon'; + import type { DbDateTime } from '../datetime/db-entry'; /** `'HH:mm'`. */ export type WallClockTime = string; +/** + * The `savedModelChange` payload — the time MODEL (iusta's house shape): + * Luxon instants plus the settled duration in seconds. Single mode carries + * `end: null, duration: 0`; a cleared side is `null`. The value channel + * stays plain DB-entry strings — this event is the Luxon rendering. + */ +export interface TimeSavedDetails { + start: DateTime | null; + end: DateTime | null; + duration: number; +} + /** The object shapes of `InlineTimeValue`: a missing `end` key is a HALF-OPEN range. */ export interface DbTimeRange { start: DbDateTime | null; diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html index 00b12fb..5270aa7 100644 --- a/projects/app/src/app/app.html +++ b/projects/app/src/app/app.html @@ -3,7 +3,7 @@ class="app-title" [isSingleLine]="true" [value]="title()" - (savedModelChange)="title.set($event)" + (savedModelChange)="title.set($event.value)" placeholder="Untitled" [normalizeValue]="true" /> diff --git a/projects/app/src/app/pages/text-playground/text-playground.html b/projects/app/src/app/pages/text-playground/text-playground.html index 4f4805d..8d5954a 100644 --- a/projects/app/src/app/pages/text-playground/text-playground.html +++ b/projects/app/src/app/pages/text-playground/text-playground.html @@ -28,7 +28,7 @@

Inline text in a paragraph

@@ -40,7 +40,7 @@

Inline text in a paragraph

class="prose-block" [value]="summary()" [normalizeValue]="summaryNormalize()" - (savedModelChange)="summary.set($event)" + (savedModelChange)="summary.set($event.value)" placeholder="Add a description…" /> @@ -140,7 +140,7 @@

Inline text in a table

@@ -150,7 +150,7 @@

Inline text in a table

Notes - + From 5e83121a4e5b63ade29a3bc9825904a19c76c729 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Fri, 10 Jul 2026 15:31:24 +0200 Subject: [PATCH 37/48] feat(RanGroup): added range group concept --- .claude/launch.json | 2 +- ROADMAP.md | 25 + .../src/range-group/range-group.spec.ts | 145 +++ .../temporal/src/range-group/range-group.ts | 1130 +++++++++++------ .../temporal-playground/_model-table.scss | 32 + .../date-card/date-card.html | 75 ++ .../date-card/date-card.scss | 7 + .../date-card/date-card.spec.ts | 22 + .../date-card/date-card.ts | 67 + .../duration-card/duration-card.html | 39 + .../duration-card/duration-card.scss | 7 + .../duration-card/duration-card.spec.ts | 22 + .../duration-card/duration-card.ts | 38 + .../mat-baseline-card/mat-baseline-card.html | 28 + .../mat-baseline-card/mat-baseline-card.scss | 19 + .../mat-baseline-card.spec.ts | 22 + .../mat-baseline-card/mat-baseline-card.ts | 21 + .../mat-quartet-card/mat-quartet-card.html | 73 ++ .../mat-quartet-card/mat-quartet-card.scss | 19 + .../mat-quartet-card/mat-quartet-card.spec.ts | 22 + .../mat-quartet-card/mat-quartet-card.ts | 77 ++ .../mat-table-card/mat-table-card.html | 52 + .../mat-table-card/mat-table-card.scss | 15 + .../mat-table-card/mat-table-card.ts | 88 ++ .../quartet-card/quartet-card.html | 87 ++ .../quartet-card/quartet-card.scss | 7 + .../quartet-card/quartet-card.spec.ts | 22 + .../quartet-card/quartet-card.ts | 73 ++ .../quartet-table-card.html | 58 + .../quartet-table-card.scss | 28 + .../quartet-table-card.spec.ts | 22 + .../quartet-table-card/quartet-table-card.ts | 108 ++ .../temporal-playground.html | 437 +------ .../temporal-playground.scss | 66 - .../temporal-playground.ts | 207 +-- .../time-card/time-card.html | 63 + .../time-card/time-card.scss | 7 + .../time-card/time-card.spec.ts | 22 + .../time-card/time-card.ts | 61 + 39 files changed, 2260 insertions(+), 1055 deletions(-) create mode 100644 projects/app/src/app/pages/temporal-playground/_model-table.scss create mode 100644 projects/app/src/app/pages/temporal-playground/date-card/date-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/date-card/date-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/date-card/date-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/date-card/date-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/duration-card/duration-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/duration-card/duration-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/duration-card/duration-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/duration-card/duration-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.ts create mode 100644 projects/app/src/app/pages/temporal-playground/time-card/time-card.html create mode 100644 projects/app/src/app/pages/temporal-playground/time-card/time-card.scss create mode 100644 projects/app/src/app/pages/temporal-playground/time-card/time-card.spec.ts create mode 100644 projects/app/src/app/pages/temporal-playground/time-card/time-card.ts diff --git a/.claude/launch.json b/.claude/launch.json index 70f985c..17ea6db 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,7 @@ "name": "app", "runtimeExecutable": "/Users/hongknop/.nvm/versions/node/v26.1.0/bin/node", "runtimeArgs": [ - "/private/tmp/claude-501/-Users-hongknop-Documents-private-repo-angular-inline-select/3d95d03c-6aa5-4117-8eaa-15a6e648d7a6/scratchpad/static-server.mjs" + "/private/tmp/claude-501/-Users-hongknop-Documents-private-repo-angular-inline-select/ea149141-dbd9-44c1-ad8d-37c7afcb4f07/scratchpad/static-server.mjs" ], "port": 4202, "autoPort": true diff --git a/ROADMAP.md b/ROADMAP.md index 306b752..db18840 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -558,6 +558,31 @@ implementation, date-v2 adopts the every-changed-settlement cadence (manual audit of the few ranged consumers — `updateAttribute(…, any)` hides null-hazards from the compiler). +**THE HEADLESS GROUP SHIPPED (2026-07-10, suite at 214):** +`createTemporalRangeGroup()` — the range-group laws as a PLAIN FACTORY +(closures over signals; no directive, no OOP), living wherever the caller +puts it (typically ROW DATA). `DateTimeRangeGroup` is now a THIN SHELL: +it builds the core with its `value` model/zone/bound-ness and forwards +`onChanges` deltas to its outputs — the laws exist ONCE. The role +attributes went DUAL-MODE: bare = DI to the ancestor directive (exactly +as before, synchronous attach so the mixed-mode guard still throws); +BOUND = a headless group by reference (`[rangeDay]="row.group"`, +`[rangeTimes]="row.group"` …) — which makes `matColumnDef`'s DI scoping +irrelevant, THE mat-table case. Traps paid for: (1) leaf-state/day-offset +providers must NOT inject the role directive (control constructs → token +→ role → control = NG0200) — they read a per-element `RANGE_ROLE_CORE` +holder signal the role's wiring fills; (2) by-reference attach happens an +effect-flush AFTER the factory's first inbound push, so the inbound +effect depends on the attachment signals (late leaf receives the current +value) and the OUTBOUND mirror is gated on `anyAttached` (an empty +composed reading must not clobber a seeded value). Factory requires an +injection context (or `options.injector`). Playground: mat-table card +(3 shifts incl. overnight) with per-row headless groups — iusta's +time-entry-table constraint, live. 4 headless specs (per-row isolation, +end→duration, duration→end, day shift). NEXT: mirror to iusta, then its +table rows adopt row-data groups and `prepareTimeData`'s hand +propagation dissolves. + **The temporal program's upstream design record was ROADMAP-DATETIME.md (retired — recover via `git show 8063fb6:ROADMAP-DATETIME.md`); the LIVE absorption log is iusta's `EDITABLES-ABSORPTION-ROADMAP.md`. That record diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts index b86b468..6ed1421 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts @@ -14,8 +14,10 @@ import { RangeEnd, RangeTimes, RangeLength, + createTemporalRangeGroup, type ComposedDateRange, type ComposedTimeRange, + type TemporalRangeGroup, type TemporalRangeValue, } from './range-group'; @@ -628,3 +630,146 @@ describe('DateTimeRangeGroup with the rangeTimes pair (the trio)', () => { ]); }); }); + +// ============================================================================= +// The HEADLESS group — by-reference roles, NO DI directive in the ancestry +// (the mat-table case: matColumnDef scoping makes a per-row DI group +// impossible; the group lives on ROW DATA instead) +// ============================================================================= + +interface HeadlessRow { + id: number; + group: TemporalRangeGroup; + commits: (TemporalRangeValue | null)[]; +} + +@Component({ + imports: [ + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + RangeDay, + RangeTimes, + RangeLength, + ], + template: ` + @for (row of rows; track row.id) { +
+ + + +
+ } + `, +}) +class HeadlessRowsHost { + now = () => NOW; + + // Field initializer = injection context; the factory registers its effects. + rows: HeadlessRow[] = [1, 2].map((id) => { + const commits: (TemporalRangeValue | null)[] = []; + const group = createTemporalRangeGroup({ + value: signal({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-22', '06:00'), + duration: 32_400, + }), + onChanges: (changes) => commits.push(changes.composed), + }); + return { id, group, commits }; + }); +} + +describe('createTemporalRangeGroup (headless, by-reference roles)', () => { + // Per row: 0 day · 1 pair start · 2 pair end · 3 length. + function setupRows() { + const fixture = TestBed.createComponent(HeadlessRowsHost); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + inputs: (row: number) => + [ + ...fixture.nativeElement + .querySelectorAll('.headless-row') + [row].querySelectorAll(LEAF_INPUTS), + ] as HTMLInputElement[], + }; + } + + it('each row group pushes its seed into ITS leaves — no DI, no cross-talk', async () => { + const h = setupRows(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + for (const row of [0, 1]) { + expect(h.inputs(row).map((input) => input.value)).toEqual([ + 'Jul 21, 2026', + '21:00', + '06:00', + '09:00', + ]); + } + }); + + it('a typed pair END commits into ITS row only — duration follows, the sibling stands', async () => { + const h = setupRows(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, () => h.inputs(0), 2, '23:30'); + + expect(h.host.rows[0].group.value()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:30'), + duration: 2.5 * 3600, + }); + expect(h.host.rows[0].commits).toEqual([ + { start: at('2026-07-21', '21:00'), end: at('2026-07-21', '23:30'), duration: 2.5 * 3600 }, + ]); + + // The sibling row is untouched — the groups are per-row state. + expect(h.host.rows[1].group.value()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-22', '06:00'), + duration: 32_400, + }); + expect(h.host.rows[1].commits).toEqual([]); + }); + + it('a duration commit MOVES the pair end (the law, headless)', async () => { + const h = setupRows(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, () => h.inputs(1), 3, '2:00'); + + expect(h.host.rows[1].group.value()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:00'), + duration: 2 * 3600, + }); + expect(h.inputs(1)[2].value).toBe('23:00'); + }); + + it('a day commit shifts BOTH pair instants, wall clocks preserved', async () => { + const h = setupRows(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, () => h.inputs(0), 0, '24.7.2026'); + + expect(h.host.rows[0].group.value()).toEqual({ + start: at('2026-07-24', '21:00'), + end: at('2026-07-25', '06:00'), + duration: 32_400, + }); + expect(h.inputs(0).map((input) => input.value)).toEqual([ + 'Jul 24, 2026', + '21:00', + '06:00', + '09:00', + ]); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/range-group/range-group.ts b/projects/angular-inline-select/temporal/src/range-group/range-group.ts index 5bdbe7a..c6ef776 100644 --- a/projects/angular-inline-select/temporal/src/range-group/range-group.ts +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.ts @@ -1,5 +1,7 @@ import { Directive, + InjectionToken, + Injector, computed, effect, inject, @@ -9,6 +11,8 @@ import { output, signal, untracked, + type Signal, + type WritableSignal, } from '@angular/core'; import { FormField, type ValidationError } from '@angular/forms/signals'; @@ -83,12 +87,98 @@ function sameTemporalValue( const NO_ERRORS = signal([]).asReadonly(); +// ============================================================================= +// The HEADLESS core — the laws as a plain factory (no directive, no OOP) +// ============================================================================= + +/** + * One commit propagation's outcome — handed to `onChanges` exactly once per + * settled leaf session that moved ANY composed value. Each `*Changed` flag + * says whether THAT stream moved; `composed` is the settled group value. + */ +export interface TemporalRangeChanges { + dateRange: ComposedDateRange | null; + dateRangeChanged: boolean; + timeRange: ComposedTimeRange | null; + timeRangeChanged: boolean; + duration: number | null; + durationChanged: boolean; + composed: TemporalRangeValue | null; +} + +export interface TemporalRangeGroupOptions { + /** + * Bring your own value channel (the directive passes its `value` model); + * omitted, the group owns a fresh `signal(null)` — read/write it via + * `group.value`. + */ + value?: WritableSignal; + /** The display zone the day arithmetic runs in (a signal or thunk). */ + zone?: () => string | undefined; + /** + * Form-BOUND mode: inbound `null` values push down (a bound field's null + * is a real clear). Unbound (the default), `null` is silence — per-leaf + * legacy setups stay untouched. + */ + bound?: boolean; + /** Fired once per commit propagation that changed any composed value. */ + onChanges?: (changes: TemporalRangeChanges) => void; + /** + * The factory registers `effect`s — call it in an injection context or + * pass the injector explicitly (row data built outside one, e.g. in a + * resource loader). + */ + injector?: Injector; +} + +/** + * The headless range group: `createTemporalRangeGroup()`'s return — the + * SAME laws as the `dateTimeRangeGroup` directive, detached from DI so it + * can live on ROW DATA. Leaves connect BY REFERENCE through the role + * attributes (`[rangeDay]="row.group"` …), which makes `matColumnDef`'s + * DI scoping irrelevant — the mat-table case the directive cannot serve. + */ +export interface TemporalRangeGroup { + /** The group's value channel (the composed `{ start, end, duration? }`). */ + readonly value: WritableSignal; + + // Live readings off the attached leaves. + readonly day: Signal; + readonly endDay: Signal; + readonly start: Signal; + readonly end: Signal; + readonly length: Signal; + readonly endDayOffset: Signal; + readonly dateRange: Signal; + readonly timeRange: Signal; + readonly composedValue: Signal; + readonly orderingErrors: Signal; + + // Attachment (the role directives call these; by reference, no DI). + attachDay(control: AngularInlineDate): void; + attachEndDay(control: AngularInlineDate): void; + attachStart(control: AngularInlineTime): void; + attachEnd(control: AngularInlineTime): void; + attachTimes(control: AngularInlineTime): void; + attachLength(control: AngularInlineDuration): void; + + // The commit laws (dispatched from the leaves' `saved` sessions). + dayCommitted(): void; + startCommitted(): void; + endCommitted(dayOverflow?: number, explicitDay?: boolean): void; + endDayCommitted(): void; + lengthCommitted(): void; +} + /** - * T5's group core: links SEPARATE temporal controls — a date (`rangeDay`), - * two times (`rangeStart`/`rangeEnd`) and a duration (`rangeLength`) — via - * DI. Every value is a UTC ISO DB entry (iusta's `toDBEntry`), so the - * datetimes are REAL and the invariants are plain arithmetic — the sandbox - * mirror of iusta's `shiftFromDuration`/`induceFromTimeRange`: + * The group core as a PLAIN FACTORY — signals + attach/commit functions, + * living wherever the caller puts it (typically on row data). Links + * SEPARATE temporal controls — a date (`rangeDay`), two times + * (`rangeStart`/`rangeEnd`) or ONE ranged pair (`rangeTimes`), and a + * duration (`rangeLength`). Every value is a UTC ISO DB entry (iusta's + * `toDBEntry`), so the datetimes are REAL and the invariants are plain + * arithmetic — the mirror of iusta's `shiftFromDuration`/ + * `induceFromTimeRange`: * * - Committing a start or end induces the duration (`end − start`); an end * instant at or before the start rolls forward by whole days until it @@ -97,103 +187,80 @@ const NO_ERRORS = signal([]).a * - Committing the day shifts BOTH times onto it, preserving wall-clock * times and the end's day over-count. * - The `+n` badge on the end field = the LOCAL calendar-day difference - * between the two instants — derived presentation, fed through - * `INLINE_TIME_DAY_OFFSET`, never state of its own. - * - Propagation happens on COMMIT (`saved`), never on live keystrokes: - * writes through `value` don't emit `saved`, so no cascades or cycles. - * - * Deliberately still open here (see ROADMAP-DATETIME): Tab-advance - * start → end, ISO-datetime paste decomposition, the calendar drag / - * Ctrl+click gestures (need T2), and the maximal end-day field. + * between the two instants — derived presentation, never state. + * - Propagation happens on COMMIT (the leaves' `saved` sessions carry the + * intent — `dayOverflow`/`explicitDay` — that values alone don't), never + * on live keystrokes: writes through `value` don't emit `saved`, so no + * cascades or cycles. */ -@Directive({ - selector: '[dateTimeRangeGroup]', - exportAs: 'dateTimeRangeGroup', -}) -export class DateTimeRangeGroup { - #day = signal(null); - #endDay = signal(null); - #start = signal(null); - #end = signal(null); +export function createTemporalRangeGroup( + options: TemporalRangeGroupOptions = {}, +): TemporalRangeGroup { + const injector = options.injector ?? inject(Injector); + const value = options.value ?? signal(null); + const zone = options.zone ?? (() => undefined); + const bound = options.bound ?? false; + + const dayCtl = signal(null); + const endDayCtl = signal(null); + const startCtl = signal(null); + const endCtl = signal(null); /** ONE ranged time control carrying BOTH endpoints (the `rangeTimes` role). */ - #times = signal(null); - #length = signal(null); - - /** Present when the GROUP carries the `[formField]` — form-bound mode. */ - #ownField = inject(FormField, { optional: true, self: true }); - - /** - * T6 — the DISPLAY ZONE the group's day arithmetic runs in. MUST agree - * with the leaves' zones: set it once via `provideInlineTemporalZone` - * (both group and leaves fall back to the token), or set the input on - * the group AND every leaf. - */ - zone = input(undefined); - - #zoneDefault = inject(INLINE_TEMPORAL_ZONE, { optional: true }); - - readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); - - /** - * The group's OWN value channel. Outbound it always mirrors the composed - * leaves (harmless when nobody listens); inbound it only pushes down when - * the group is form-bound or the value is non-null — a `null` on an - * unbound group is silence, not a clear, so per-leaf-bound legacy setups - * stay untouched. - */ - value = model(null); - - /** Form Value Contract — forwarded down to the leaves via role-provided state. */ - errors = input([]); - disabled = input(false); - readonly = input(false); - touched = input(false); - invalid = input(false); - - /** Form Value Contract: touch — any leaf touching bubbles up as the group's. */ - touch = output(); - - /** One commit event for the whole range: the composed `{start, end, duration?}`. */ - savedModelChange = output(); + const timesCtl = signal(null); + const lengthCtl = signal(null); /** * `duration` shape memory (the date control's `#lastShape` pattern): * a non-null bound value declares whether the key participates; `null` * remembers; cold start includes it (the `DomainResult['model']` shape). */ - #durationInShape = linkedSignal({ - source: this.value, - computation: (value, previous) => - value === null ? (previous?.value ?? true) : 'duration' in value, - }); - - /** The composed domain value read live off the leaves, in the echoed shape. */ - readonly composedValue = computed(() => { - const start = this.start(); - const end = this.end(); - const duration = this.length(); - if (start === null && end === null && duration === null) return null; - - return this.#durationInShape() ? { start, end, duration } : { start, end }; + const durationInShape = linkedSignal({ + source: value, + computation: (current, previous) => + current === null ? (previous?.value ?? true) : 'duration' in current, }); - /** The stay's LOCAL calendar day, read off the date control. */ - readonly day = computed(() => { - const control = this.#day(); + const day = computed(() => { + const control = dayCtl(); if (!control) return null; const start = toInternalRange(control.value()).start; - return start === null ? null : localDayOf(start, this.effectiveZone()); + return start === null ? null : localDayOf(start, zone()); }); - /** The END's LOCAL calendar day, read off the end-day control (T5 maximal form). */ - readonly endDay = computed(() => { - const control = this.#endDay(); + /** The END's LOCAL calendar day, read off the end-day control (the maximal form). */ + const endDay = computed(() => { + const control = endDayCtl(); if (!control) return null; const start = toInternalRange(control.value()).start; - return start === null ? null : localDayOf(start, this.effectiveZone()); + return start === null ? null : localDayOf(start, zone()); + }); + + /** + * The endpoint instants and duration, read live off the controls. Two + * SINGLE-shape time leaves (`rangeStart`/`rangeEnd`) or ONE ranged pair + * (`rangeTimes`) — read through the internal model, like `rangeDay` does. + */ + const start = computed( + () => startCtl()?.internalRange().start ?? timesCtl()?.internalRange().start ?? null, + ); + const end = computed( + () => endCtl()?.internalRange().start ?? timesCtl()?.internalRange().end ?? null, + ); + const length = computed(() => lengthCtl()?.value() ?? null); + + /** The composed domain value read live off the leaves, in the echoed shape. */ + const composedValue = computed(() => { + const startValue = start(); + const endValue = end(); + const duration = length(); + if (startValue === null && endValue === null && duration === null) return null; + + return durationInShape() + ? { start: startValue, end: endValue, duration } + : { start: startValue, end: endValue }; }); /** @@ -202,135 +269,115 @@ export class DateTimeRangeGroup { * still roll forward and can't. Routed to the END leaves, revealed by * their own touched machinery. */ - readonly orderingErrors = computed(() => { - const start = this.start(); - const end = this.end(); + const orderingErrors = computed(() => { + const startValue = start(); + const endValue = end(); // DB entries are fixed-width UTC ISO strings — lexicographic order IS // instant order. - if (start !== null && end !== null && end < start) { + if (startValue !== null && endValue !== null && endValue < startValue) { return [{ kind: 'temporal-order', message: 'The end lies before the start.' }]; } return []; }); - /** - * The endpoint instants and duration, read live off the controls. Two - * SINGLE-shape time leaves (`rangeStart`/`rangeEnd`) or ONE ranged pair - * (`rangeTimes`) — read through the internal model, like `rangeDay` does. - */ - readonly start = computed( - () => this.#start()?.internalRange().start ?? this.#times()?.internalRange().start ?? null, - ); - readonly end = computed( - () => this.#end()?.internalRange().start ?? this.#times()?.internalRange().end ?? null, - ); - readonly length = computed(() => this.#length()?.value() ?? null); - /** * The end field's `+n` badge: LOCAL calendar days between the two * instants — intrinsic to the values now that they carry their days. */ - readonly endDayOffset = computed(() => { - const start = this.start(); - if (start === null) return 0; + const endDayOffset = computed(() => { + const startValue = start(); + if (startValue === null) return 0; - const end = this.end(); - if (end !== null) return Math.max(0, localDayDiff(start, end, this.effectiveZone()) ?? 0); + const endValue = end(); + if (endValue !== null) { + return Math.max(0, localDayDiff(startValue, endValue, zone()) ?? 0); + } - const length = this.length(); - if (length !== null) return Math.max(0, localDayDiff(start, shiftDbEntry(start, length), this.effectiveZone()) ?? 0); + const duration = length(); + if (duration !== null) { + return Math.max( + 0, + localDayDiff(startValue, shiftDbEntry(startValue, duration), zone()) ?? 0, + ); + } return 0; }); /** The composed DATE value: day boundaries as DB entries, over-count applied. */ - readonly dateRange = computed(() => { - const zone = this.effectiveZone(); - const startDay = this.day() ?? (this.start() !== null ? localDayOf(this.start(), zone) : null); + const dateRange = computed(() => { + const startDay = day() ?? (start() !== null ? localDayOf(start(), zone()) : null); if (startDay === null) return null; return { - start: dayToDbEntry(startDay, zone), - end: dayEndToDbEntry(addLocalDays(startDay, this.endDayOffset()), zone), + start: dayToDbEntry(startDay, zone()), + end: dayEndToDbEntry(addLocalDays(startDay, endDayOffset()), zone()), }; }); /** The composed TIME value: both endpoint instants, or `null` while incomplete. */ - readonly timeRange = computed(() => { - const start = this.start(); - const end = this.end(); - return start !== null && end !== null ? { start, end } : null; + const timeRange = computed(() => { + const startValue = start(); + const endValue = end(); + return startValue !== null && endValue !== null + ? { start: startValue, end: endValue } + : null; }); - /** - * Composed emissions — the group speaks three values, each fired after - * commit propagation whenever ITS composed value changed: the date range, - * the time range (all UTC ISO DB entries), and the duration (seconds). - */ - dateRangeChange = output(); - timeRangeChange = output(); - durationChange = output(); + // -- Write helpers ----------------------------------------------------------- - // `undefined` = baseline not captured yet (never emitted-against). - #lastDate: ComposedDateRange | null | undefined = undefined; - #lastTime: ComposedTimeRange | null | undefined = undefined; - #lastLength: number | null | undefined = undefined; + function writeStart(next: DbDateTime) { + const control = startCtl(); + if (control && control.value() !== next) control.value.set(next); - constructor() { - // Baseline the composed values once the initial bindings have settled, - // so the first commit emits real deltas, not the seed state. - effect(() => { - const date = this.dateRange(); - const time = this.timeRange(); - const length = this.length(); - - if (this.#lastDate === undefined) { - this.#lastDate = date; - this.#lastTime = time; - this.#lastLength = length; - } - }); - - // The TWO boundary effects of form-bound mode — both equality-guarded, - // both one-directional, converging in a single pass (a group write - // pushes down; the mirror reads back the same values and stops). + const times = timesCtl(); + if (times && times.internalRange().start !== next) { + times.value.set({ start: next, end: times.internalRange().end }); + } + } - // INBOUND: the form's value → the leaf surfaces. - effect(() => { - const value = this.value(); - if (this.#ownField === null && value === null) return; + function writeEnd(next: DbDateTime) { + const control = endCtl(); + if (control && control.value() !== next) control.value.set(next); - untracked(() => this.#pushDown(value)); - }); + const times = timesCtl(); + if (times && times.internalRange().end !== next) { + times.value.set({ start: times.internalRange().start, end: next }); + } + } - // OUTBOUND: the leaves' live values → the group's value (live channel). - effect(() => { - const composed = this.composedValue(); - if (!sameTemporalValue(composed, untracked(this.value))) this.value.set(composed); - }); + function writeLength(next: number | null) { + const control = lengthCtl(); + if (control && control.value() !== next) control.value.set(next); } /** Writes the bound value onto the leaf surfaces (no-ops on equal values). */ - #pushDown(value: TemporalRangeValue | null) { - const start = value?.start ?? null; - const end = value?.end ?? null; + function pushDown(next: TemporalRangeValue | null) { + const startValue = next?.start ?? null; + const endValue = next?.end ?? null; const duration = - value === null + next === null ? null - : value.duration !== undefined - ? value.duration - : start !== null && end !== null - ? diffDbEntrySeconds(start, end) + : next.duration !== undefined + ? next.duration + : startValue !== null && endValue !== null + ? diffDbEntrySeconds(startValue, endValue) : null; - this.#start()?.value.set(start); - this.#end()?.value.set(end); + startCtl()?.value.set(startValue); + endCtl()?.value.set(endValue); // The ranged pair speaks the object shape — both endpoints in one value. - this.#times()?.value.set(start === null && end === null ? null : { start, end }); - this.#length()?.value.set(duration); - this.#day()?.value.set(start === null ? null : dayToDbEntry(localDayOf(start, this.effectiveZone())!, this.effectiveZone())); - this.#endDay()?.value.set(end === null ? null : dayToDbEntry(localDayOf(end, this.effectiveZone())!, this.effectiveZone())); + timesCtl()?.value.set( + startValue === null && endValue === null ? null : { start: startValue, end: endValue }, + ); + lengthCtl()?.value.set(duration); + dayCtl()?.value.set( + startValue === null ? null : dayToDbEntry(localDayOf(startValue, zone())!, zone()), + ); + endDayCtl()?.value.set( + endValue === null ? null : dayToDbEntry(localDayOf(endValue, zone())!, zone()), + ); } /** @@ -339,103 +386,67 @@ export class DateTimeRangeGroup { * multi-day duration, an end-day commit), they re-mirror. Writes go * through `value` (no `saved`), equality-guarded: no cascades. */ - #syncDayLeaves() { - const start = this.start(); - const dayControl = this.#day(); - if (dayControl && start !== null) { - const day = dayToDbEntry(localDayOf(start, this.effectiveZone())!, this.effectiveZone()); - if (!Object.is(dayControl.value(), day)) dayControl.value.set(day); + function syncDayLeaves() { + const startValue = start(); + const dayControl = dayCtl(); + if (dayControl && startValue !== null) { + const next = dayToDbEntry(localDayOf(startValue, zone())!, zone()); + if (!Object.is(dayControl.value(), next)) dayControl.value.set(next); } - const end = this.end(); - const endDayControl = this.#endDay(); - if (endDayControl && end !== null) { - const day = dayToDbEntry(localDayOf(end, this.effectiveZone())!, this.effectiveZone()); - if (!Object.is(endDayControl.value(), day)) endDayControl.value.set(day); + const endValue = end(); + const endDayControl = endDayCtl(); + if (endDayControl && endValue !== null) { + const next = dayToDbEntry(localDayOf(endValue, zone())!, zone()); + if (!Object.is(endDayControl.value(), next)) endDayControl.value.set(next); } } - #emitChanges() { - this.#syncDayLeaves(); + // `undefined` = baseline not captured yet (never emitted-against). + let lastDate: ComposedDateRange | null | undefined = undefined; + let lastTime: ComposedTimeRange | null | undefined = undefined; + let lastLength: number | null | undefined = undefined; - let changed = false; + function emitChanges() { + syncDayLeaves(); - const date = this.dateRange(); - if (this.#lastDate === undefined || !sameRange(date, this.#lastDate)) { - this.#lastDate = date; - this.dateRangeChange.emit(date); - changed = true; - } + const date = dateRange(); + const dateChanged = lastDate === undefined || !sameRange(date, lastDate); + lastDate = date; - const time = this.timeRange(); - if (this.#lastTime === undefined || !sameRange(time, this.#lastTime)) { - this.#lastTime = time; - this.timeRangeChange.emit(time); - changed = true; - } + const time = timeRange(); + const timeChanged = lastTime === undefined || !sameRange(time, lastTime); + lastTime = time; - const length = this.length(); - if (this.#lastLength === undefined || length !== this.#lastLength) { - this.#lastLength = length; - this.durationChange.emit(length); - changed = true; - } + const duration = length(); + const durationChanged = lastLength === undefined || duration !== lastLength; + lastLength = duration; - if (!changed) return; + if (!dateChanged && !timeChanged && !durationChanged) return; // The composite commit: value settles synchronously (the outbound - // mirror then finds it equal), one savedModelChange for the range. - const composed = this.composedValue(); - if (!sameTemporalValue(composed, this.value())) this.value.set(composed); - this.savedModelChange.emit(composed); - } - - // -- Registration (the role directives call these) -------------------------- - - /** Mixed mode is a bug: a field-bound leaf inside a field-bound group throws. */ - #registerBinding(leafBound: boolean, role: string) { - if (leafBound && this.#ownField !== null) { - throw new Error( - `DateTimeRangeGroup: the ${role} leaf has its own [formField] inside a ` + - `form-bound group — bind EITHER the group (composed {start, end, duration}) ` + - `OR the leaves, never both.`, - ); - } - } - - attachDay(control: AngularInlineDate, leafBound = false) { - this.#registerBinding(leafBound, 'rangeDay'); - this.#day.set(control); - } - attachEndDay(control: AngularInlineDate, leafBound = false) { - this.#registerBinding(leafBound, 'rangeEndDay'); - this.#endDay.set(control); - } - attachStart(control: AngularInlineTime, leafBound = false) { - this.#registerBinding(leafBound, 'rangeStart'); - this.#start.set(control); - } - attachEnd(control: AngularInlineTime, leafBound = false) { - this.#registerBinding(leafBound, 'rangeEnd'); - this.#end.set(control); - } - attachTimes(control: AngularInlineTime, leafBound = false) { - this.#registerBinding(leafBound, 'rangeTimes'); - this.#times.set(control); - } - attachLength(control: AngularInlineDuration, leafBound = false) { - this.#registerBinding(leafBound, 'rangeLength'); - this.#length.set(control); + // mirror then finds it equal), one `onChanges` for the range. + const composed = composedValue(); + if (!sameTemporalValue(composed, value())) value.set(composed); + options.onChanges?.({ + dateRange: date, + dateRangeChanged: dateChanged, + timeRange: time, + timeRangeChanged: timeChanged, + duration, + durationChanged, + composed, + }); } - // -- Commit propagation ------------------------------------------------------ + // -- Commit laws --------------------------------------------------------------- /** Rolls `end` forward by whole LOCAL days until it strictly follows `start`, then induces. */ - #induceFrom(start: DbDateTime, end: DbDateTime) { - end = rollDbEntryForward(start, end, this.effectiveZone()); + function induceFrom(startValue: DbDateTime, endValue: DbDateTime) { + endValue = rollDbEntryForward(startValue, endValue, zone()); - this.#writeEnd(end); - this.#writeLength(diffDbEntrySeconds(start, end)!); + writeEnd(endValue); + writeLength(diffDbEntrySeconds(startValue, endValue)!); } /** @@ -443,21 +454,21 @@ export class DateTimeRangeGroup { * instants as they stand (multi-day ends survive); with no end but a * duration, the end is filled from `start + duration`. */ - startCommitted() { - const start = this.start(); + function startCommitted() { + const startValue = start(); - if (start !== null) { - const end = this.end(); + if (startValue !== null) { + const endValue = end(); - if (end !== null) { - this.#induceFrom(start, end); + if (endValue !== null) { + induceFrom(startValue, endValue); } else { - const length = this.length(); - if (length !== null) this.#writeEnd(shiftDbEntry(start, length)); + const duration = length(); + if (duration !== null) writeEnd(shiftDbEntry(startValue, duration)); } } - this.#emitChanges(); + emitChanges(); } /** @@ -468,24 +479,24 @@ export class DateTimeRangeGroup { * `'240:30'` → +10) is an explicit over-count: it anchors on the start's * day directly. */ - endCommitted(dayOverflow = 0, explicitDay = false) { - const start = this.start(); - const end = this.end(); + function endCommitted(dayOverflow = 0, explicitDay = false) { + const startValue = start(); + const endValue = end(); - if (start !== null && end !== null) { + if (startValue !== null && endValue !== null) { if (explicitDay) { // A pasted full instant IS the end — no re-anchor, no roll. An end // before the start stands as the ORDERING ERROR; the duration is // then underivable. - const diff = diffDbEntrySeconds(start, end)!; - this.#writeLength(diff > 0 ? diff : null); + const diff = diffDbEntrySeconds(startValue, endValue)!; + writeLength(diff > 0 ? diff : null); } else { - const day = addLocalDays(localDayOf(start, this.effectiveZone())!, dayOverflow); - this.#induceFrom(start, composeDbEntry(day, localTimeOf(end, this.effectiveZone())!, this.effectiveZone())); + const anchoredDay = addLocalDays(localDayOf(startValue, zone())!, dayOverflow); + induceFrom(startValue, composeDbEntry(anchoredDay, localTimeOf(endValue, zone())!, zone())); } } - this.#emitChanges(); + emitChanges(); } /** @@ -495,101 +506,346 @@ export class DateTimeRangeGroup { * state now (the ordering error on the end leaves), and the duration is * underivable (`null` — never a stale one). */ - endDayCommitted() { - const day = this.endDay(); - const end = this.end(); + function endDayCommitted() { + const typedDay = endDay(); + const endValue = end(); - if (day !== null && end !== null) { - this.#writeEnd(composeDbEntry(day, localTimeOf(end, this.effectiveZone())!, this.effectiveZone())); + if (typedDay !== null && endValue !== null) { + writeEnd(composeDbEntry(typedDay, localTimeOf(endValue, zone())!, zone())); - const start = this.start(); - if (start !== null) { - const diff = diffDbEntrySeconds(start, this.end()!)!; - this.#writeLength(diff > 0 ? diff : null); + const startValue = start(); + if (startValue !== null) { + const diff = diffDbEntrySeconds(startValue, end()!)!; + writeLength(diff > 0 ? diff : null); } } - this.#emitChanges(); + emitChanges(); } /** A duration settled — `shiftFromDuration`: the end MOVES (`start + duration`). */ - lengthCommitted() { - const start = this.start(); - const length = this.length(); - if (start !== null && length !== null) this.#writeEnd(shiftDbEntry(start, length)); + function lengthCommitted() { + const startValue = start(); + const duration = length(); + if (startValue !== null && duration !== null) writeEnd(shiftDbEntry(startValue, duration)); - this.#emitChanges(); + emitChanges(); } /** * The day settled: shift BOTH instants onto it — wall-clock times and * the end's day over-count are preserved. */ - dayCommitted() { - const day = this.day(); - - if (day !== null) { - const start = this.start(); - const end = this.end(); - const offset = start !== null && end !== null ? Math.max(0, localDayDiff(start, end, this.effectiveZone()) ?? 0) : 0; - - if (start !== null) { - this.#writeStart(composeDbEntry(day, localTimeOf(start, this.effectiveZone())!, this.effectiveZone())); + function dayCommitted() { + const typedDay = day(); + + if (typedDay !== null) { + const startValue = start(); + const endValue = end(); + const offset = + startValue !== null && endValue !== null + ? Math.max(0, localDayDiff(startValue, endValue, zone()) ?? 0) + : 0; + + if (startValue !== null) { + writeStart(composeDbEntry(typedDay, localTimeOf(startValue, zone())!, zone())); } - if (end !== null) { - this.#writeEnd( - composeDbEntry(addLocalDays(day, offset), localTimeOf(end, this.effectiveZone())!, this.effectiveZone()), + if (endValue !== null) { + writeEnd( + composeDbEntry( + addLocalDays(typedDay, offset), + localTimeOf(endValue, zone())!, + zone(), + ), ); } } - this.#emitChanges(); + emitChanges(); } - #writeStart(value: DbDateTime) { - const control = this.#start(); - if (control && control.value() !== value) control.value.set(value); + // -- Boundary effects ------------------------------------------------------------ - const times = this.#times(); - if (times && times.internalRange().start !== value) { - times.value.set({ start: value, end: times.internalRange().end }); - } - } + // Baseline the composed values once the initial bindings have settled, + // so the first commit emits real deltas, not the seed state. + effect( + () => { + const date = dateRange(); + const time = timeRange(); + const duration = length(); - #writeEnd(value: DbDateTime) { - const control = this.#end(); - if (control && control.value() !== value) control.value.set(value); + if (lastDate === undefined) { + lastDate = date; + lastTime = time; + lastLength = duration; + } + }, + { injector }, + ); - const times = this.#times(); - if (times && times.internalRange().end !== value) { - times.value.set({ start: times.internalRange().start, end: value }); + // The TWO boundary effects — both equality-guarded, both one-directional, + // converging in a single pass (a value write pushes down; the mirror reads + // back the same values and stops). + + // INBOUND: the value channel → the leaf surfaces. Attachment is a + // dependency on purpose: by-reference leaves attach through an EFFECT + // (after the first inbound flush), so a late-attaching leaf must receive + // the current value — the directive's synchronous DI attach never needed + // this. + effect( + () => { + const next = value(); + dayCtl(); + endDayCtl(); + startCtl(); + endCtl(); + timesCtl(); + lengthCtl(); + if (!bound && next === null) return; + + untracked(() => pushDown(next)); + }, + { injector }, + ); + + const anyAttached = computed( + () => + dayCtl() !== null || + endDayCtl() !== null || + startCtl() !== null || + endCtl() !== null || + timesCtl() !== null || + lengthCtl() !== null, + ); + + // OUTBOUND: the leaves' live values → the value channel (live mirror). + // Gated on attachment: with NO leaves yet (by-reference roles attach an + // effect-flush later), the composed reading is an empty null that must + // not clobber a seeded value. + effect( + () => { + if (!anyAttached()) return; + + const composed = composedValue(); + if (!sameTemporalValue(composed, untracked(value))) value.set(composed); + }, + { injector }, + ); + + return { + value, + day, + endDay, + start, + end, + length, + endDayOffset, + dateRange, + timeRange, + composedValue, + orderingErrors, + attachDay: (control) => dayCtl.set(control), + attachEndDay: (control) => endDayCtl.set(control), + attachStart: (control) => startCtl.set(control), + attachEnd: (control) => endCtl.set(control), + attachTimes: (control) => timesCtl.set(control), + attachLength: (control) => lengthCtl.set(control), + dayCommitted, + startCommitted, + endCommitted, + endDayCommitted, + lengthCommitted, + }; +} + +// ============================================================================= +// The DI directive — a thin shell over the factory (the form-bound face) +// ============================================================================= + +/** + * The group as a DIRECTIVE: `createTemporalRangeGroup`'s laws wearing the + * form contract — one `[formField]` binds the composed + * `{ start, end, duration? }`, contract state forwards to the leaves via + * the role-provided leaf state, and the composed streams ride outputs. + * Role attributes left BARE connect to this directive via DI; where DI + * cannot reach (mat-table's `matColumnDef`), bind the headless group by + * reference instead — `[rangeDay]="row.group"`. + */ +@Directive({ + selector: '[dateTimeRangeGroup]', + exportAs: 'dateTimeRangeGroup', +}) +export class DateTimeRangeGroup { + /** Present when the GROUP carries the `[formField]` — form-bound mode. */ + #ownField = inject(FormField, { optional: true, self: true }); + + /** + * T6 — the DISPLAY ZONE the group's day arithmetic runs in. MUST agree + * with the leaves' zones: set it once via `provideInlineTemporalZone` + * (both group and leaves fall back to the token), or set the input on + * the group AND every leaf. + */ + zone = input(undefined); + + #zoneDefault = inject(INLINE_TEMPORAL_ZONE, { optional: true }); + + readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); + + /** + * The group's OWN value channel. Outbound it always mirrors the composed + * leaves (harmless when nobody listens); inbound it only pushes down when + * the group is form-bound or the value is non-null — a `null` on an + * unbound group is silence, not a clear, so per-leaf-bound legacy setups + * stay untouched. + */ + value = model(null); + + /** Form Value Contract — forwarded down to the leaves via role-provided state. */ + errors = input([]); + disabled = input(false); + readonly = input(false); + touched = input(false); + invalid = input(false); + + /** Form Value Contract: touch — any leaf touching bubbles up as the group's. */ + touch = output(); + + /** One commit event for the whole range: the composed `{start, end, duration?}`. */ + savedModelChange = output(); + + /** + * Composed emissions — the group speaks three values, each fired after + * commit propagation whenever ITS composed value changed: the date range, + * the time range (all UTC ISO DB entries), and the duration (seconds). + */ + dateRangeChange = output(); + timeRangeChange = output(); + durationChange = output(); + + /** The headless core carrying the laws — the directive is its form-bound face. */ + readonly core: TemporalRangeGroup = createTemporalRangeGroup({ + value: this.value, + zone: this.effectiveZone, + bound: this.#ownField !== null, + onChanges: (changes) => { + if (changes.dateRangeChanged) this.dateRangeChange.emit(changes.dateRange); + if (changes.timeRangeChanged) this.timeRangeChange.emit(changes.timeRange); + if (changes.durationChanged) this.durationChange.emit(changes.duration); + this.savedModelChange.emit(changes.composed); + }, + }); + + // The public readings, delegated (API-compatible with the pre-factory group). + readonly day = this.core.day; + readonly endDay = this.core.endDay; + readonly start = this.core.start; + readonly end = this.core.end; + readonly length = this.core.length; + readonly endDayOffset = this.core.endDayOffset; + readonly dateRange = this.core.dateRange; + readonly timeRange = this.core.timeRange; + readonly composedValue = this.core.composedValue; + readonly orderingErrors = this.core.orderingErrors; + + // -- Registration (the role directives call these in DI mode) ---------------- + + /** Mixed mode is a bug: a field-bound leaf inside a field-bound group throws. */ + #registerBinding(leafBound: boolean, role: string) { + if (leafBound && this.#ownField !== null) { + throw new Error( + `DateTimeRangeGroup: the ${role} leaf has its own [formField] inside a ` + + `form-bound group — bind EITHER the group (composed {start, end, duration}) ` + + `OR the leaves, never both.`, + ); } } - #writeLength(value: number | null) { - const control = this.#length(); - if (control && control.value() !== value) control.value.set(value); + attachDay(control: AngularInlineDate, leafBound = false) { + this.#registerBinding(leafBound, 'rangeDay'); + this.core.attachDay(control); + } + attachEndDay(control: AngularInlineDate, leafBound = false) { + this.#registerBinding(leafBound, 'rangeEndDay'); + this.core.attachEndDay(control); + } + attachStart(control: AngularInlineTime, leafBound = false) { + this.#registerBinding(leafBound, 'rangeStart'); + this.core.attachStart(control); + } + attachEnd(control: AngularInlineTime, leafBound = false) { + this.#registerBinding(leafBound, 'rangeEnd'); + this.core.attachEnd(control); + } + attachTimes(control: AngularInlineTime, leafBound = false) { + this.#registerBinding(leafBound, 'rangeTimes'); + this.core.attachTimes(control); + } + attachLength(control: AngularInlineDuration, leafBound = false) { + this.#registerBinding(leafBound, 'rangeLength'); + this.core.attachLength(control); + } + + // The commit laws, delegated (API compatibility). + dayCommitted() { + this.core.dayCommitted(); + } + startCommitted() { + this.core.startCommitted(); + } + endCommitted(dayOverflow = 0, explicitDay = false) { + this.core.endCommitted(dayOverflow, explicitDay); + } + endDayCommitted() { + this.core.endDayCommitted(); + } + lengthCommitted() { + this.core.lengthCommitted(); } } +// ============================================================================= +// The role directives — DI mode (bare attribute) or by-reference mode +// ============================================================================= + +/** + * The role's RESOLVED core, as a per-element holder signal. An indirection + * on purpose: the leaf-state / day-offset providers run while the CONTROL + * constructs, so they must not inject the role directive (whose constructor + * injects the control — NG0200). They read this holder instead; the role's + * wiring fills it once resolution settles. + */ +const RANGE_ROLE_CORE = new InjectionToken>( + 'RANGE_ROLE_CORE', +); + +function provideRoleCore() { + return { provide: RANGE_ROLE_CORE, useFactory: () => signal(null) }; +} + /** * The role-provided leaf state (the day-offset pattern): the group's * contract inputs, pulled by the leaf via a per-element token — no - * effects, no writes. Ordering/range errors route to the END leaf only. + * effects, no writes. Contract state exists only in DI (form-bound) mode; + * ordering/range errors come from the RESOLVED core, so they reach the + * END leaf in both modes. */ function provideLeafState(withErrors: boolean) { return { provide: INLINE_TEMPORAL_LEAF_STATE, useFactory: (): TemporalLeafState => { - const group = inject(DateTimeRangeGroup); + const group = inject(DateTimeRangeGroup, { optional: true }); + const core = inject(RANGE_ROLE_CORE, { self: true }); return { - disabled: group.disabled, - readonly: group.readonly, - touched: group.touched, - invalid: group.invalid, + disabled: computed(() => group?.disabled() ?? false), + readonly: computed(() => group?.readonly() ?? false), + touched: computed(() => group?.touched() ?? false), + invalid: computed(() => group?.invalid() ?? false), // Consumer errors + the group's OWN ordering verdict, end leaves only. errors: withErrors - ? computed(() => [...group.errors(), ...group.orderingErrors()]) + ? computed(() => [ + ...(group?.errors() ?? []), + ...(core()?.orderingErrors() ?? []), + ]) : NO_ERRORS, }; }, @@ -600,80 +856,170 @@ function provideLeafState(withErrors: boolean) { const leafHasOwnField = () => inject(FormField, { optional: true, self: true }) !== null; /** - * Marks the group's date control: ``. The - * pair's inline-START leaf — its clear bubble opens outward (leftward) by - * default via `INLINE_TEMPORAL_BUBBLE_SIDE`. + * The shared role wiring: resolves the group (the role attribute's bound + * reference wins over the DI directive), attaches the control to it as the + * reference (re)binds, and dispatches the leaf's settled sessions into the + * given commit law. DI mode attaches through the DIRECTIVE so the + * mixed-mode guard keeps throwing. + */ +function wireRole( + reference: Signal, + control: TControl, + attach: (core: TemporalRangeGroup) => void, + attachViaDirective: (group: DateTimeRangeGroup, leafBound: boolean) => void, +): Signal { + const di = inject(DateTimeRangeGroup, { optional: true }); + const holder = inject(RANGE_ROLE_CORE, { self: true }); + const leafBound = leafHasOwnField(); + + const resolvedCore = computed(() => { + const ref = reference(); + return typeof ref === 'object' && ref !== null ? ref : (di?.core ?? null); + }); + + // DI mode attaches SYNCHRONOUSLY (the mixed-mode guard must throw during + // construction, exactly as before the factory existed); a bound reference + // arrives with the first input flush and simply wins. + let attached: TemporalRangeGroup | null = null; + if (di !== null) { + attached = di.core; + holder.set(di.core); + attachViaDirective(di, leafBound); + } + + effect(() => { + const core = resolvedCore(); + holder.set(core); + if (core === null || core === attached) return; + + attached = core; + if (di !== null && core === di.core) attachViaDirective(di, leafBound); + else attach(core); + }); + + return resolvedCore; +} + +/** + * Marks the group's date control. Bare, it connects to the ancestor + * `dateTimeRangeGroup` directive via DI: ``; + * bound, it connects to a HEADLESS group by reference — + * `` (the mat-table case, + * where `matColumnDef` blocks DI). The pair's inline-START leaf — its clear + * bubble opens outward (leftward) by default via + * `INLINE_TEMPORAL_BUBBLE_SIDE`. */ @Directive({ selector: 'angular-inline-date[rangeDay]', - providers: [provideLeafState(false), { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }], + providers: [ + provideRoleCore(), + provideLeafState(false), + { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }, + ], }) export class RangeDay { + rangeDay = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineDate); - - group.attachDay(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeDay, + control, + (core) => core.attachDay(control), + (group, leafBound) => group.attachDay(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.dayCommitted(); + if (session.changed) this.resolvedCore()?.dayCommitted(); }); } } /** - * Marks the group's start time: ``. An - * inline-START leaf — its clear bubble opens outward (leftward) by default - * via `INLINE_TEMPORAL_BUBBLE_SIDE`. + * Marks the group's start time (DI via the bare attribute, a headless + * group by reference — see `RangeDay`). An inline-START leaf — its clear + * bubble opens outward (leftward) by default. */ @Directive({ selector: 'angular-inline-time[rangeStart]', - providers: [provideLeafState(false), { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }], + providers: [ + provideRoleCore(), + provideLeafState(false), + { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }, + ], }) export class RangeStart { + rangeStart = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineTime); - - group.attachStart(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeStart, + control, + (core) => core.attachStart(control), + (group, leafBound) => group.attachStart(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.startCommitted(); + if (session.changed) this.resolvedCore()?.startCommitted(); }); } } /** - * Marks the group's end time: ``. Also - * feeds the control's `+n` day-overflow badge via `INLINE_TIME_DAY_OFFSET` - * and receives the group's range errors. + * Marks the group's end time (DI via the bare attribute, a headless group + * by reference — see `RangeDay`). Also feeds the control's `+n` + * day-overflow badge via `INLINE_TIME_DAY_OFFSET` and receives the group's + * range errors. */ @Directive({ selector: 'angular-inline-time[rangeEnd]', providers: [ + provideRoleCore(), provideLeafState(true), { provide: INLINE_TIME_DAY_OFFSET, - useFactory: () => inject(DateTimeRangeGroup).endDayOffset, + useFactory: () => { + const core = inject(RANGE_ROLE_CORE, { self: true }); + return computed(() => core()?.endDayOffset() ?? 0); + }, }, ], }) export class RangeEnd { + rangeEnd = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineTime); - - group.attachEnd(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeEnd, + control, + (core) => core.attachEnd(control), + (group, leafBound) => group.attachEnd(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.endCommitted(session.dayOverflow, session.explicitDay); + if (session.changed) this.resolvedCore()?.endCommitted(session.dayOverflow, session.explicitDay); }); } } /** * Marks ONE ranged time control carrying BOTH endpoints: - * `` — the pair replaces + * `` (DI) or + * `[rangeTimes]="row.group"` (headless, by reference) — the pair replaces * the two single `rangeStart`/`rangeEnd` leaves (the add-dialog / table * TIME-column shape). Propagation stays per-endpoint: the control's * `saved.side` dispatches to the same start/end commit laws. The control @@ -681,55 +1027,93 @@ export class RangeEnd { * idempotent over a settled pair (that is what `dayOverflow`/ * `explicitDay` are carried FOR). Receives the group's range errors. */ -@Directive({ selector: 'angular-inline-time[rangeTimes]', providers: [provideLeafState(true)] }) +@Directive({ + selector: 'angular-inline-time[rangeTimes]', + providers: [provideRoleCore(), provideLeafState(true)], +}) export class RangeTimes { + rangeTimes = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineTime); - - group.attachTimes(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeTimes, + control, + (core) => core.attachTimes(control), + (group, leafBound) => group.attachTimes(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { if (!session.changed) return; - if (session.side === 'start') group.startCommitted(); - else group.endCommitted(session.dayOverflow, session.explicitDay); + const core = this.resolvedCore(); + if (core === null) return; + if (session.side === 'start') core.startCommitted(); + else core.endCommitted(session.dayOverflow, session.explicitDay); }); } } /** - * Marks the group's END-DAY control (the maximal five-field form): - * ``. Receives the ordering errors — - * this leaf is where violations are made. + * Marks the group's END-DAY control (the maximal five-field form; DI via + * the bare attribute, a headless group by reference — see `RangeDay`). + * Receives the ordering errors — this leaf is where violations are made. */ -@Directive({ selector: 'angular-inline-date[rangeEndDay]', providers: [provideLeafState(true)] }) +@Directive({ + selector: 'angular-inline-date[rangeEndDay]', + providers: [provideRoleCore(), provideLeafState(true)], +}) export class RangeEndDay { + rangeEndDay = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineDate); - - group.attachEndDay(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeEndDay, + control, + (core) => core.attachEndDay(control), + (group, leafBound) => group.attachEndDay(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.endDayCommitted(); + if (session.changed) this.resolvedCore()?.endDayCommitted(); }); } } -/** Marks the group's duration: ``. */ +/** + * Marks the group's duration (DI via the bare attribute, a headless group + * by reference — see `RangeDay`). + */ @Directive({ selector: 'angular-inline-duration[rangeLength]', - providers: [provideLeafState(false)], + providers: [provideRoleCore(), provideLeafState(false)], }) export class RangeLength { + rangeLength = input(''); + + readonly resolvedCore: Signal; + constructor() { - const group = inject(DateTimeRangeGroup); const control = inject(AngularInlineDuration); - - group.attachLength(control, leafHasOwnField()); - control.touch.subscribe(() => group.touch.emit()); + this.resolvedCore = wireRole( + this.rangeLength, + control, + (core) => core.attachLength(control), + (group, leafBound) => group.attachLength(control, leafBound), + ); + + const di = inject(DateTimeRangeGroup, { optional: true }); + control.touch.subscribe(() => di?.touch.emit()); control.saved.subscribe((session) => { - if (session.changed) group.lengthCommitted(); + if (session.changed) this.resolvedCore()?.lengthCommitted(); }); } } diff --git a/projects/app/src/app/pages/temporal-playground/_model-table.scss b/projects/app/src/app/pages/temporal-playground/_model-table.scss new file mode 100644 index 0000000..065839f --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/_model-table.scss @@ -0,0 +1,32 @@ +// Display-vs-model table: what the user sees | the DB entry behind it. +// @use'd by every temporal card that renders the two-column comparison. +.model-table { + width: 100%; + border-collapse: collapse; + font: var(--mat-sys-body-medium); + + th, + td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--mat-sys-outline-variant); + vertical-align: baseline; + } + + thead th { + font: var(--mat-sys-label-large); + color: var(--mat-sys-on-surface-variant); + } + + tbody th { + font: var(--mat-sys-label-large); + color: var(--mat-sys-on-surface-variant); + white-space: nowrap; + } + + code { + font-size: 0.85em; + color: var(--mat-sys-on-surface-variant); + word-break: break-all; + } +} diff --git a/projects/app/src/app/pages/temporal-playground/date-card/date-card.html b/projects/app/src/app/pages/temporal-playground/date-card/date-card.html new file mode 100644 index 0000000..b919128 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/date-card/date-card.html @@ -0,0 +1,75 @@ +
+

Date & date range — what the user sees vs the model

+

+ The SAME date control twice — the binding shape IS the mode. Deadline is one + [formField]: type “24.12.” (year auto-completes), an ISO date, or a month name — focus opens + the calendar panel without stealing the caret (ArrowDown enters the grid; yesterday/today/tomorrow chips + are the quick-picks, labels via Intl); the model is the UTC ISO DB entry of the local + startOf('day'). Vacation binds {{ '{' }} start, end {{ '}' }} — the + shape-echo renders the TWO-FIELD pair: start = local startOf('day'), + end = endOf('day'). Tab commits the start and lands in the end; each side owns + its clear (half-open ranges are real states). On the calendar: PRESS-HOLD-DRAG paints the range, + Ctrl/Cmd+click restarts it half-open. The toggles below apply to BOTH fields. +

+ + + + + + + + + + + + + + + + + + + + + +
FieldDisplay (local)Model (UTC, SQL-friendly)
Deadline + + + @if (due.parseFailed()) { + That doesn’t read as a date — try “24.12.2026”, “24.12.” or “2026-12-24”. + } @else if (dueMissing()) { + A deadline is required. + } + + + {{ dateModel().due ?? '∅' }}
Vacation + + + {{ dateModel().vacation?.start ?? '∅' }} + → + {{ dateModel().vacation?.end ?? '∅' }} +
+ +
+ + + + +
+
diff --git a/projects/app/src/app/pages/temporal-playground/date-card/date-card.scss b/projects/app/src/app/pages/temporal-playground/date-card/date-card.scss new file mode 100644 index 0000000..6e58920 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/date-card/date-card.scss @@ -0,0 +1,7 @@ +@use '../../demo'; +@use '../model-table'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} diff --git a/projects/app/src/app/pages/temporal-playground/date-card/date-card.spec.ts b/projects/app/src/app/pages/temporal-playground/date-card/date-card.spec.ts new file mode 100644 index 0000000..751cb51 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/date-card/date-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DateCard } from './date-card'; + +describe('DateCard', () => { + let component: DateCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DateCard], + }).compileComponents(); + + fixture = TestBed.createComponent(DateCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/date-card/date-card.ts b/projects/app/src/app/pages/temporal-playground/date-card/date-card.ts new file mode 100644 index 0000000..68ba85d --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/date-card/date-card.ts @@ -0,0 +1,67 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + model, + output, +} from '@angular/core'; +import { FormField, form, required } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; + +// Components +import { + AngularInlineDate, + dayToDbEntry, + dayEndToDbEntry, + type IsoDateRange, +} from 'angular-inline-select/temporal'; + +/** + * Date & date range — ONE form: the single deadline and the ranged vacation + * live in the same card, so the card's toggles (required, locale, touched, + * reset) apply to BOTH fields. + */ +@Component({ + selector: 'app-date-card', + templateUrl: './date-card.html', + styleUrl: './date-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatButtonModule, FormField, AngularInlineDate], +}) +export class DateCard { + /** The page's locale — two-way: this card's toggle drives the sibling cards too. */ + readonly locale = model<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected fieldRequired = signal(true); + + protected dateModel = signal<{ due: string | null; vacation: IsoDateRange | null }>({ + due: dayToDbEntry('2026-07-20'), + vacation: { start: dayToDbEntry('2026-07-21'), end: dayEndToDbEntry('2026-07-24') }, + }); + + protected dateForm = form(this.dateModel, (path) => { + required(path.due, { when: () => this.fieldRequired() }); + required(path.vacation, { when: () => this.fieldRequired() }); + }); + + protected dueMissing = computed(() => + this.dateForm.due().errors().some((error) => error.kind === 'required'), + ); + + protected resetDateFields() { + this.dateForm.due().reset(); + this.dateForm.vacation().reset(); + } + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} diff --git a/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.html b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.html new file mode 100644 index 0000000..0d74ab0 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.html @@ -0,0 +1,39 @@ +
+

Duration — what the user sees vs the model

+

+ One [formField]. Type “2:15”, “135” (minutes), or “1h 30m”; commits snap to whole minutes and + round-trip the codec. The model is always plain seconds. +

+ + + + + + + + + + + + + + + + +
FieldDisplay (local)Model (seconds)
Estimated effort + + {{ durationModel().estimate ?? '∅' }}s
+ +
+ + + + current: {{ durationFormat() }} +
+
diff --git a/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.scss b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.scss new file mode 100644 index 0000000..6e58920 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.scss @@ -0,0 +1,7 @@ +@use '../../demo'; +@use '../model-table'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} diff --git a/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.spec.ts b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.spec.ts new file mode 100644 index 0000000..97c1a00 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DurationCard } from './duration-card'; + +describe('DurationCard', () => { + let component: DurationCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DurationCard], + }).compileComponents(); + + fixture = TestBed.createComponent(DurationCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.ts b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.ts new file mode 100644 index 0000000..6a872d9 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/duration-card/duration-card.ts @@ -0,0 +1,38 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + output, +} from '@angular/core'; +import { FormField, form } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; + +// Components +import { AngularInlineDuration, type DurationFormat } from 'angular-inline-select/temporal'; + +/** + * Duration — form-driven: the model is seconds. + */ +@Component({ + selector: 'app-duration-card', + templateUrl: './duration-card.html', + styleUrl: './duration-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatButtonModule, FormField, AngularInlineDuration], +}) +export class DurationCard { + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected durationFormat = signal('h:mm'); + protected durationModel = signal<{ estimate: number | null }>({ estimate: 5400 }); + protected durationForm = form(this.durationModel); + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} diff --git a/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.html b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.html new file mode 100644 index 0000000..0514167 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.html @@ -0,0 +1,28 @@ +
+

Baseline — stock Material datepickers in mat-form-fields

+

+ The reference point: Angular Material's OWN <mat-datepicker> and + <mat-date-range-picker> hosted in <mat-form-field>. One field is a range + (matStartDate/matEndDate), the other a single date — both start EMPTY and both use + floatLabel="always", so the labels sit floated above the placeholders with nothing selected. +

+ +
+ + Trip dates + + + + + + + + + + Single day + + + + +
+
diff --git a/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.scss b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.scss new file mode 100644 index 0000000..2c65c9a --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.scss @@ -0,0 +1,19 @@ +@use '../../demo'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} + +// Two labeled mat boxes in one row, wrapping on narrow screens. +.mat-quartet { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: baseline; + + mat-form-field { + flex: 1 1 10rem; + min-width: 9rem; + } +} diff --git a/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.spec.ts b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.spec.ts new file mode 100644 index 0000000..c992e64 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MatBaselineCard } from './mat-baseline-card'; + +describe('MatBaselineCard', () => { + let component: MatBaselineCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatBaselineCard], + }).compileComponents(); + + fixture = TestBed.createComponent(MatBaselineCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.ts b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.ts new file mode 100644 index 0000000..7d9e48d --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-baseline-card/mat-baseline-card.ts @@ -0,0 +1,21 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +// Material +import { provideNativeDateAdapter } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatDatepickerModule } from '@angular/material/datepicker'; + +/** + * Baseline — stock Material datepickers in mat-form-fields: the reference + * point the inline controls are measured against. + */ +@Component({ + selector: 'app-mat-baseline-card', + templateUrl: './mat-baseline-card.html', + styleUrl: './mat-baseline-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + providers: [provideNativeDateAdapter()], + imports: [MatFormFieldModule, MatInputModule, MatDatepickerModule], +}) +export class MatBaselineCard {} diff --git a/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.html b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.html new file mode 100644 index 0000000..ac32d7e --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.html @@ -0,0 +1,73 @@ +
+

The quartet in mat-form-fields (T4) — same controls, mat chrome

+

+ The SAME group and the SAME mat-ignorant leaves — each hosted by <mat-form-field> via + the inlineMatFormField adapter (angular-inline-select/temporal-mat). The adapter + derives MatFormFieldControl entirely from the controls' public signals — label float from + emptiness/focus, errorState from the field's own verdict — and the controls' underline rests + via the generic bare-chrome classes. Sessions, snap-back, the calendar panel, overflow hours and the + +{{ matStayGroup.endDayOffset() }} badge all work unchanged inside the mat box. +

+ +
+ + Stay + + + + + Starts + + + + + Ends + + + + + Length + + whole minutes + +
+ +

+ Model: + {{ matStayModel()?.start ?? '∅' }} → + {{ matStayModel()?.end ?? '∅' }} · + {{ matStayModel()?.duration ?? '∅' }}s +

+
diff --git a/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.scss b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.scss new file mode 100644 index 0000000..d993ce3 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.scss @@ -0,0 +1,19 @@ +@use '../../demo'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} + +// Four labeled mat boxes in one row, wrapping on narrow screens. +.mat-quartet { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: baseline; + + mat-form-field { + flex: 1 1 10rem; + min-width: 9rem; + } +} diff --git a/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.spec.ts b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.spec.ts new file mode 100644 index 0000000..1d63c75 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MatQuartetCard } from './mat-quartet-card'; + +describe('MatQuartetCard', () => { + let component: MatQuartetCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatQuartetCard], + }).compileComponents(); + + fixture = TestBed.createComponent(MatQuartetCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.ts b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.ts new file mode 100644 index 0000000..344329e --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-quartet-card/mat-quartet-card.ts @@ -0,0 +1,77 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + input, + output, +} from '@angular/core'; +import { FormField, form } from '@angular/forms/signals'; + +// Material +import { MatFormFieldModule } from '@angular/material/form-field'; + +// Components +import { + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + composeDbEntry, + type TemporalRangeValue, +} from 'angular-inline-select/temporal'; +import { InlineMatFormField } from 'angular-inline-select/temporal-mat'; + +/** + * The quartet in MAT-FORM-FIELDS (T4): same group, same unbound leaves — + * each hosted by via the temporal-mat adapter. The + * controls stay mat-ignorant; the adapter derives MatFormFieldControl + * from their public signals. + */ +@Component({ + selector: 'app-mat-quartet-card', + templateUrl: './mat-quartet-card.html', + styleUrl: './mat-quartet-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + MatFormFieldModule, + InlineMatFormField, + FormField, + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + ], +}) +export class MatQuartetCard { + /** The page's locale, owned by the date card's toggle. */ + readonly locale = input<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected matStayModel = signal({ + start: composeDbEntry('2026-07-21', '21:00'), + end: composeDbEntry('2026-07-22', '06:00'), + duration: 32_400, + }); + + protected matStayForm = form(this.matStayModel); + + /** The locale pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.locale()}-u-hc-h23`); + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} diff --git a/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.html b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.html new file mode 100644 index 0000000..675e256 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.html @@ -0,0 +1,52 @@ +
+

The HEADLESS group in a mat-table — the group lives on row data

+

+ A real mat-table: matColumnDef cells cannot inject a per-row group + directive, so each row's DATA carries its own createTemporalRangeGroup() and the + leaves connect by reference[rangeDay]="row.group", + [rangeTimes]="row.group", [rangeLength]="row.group". Same laws: + an end commit induces the length, a length commit moves the end, a day commit shifts the whole + row. Night is seeded overnight and wears the +1 badge. +

+ + + + + + + + + + + + + + + + + + + + + + + + +
Shift{{ row.label }}Day + + Time + + Length + +
+
diff --git a/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.scss b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.scss new file mode 100644 index 0000000..6e4002a --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.scss @@ -0,0 +1,15 @@ +@use '../../demo'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} + +.headless-table { + width: 100%; + background: transparent; + + td.mat-mdc-cell { + white-space: nowrap; + } +} diff --git a/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.ts b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.ts new file mode 100644 index 0000000..500aac9 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.ts @@ -0,0 +1,88 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + input, + output, +} from '@angular/core'; +import { MatTableModule } from '@angular/material/table'; + +// Components +import { + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + RangeDay, + RangeTimes, + RangeLength, + composeDbEntry, + createTemporalRangeGroup, + type TemporalRangeGroup, + type TemporalRangeValue, +} from 'angular-inline-select/temporal'; + +interface ShiftRow { + label: string; + group: TemporalRangeGroup; +} + +/** + * The HEADLESS group in a MAT-TABLE — the case the DI directive cannot + * serve: `matColumnDef` cell templates are declared on the table, not the + * row, so a per-row `dateTimeRangeGroup` directive is unreachable through + * the element injector. Instead each row's DATA carries its own + * `createTemporalRangeGroup()` and the leaves connect BY REFERENCE + * (`[rangeDay]="row.group"` …) — same laws, no DI. This fixture mirrors + * iusta's time-entry table exactly. + */ +@Component({ + selector: 'app-mat-table-card', + templateUrl: './mat-table-card.html', + styleUrl: './mat-table-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + MatTableModule, + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + RangeDay, + RangeTimes, + RangeLength, + ], +}) +export class MatTableCard { + /** The page's locale, owned by the date card's toggle. */ + readonly locale = input<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected columns = ['label', 'day', 'times', 'length']; + + // Component field initializer = injection context; the factory registers + // its effects here. Each row is its own group — its own laws, its own value. + protected rows: ShiftRow[] = ( + [ + { label: 'Early', day: '2026-07-20', start: '06:00', end: '14:00', duration: 28_800 }, + { label: 'Core', day: '2026-07-21', start: '09:00', end: '17:30', duration: 30_600 }, + { label: 'Night', day: '2026-07-23', start: '22:00', end: '06:00', duration: 28_800, endDay: '2026-07-24' }, + ] as { label: string; day: string; start: string; end: string; duration: number; endDay?: string }[] + ).map(({ label, day, start, end, duration, endDay }) => ({ + label, + group: createTemporalRangeGroup({ + value: signal({ + start: composeDbEntry(day, start), + end: composeDbEntry(endDay ?? day, end), + duration, + }), + onChanges: (changes) => + this.emitted.emit({ name: `matTable.${label}.savedModelChange`, payload: changes.composed }), + }), + })); + + /** The locale pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.locale()}-u-hc-h23`); +} diff --git a/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.html b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.html new file mode 100644 index 0000000..572b0b3 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.html @@ -0,0 +1,87 @@ +
+

The quartet — ONE form field, the group is the control (T5b)

+

+ One [formField] on the group, model = the domain shape + {{ '{' }} start, end, duration {{ '}' }} (UTC ISO DB entries + seconds). The four leaves are + UNBOUND surfaces the group feeds — Stay is a rendering of start's date part: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDisplay (local)Model (UTC, SQL-friendly)
Stay + + — derived from start —
Starts + + {{ stayModel()?.start ?? '∅' }}
Ends + + {{ stayModel()?.end ?? '∅' }}
End day + + — derived from end —
Length + + {{ stayModel()?.duration ?? '∅' }}s
+ +

+ The group owns the invariants: a typed end is wall-clock intent (at-or-before the start rolls to the next + day — the +{{ stayGroup.endDayOffset() }} badge; overflow hours like “240:30” type the + over-count by hand); a length commit moves the end; a day commit shifts both instants. The END DAY (T5's + maximal form) moves the end WITHOUT rolling — an end before the start finally IS an error (ordering error + on the end leaves, duration underivable). Paste a full ISO datetime into Starts or Ends and it decomposes + across the group. Each settled commit writes ONE composed model and emits ONE + savedModelChange — see the log. +

+
diff --git a/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.scss b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.scss new file mode 100644 index 0000000..6e58920 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.scss @@ -0,0 +1,7 @@ +@use '../../demo'; +@use '../model-table'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} diff --git a/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.spec.ts b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.spec.ts new file mode 100644 index 0000000..18183cb --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuartetCard } from './quartet-card'; + +describe('QuartetCard', () => { + let component: QuartetCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QuartetCard], + }).compileComponents(); + + fixture = TestBed.createComponent(QuartetCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.ts b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.ts new file mode 100644 index 0000000..1a5d3ce --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-card/quartet-card.ts @@ -0,0 +1,73 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + input, + output, +} from '@angular/core'; +import { FormField, form } from '@angular/forms/signals'; + +// Components +import { + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeEndDay, + RangeStart, + RangeEnd, + RangeLength, + composeDbEntry, + type TemporalRangeValue, +} from 'angular-inline-select/temporal'; + +/** + * The quartet — T5b: the GROUP is the form control. ONE field, the domain + * shape ({start, end, duration} — DB entries + seconds); the four leaves + * are unbound surfaces the group feeds. Seeded OVERNIGHT: the end instant + * is on the next day, so the end field wears the +1 badge. + */ +@Component({ + selector: 'app-quartet-card', + templateUrl: './quartet-card.html', + styleUrl: './quartet-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + FormField, + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeEndDay, + RangeStart, + RangeEnd, + RangeLength, + ], +}) +export class QuartetCard { + /** The page's locale, owned by the date card's toggle. */ + readonly locale = input<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected stayModel = signal({ + start: composeDbEntry('2026-07-21', '21:00'), + end: composeDbEntry('2026-07-22', '06:00'), + duration: 32_400, + }); + + protected stayForm = form(this.stayModel); + + /** The locale pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.locale()}-u-hc-h23`); + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} diff --git a/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.html b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.html new file mode 100644 index 0000000..f094c76 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.html @@ -0,0 +1,58 @@ +
+

The quartet in a table — each ROW is one control

+

+ Five shifts, one dateTimeRangeGroup per <tr>, its + {{ '{' }} start, end, duration {{ '}' }} value two-way bound per row — day, time range and + length are the group's leaves in the row's cells. Night is seeded overnight, so its end wears the + +1 badge; On-call runs a day and a half. Type overflow hours (“25:15”) into any + Ends to roll its day by hand — a length commit moves the end, a day commit shifts the whole row. +

+ + + + + + + + + + + + @for (row of stayRows; track row.label) { + + + + + + + } + +
ShiftDayTimeLength
{{ row.label }} + + + + + + + + + +
+
diff --git a/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.scss b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.scss new file mode 100644 index 0000000..947e19b --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.scss @@ -0,0 +1,28 @@ +@use '../../demo'; +@use '../model-table'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} + +// The quartet-per-row table: the time-range pair rides in ONE column (the +// ranged control's look — start – end); the label column hugs its content +// like the other model tables. +.quartet-table { + td { + width: 25%; + white-space: nowrap; + } +} + +.time-range-pair { + display: inline-flex; + align-items: baseline; + gap: 0.35em; +} + +.time-range-pair__separator { + user-select: none; + color: var(--mat-sys-on-surface-variant); +} diff --git a/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.spec.ts b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.spec.ts new file mode 100644 index 0000000..532002f --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QuartetTableCard } from './quartet-table-card'; + +describe('QuartetTableCard', () => { + let component: QuartetTableCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QuartetTableCard], + }).compileComponents(); + + fixture = TestBed.createComponent(QuartetTableCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.ts b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.ts new file mode 100644 index 0000000..8cc76c3 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/quartet-table-card/quartet-table-card.ts @@ -0,0 +1,108 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + input, + output, + type WritableSignal, +} from '@angular/core'; + +// Components +import { + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + composeDbEntry, + type TemporalRangeValue, +} from 'angular-inline-select/temporal'; + +/** + * The quartet in a TABLE — five rows, each ROW is one control: the group + * directive sits on the , its value two-way bound per row. A plain + * table, not mat-table: the leaves inject their group through the element + * injector, so they must be template children of the row — matColumnDef + * cell templates are declared on the table, not the row, and would all + * resolve the same group. The night shift is seeded overnight (+1 badge); + * typed overflow hours ("25:15") roll an end the same way. + */ +@Component({ + selector: 'app-quartet-table-card', + templateUrl: './quartet-table-card.html', + styleUrl: './quartet-table-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + AngularInlineDate, + AngularInlineDuration, + AngularInlineTime, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + ], +}) +export class QuartetTableCard { + /** The page's locale, owned by the date card's toggle. */ + readonly locale = input<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected stayRows: { label: string; value: WritableSignal }[] = [ + { + label: 'Early', + value: signal({ + start: composeDbEntry('2026-07-20', '06:00'), + end: composeDbEntry('2026-07-20', '14:00'), + duration: 28_800, + }), + }, + { + label: 'Core', + value: signal({ + start: composeDbEntry('2026-07-21', '09:00'), + end: composeDbEntry('2026-07-21', '17:30'), + duration: 30_600, + }), + }, + { + label: 'Late', + value: signal({ + start: composeDbEntry('2026-07-22', '13:15'), + end: composeDbEntry('2026-07-22', '21:45'), + duration: 30_600, + }), + }, + { + label: 'Night', + value: signal({ + start: composeDbEntry('2026-07-23', '22:00'), + end: composeDbEntry('2026-07-24', '06:00'), + duration: 28_800, + }), + }, + { + label: 'On-call', + value: signal({ + start: composeDbEntry('2026-07-24', '08:00'), + end: composeDbEntry('2026-07-25', '20:00'), + duration: 129_600, + }), + }, + ]; + + /** The locale pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.locale()}-u-hc-h23`); + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.html b/projects/app/src/app/pages/temporal-playground/temporal-playground.html index 7be470c..d2c75b2 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.html +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -13,435 +13,14 @@

Inline temporal editables

-
-

Date & date range — what the user sees vs the model

-

- The SAME date control twice — the binding shape IS the mode. Deadline is one - [formField]: type “24.12.” (year auto-completes), an ISO date, or a month name — focus opens - the calendar panel without stealing the caret (ArrowDown enters the grid; yesterday/today/tomorrow chips - are the quick-picks, labels via Intl); the model is the UTC ISO DB entry of the local - startOf('day'). Vacation binds {{ '{' }} start, end {{ '}' }} — the - shape-echo renders the TWO-FIELD pair: start = local startOf('day'), - end = endOf('day'). Tab commits the start and lands in the end; each side owns - its clear (half-open ranges are real states). On the calendar: PRESS-HOLD-DRAG paints the range, - Ctrl/Cmd+click restarts it half-open. The toggles below apply to BOTH fields. -

- - - - - - - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (UTC, SQL-friendly)
Deadline - - - @if (due.parseFailed()) { - That doesn’t read as a date — try “24.12.2026”, “24.12.” or “2026-12-24”. - } @else if (dueMissing()) { - A deadline is required. - } - - - {{ dateModel().due ?? '∅' }}
Vacation - - - {{ dateModel().vacation?.start ?? '∅' }} - → - {{ dateModel().vacation?.end ?? '∅' }} -
- -
- - - - -
-
- -
-

Time & time range — what the user sees vs the model

-

- The SAME time control twice. Starts at is one [formField]: type “930”, “9”, “21:05” — - the local wall-clock display hides a FULL UTC instant that carries its own day. Shift binds - {{ '{' }} start, end {{ '}' }} — the shape-echo renders the TWO-FIELD pair (no group, no - leaves). The range house rules live INSIDE the control: a typed end is wall-clock intent anchored on the - START's day — at-or-before the start it rolls next-day (the overnight seed wears the intrinsic - +{{ shiftTime.dayOffset() }} badge), overflow hours like “25:15” type the over-count by hand, - and a pasted full ISO datetime stands as-is, never re-anchored. Tab commits the start and lands in the end; - Escape reverts the PAIR. The native-picker toggle below applies to BOTH fields. -

- - - - - - - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (UTC, SQL-friendly)
Starts at - - {{ timeModel().starts ?? '∅' }}
Shift - - - {{ timeModel().shift?.start ?? '∅' }} - → - {{ timeModel().shift?.end ?? '∅' }} -
- -
- -
-
- -
-

Duration — what the user sees vs the model

-

- One [formField]. Type “2:15”, “135” (minutes), or “1h 30m”; commits snap to whole minutes and - round-trip the codec. The model is always plain seconds. -

- - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (seconds)
Estimated effort - - {{ durationModel().estimate ?? '∅' }}s
- -
- - - - current: {{ durationFormat() }} -
-
- -
-

The quartet — ONE form field, the group is the control (T5b)

-

- One [formField] on the group, model = the domain shape - {{ '{' }} start, end, duration {{ '}' }} (UTC ISO DB entries + seconds). The four leaves are - UNBOUND surfaces the group feeds — Stay is a rendering of start's date part: -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldDisplay (local)Model (UTC, SQL-friendly)
Stay - - — derived from start —
Starts - - {{ stayModel()?.start ?? '∅' }}
Ends - - {{ stayModel()?.end ?? '∅' }}
End day - - — derived from end —
Length - - {{ stayModel()?.duration ?? '∅' }}s
- -

- The group owns the invariants: a typed end is wall-clock intent (at-or-before the start rolls to the next - day — the +{{ stayGroup.endDayOffset() }} badge; overflow hours like “240:30” type the - over-count by hand); a length commit moves the end; a day commit shifts both instants. The END DAY (T5's - maximal form) moves the end WITHOUT rolling — an end before the start finally IS an error (ordering error - on the end leaves, duration underivable). Paste a full ISO datetime into Starts or Ends and it decomposes - across the group. Each settled commit writes ONE composed model and emits ONE - savedModelChange — see the log. -

-
- -
-

The quartet in a table — each ROW is one control

-

- Five shifts, one dateTimeRangeGroup per <tr>, its - {{ '{' }} start, end, duration {{ '}' }} value two-way bound per row — day, time range and - length are the group's leaves in the row's cells. Night is seeded overnight, so its end wears the - +1 badge; On-call runs a day and a half. Type overflow hours (“25:15”) into any - Ends to roll its day by hand — a length commit moves the end, a day commit shifts the whole row. -

- - - - - - - - - - - - @for (row of stayRows; track row.label) { - - - - - - - } - -
ShiftDayTimeLength
{{ row.label }} - - - - - - - - - -
-
- -
-

The quartet in mat-form-fields (T4) — same controls, mat chrome

-

- The SAME group and the SAME mat-ignorant leaves — each hosted by <mat-form-field> via - the inlineMatFormField adapter (angular-inline-select/temporal-mat). The adapter - derives MatFormFieldControl entirely from the controls' public signals — label float from - emptiness/focus, errorState from the field's own verdict — and the controls' underline rests - via the generic bare-chrome classes. Sessions, snap-back, the calendar panel, overflow hours and the - +{{ matStayGroup.endDayOffset() }} badge all work unchanged inside the mat box. -

- -
- - Stay - - - - - Starts - - - - - Ends - - - - - Length - - whole minutes - -
- -

- Model: - {{ matStayModel()?.start ?? '∅' }} → - {{ matStayModel()?.end ?? '∅' }} · - {{ matStayModel()?.duration ?? '∅' }}s -

-
- -
-

Baseline — stock Material datepickers in mat-form-fields

-

- The reference point: Angular Material's OWN <mat-datepicker> and - <mat-date-range-picker> hosted in <mat-form-field>. One field is a range - (matStartDate/matEndDate), the other a single date — both start EMPTY and both use - floatLabel="always", so the labels sit floated above the placeholders with nothing selected. -

- -
- - Trip dates - - - - - - - - - - Single day - - - - -
-
+ + + + + + + + @if (emittedEvents().length > 0) {
diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss index 950e6f0..e1686d8 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss @@ -1,67 +1 @@ @use '../demo'; - -// Display-vs-model table: what the user sees | the DB entry behind it. -.model-table { - width: 100%; - border-collapse: collapse; - font: var(--mat-sys-body-medium); - - th, - td { - padding: 10px 14px; - text-align: left; - border-bottom: 1px solid var(--mat-sys-outline-variant); - vertical-align: baseline; - } - - thead th { - font: var(--mat-sys-label-large); - color: var(--mat-sys-on-surface-variant); - } - - tbody th { - font: var(--mat-sys-label-large); - color: var(--mat-sys-on-surface-variant); - white-space: nowrap; - } - - code { - font-size: 0.85em; - color: var(--mat-sys-on-surface-variant); - word-break: break-all; - } -} - -// The quartet-per-row table: the time-range pair rides in ONE column (the -// ranged control's look — start – end); the label column hugs its content -// like the other model tables. -.quartet-table { - td { - width: 25%; - white-space: nowrap; - } -} - -.time-range-pair { - display: inline-flex; - align-items: baseline; - gap: 0.35em; -} - -.time-range-pair__separator { - user-select: none; - color: var(--mat-sys-on-surface-variant); -} - -// The T4 card: four labeled mat boxes in one row, wrapping on narrow screens. -.mat-quartet { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: baseline; - - mat-form-field { - flex: 1 1 10rem; - min-width: 9rem; - } -} diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts index 03b764a..efd977f 100644 --- a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -4,208 +4,43 @@ import { // Signals signal, - computed, - type WritableSignal, } from '@angular/core'; -import { FormField, form, required } from '@angular/forms/signals'; -// Material -import { provideNativeDateAdapter } from '@angular/material/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatDatepickerModule } from '@angular/material/datepicker'; - -// Components -import { - AngularInlineDate, - AngularInlineDuration, - AngularInlineTime, - DateTimeRangeGroup, - RangeDay, - RangeEndDay, - RangeStart, - RangeEnd, - RangeLength, - composeDbEntry, - dayToDbEntry, - dayEndToDbEntry, - type DbTimeRange, - type DurationFormat, - type TemporalRangeValue, - type IsoDateRange, -} from 'angular-inline-select/temporal'; -import { InlineMatFormField } from 'angular-inline-select/temporal-mat'; +// Cards — one component per example, each owning its own model/form state. +import { DateCard } from './date-card/date-card'; +import { TimeCard } from './time-card/time-card'; +import { DurationCard } from './duration-card/duration-card'; +import { QuartetCard } from './quartet-card/quartet-card'; +import { QuartetTableCard } from './quartet-table-card/quartet-table-card'; +import { MatTableCard } from './mat-table-card/mat-table-card'; +import { MatQuartetCard } from './mat-quartet-card/mat-quartet-card'; +import { MatBaselineCard } from './mat-baseline-card/mat-baseline-card'; @Component({ selector: 'app-temporal-playground', templateUrl: './temporal-playground.html', styleUrl: './temporal-playground.scss', changeDetection: ChangeDetectionStrategy.Eager, - providers: [provideNativeDateAdapter()], imports: [ - // Material - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatDatepickerModule, - InlineMatFormField, - - // Forms - FormField, - - // Components - AngularInlineDate, - AngularInlineDuration, - AngularInlineTime, - DateTimeRangeGroup, - RangeDay, - RangeEndDay, - RangeStart, - RangeEnd, - RangeLength, + DateCard, + TimeCard, + DurationCard, + QuartetCard, + QuartetTableCard, + MatTableCard, + MatQuartetCard, + MatBaselineCard, ], }) export class TemporalPlayground { - // --------------------------------------------------------------------------- - // Date & date range — ONE form: the single deadline and the ranged vacation - // live in the same card, so the card's toggles (required, locale, touched, - // reset) apply to BOTH fields. - // --------------------------------------------------------------------------- - protected fieldRequired = signal(true); + // The page's locale — owned here because it spans cards: the date card's + // toggle drives it (two-way), the others read it. protected dateLocale = signal<'de' | 'en'>('en'); - protected dateModel = signal<{ due: string | null; vacation: IsoDateRange | null }>({ - due: dayToDbEntry('2026-07-20'), - vacation: { start: dayToDbEntry('2026-07-21'), end: dayEndToDbEntry('2026-07-24') }, - }); - - protected dateForm = form(this.dateModel, (path) => { - required(path.due, { when: () => this.fieldRequired() }); - required(path.vacation, { when: () => this.fieldRequired() }); - }); - - protected dueMissing = computed(() => - this.dateForm.due().errors().some((error) => error.kind === 'required'), - ); - - protected resetDateFields() { - this.dateForm.due().reset(); - this.dateForm.vacation().reset(); - } - - // --------------------------------------------------------------------------- - // Time & time range — ONE form: the single instant and the ranged shift - // share the card's native-picker toggle. Models are full UTC instants - // carrying their day; the shift is seeded OVERNIGHT so the end instant is - // next-day and wears the intrinsic +1 badge. - // --------------------------------------------------------------------------- - protected timeModel = signal<{ starts: string | null; shift: DbTimeRange | null }>({ - starts: composeDbEntry('2026-07-20', '09:30'), - shift: { - start: composeDbEntry('2026-07-21', '22:00'), - end: composeDbEntry('2026-07-22', '01:30'), - }, - }); - protected timeForm = form(this.timeModel); - - /** Native mode: the fields themselves open the OS picker — no 🕐 suffix. */ - protected nativeTimePicker = signal(false); - - // --------------------------------------------------------------------------- - // Duration — form-driven: the model is seconds - // --------------------------------------------------------------------------- - protected durationFormat = signal('h:mm'); - protected durationModel = signal<{ estimate: number | null }>({ estimate: 5400 }); - protected durationForm = form(this.durationModel); - - // --------------------------------------------------------------------------- - // The quartet — T5b: the GROUP is the form control. ONE field, the domain - // shape ({start, end, duration} — DB entries + seconds); the four leaves - // are unbound surfaces the group feeds. Seeded OVERNIGHT: the end instant - // is on the next day, so the end field wears the +1 badge. - // --------------------------------------------------------------------------- - protected stayModel = signal({ - start: composeDbEntry('2026-07-21', '21:00'), - end: composeDbEntry('2026-07-22', '06:00'), - duration: 32_400, - }); - - protected stayForm = form(this.stayModel); - - // --------------------------------------------------------------------------- - // The quartet in a TABLE — five rows, each ROW is one control: the group - // directive sits on the , its value two-way bound per row. A plain - // table, not mat-table: the leaves inject their group through the element - // injector, so they must be template children of the row — matColumnDef - // cell templates are declared on the table, not the row, and would all - // resolve the same group. The night shift is seeded overnight (+1 badge); - // typed overflow hours ("25:15") roll an end the same way. - // --------------------------------------------------------------------------- - protected stayRows: { label: string; value: WritableSignal }[] = [ - { - label: 'Early', - value: signal({ - start: composeDbEntry('2026-07-20', '06:00'), - end: composeDbEntry('2026-07-20', '14:00'), - duration: 28_800, - }), - }, - { - label: 'Core', - value: signal({ - start: composeDbEntry('2026-07-21', '09:00'), - end: composeDbEntry('2026-07-21', '17:30'), - duration: 30_600, - }), - }, - { - label: 'Late', - value: signal({ - start: composeDbEntry('2026-07-22', '13:15'), - end: composeDbEntry('2026-07-22', '21:45'), - duration: 30_600, - }), - }, - { - label: 'Night', - value: signal({ - start: composeDbEntry('2026-07-23', '22:00'), - end: composeDbEntry('2026-07-24', '06:00'), - duration: 28_800, - }), - }, - { - label: 'On-call', - value: signal({ - start: composeDbEntry('2026-07-24', '08:00'), - end: composeDbEntry('2026-07-25', '20:00'), - duration: 129_600, - }), - }, - ]; - - // --------------------------------------------------------------------------- - // The quartet in MAT-FORM-FIELDS (T4): same group, same unbound leaves — - // each hosted by via the temporal-mat adapter. The - // controls stay mat-ignorant; the adapter derives MatFormFieldControl - // from their public signals. - // --------------------------------------------------------------------------- - protected matStayModel = signal({ - start: composeDbEntry('2026-07-21', '21:00'), - end: composeDbEntry('2026-07-22', '06:00'), - duration: 32_400, - }); - - protected matStayForm = form(this.matStayModel); - - /** The page's locale toggle, pinned to 24 h — military time survives `en`. */ - protected militaryLocale = computed(() => `${this.dateLocale()}-u-hc-h23`); - - // Event console: newest first. + // Event console: newest first, fed by every card's `emitted` output. protected emittedEvents = signal([]); - protected logEmit(name: string, payload: unknown) { + protected logEmit({ name, payload }: { name: string; payload: unknown }) { this.emittedEvents.update((events) => [`${name} → ${JSON.stringify(payload)}`, ...events].slice(0, 8), ); diff --git a/projects/app/src/app/pages/temporal-playground/time-card/time-card.html b/projects/app/src/app/pages/temporal-playground/time-card/time-card.html new file mode 100644 index 0000000..677d1a1 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/time-card/time-card.html @@ -0,0 +1,63 @@ +
+

Time & time range — what the user sees vs the model

+

+ The SAME time control twice. Starts at is one [formField]: type “930”, “9”, “21:05” — + the local wall-clock display hides a FULL UTC instant that carries its own day. Shift binds + {{ '{' }} start, end {{ '}' }} — the shape-echo renders the TWO-FIELD pair (no group, no + leaves). The range house rules live INSIDE the control: a typed end is wall-clock intent anchored on the + START's day — at-or-before the start it rolls next-day (the overnight seed wears the intrinsic + +{{ shiftTime.dayOffset() }} badge), overflow hours like “25:15” type the over-count by hand, + and a pasted full ISO datetime stands as-is, never re-anchored. Tab commits the start and lands in the end; + Escape reverts the PAIR. The native-picker toggle below applies to BOTH fields. +

+ + + + + + + + + + + + + + + + + + + + + +
FieldDisplay (local)Model (UTC, SQL-friendly)
Starts at + + {{ timeModel().starts ?? '∅' }}
Shift + + + {{ timeModel().shift?.start ?? '∅' }} + → + {{ timeModel().shift?.end ?? '∅' }} +
+ +
+ +
+
diff --git a/projects/app/src/app/pages/temporal-playground/time-card/time-card.scss b/projects/app/src/app/pages/temporal-playground/time-card/time-card.scss new file mode 100644 index 0000000..6e58920 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/time-card/time-card.scss @@ -0,0 +1,7 @@ +@use '../../demo'; +@use '../model-table'; + +// The card div stays the direct flex child of the page's .example-stack. +:host { + display: contents; +} diff --git a/projects/app/src/app/pages/temporal-playground/time-card/time-card.spec.ts b/projects/app/src/app/pages/temporal-playground/time-card/time-card.spec.ts new file mode 100644 index 0000000..2c04062 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/time-card/time-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimeCard } from './time-card'; + +describe('TimeCard', () => { + let component: TimeCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimeCard], + }).compileComponents(); + + fixture = TestBed.createComponent(TimeCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/time-card/time-card.ts b/projects/app/src/app/pages/temporal-playground/time-card/time-card.ts new file mode 100644 index 0000000..cc4ea39 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/time-card/time-card.ts @@ -0,0 +1,61 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, + input, + output, +} from '@angular/core'; +import { FormField, form } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; + +// Components +import { + AngularInlineTime, + composeDbEntry, + type DbTimeRange, +} from 'angular-inline-select/temporal'; + +/** + * Time & time range — ONE form: the single instant and the ranged shift + * share the card's native-picker toggle. Models are full UTC instants + * carrying their day; the shift is seeded OVERNIGHT so the end instant is + * next-day and wears the intrinsic +1 badge. + */ +@Component({ + selector: 'app-time-card', + templateUrl: './time-card.html', + styleUrl: './time-card.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [MatButtonModule, FormField, AngularInlineTime], +}) +export class TimeCard { + /** The page's locale, owned by the date card's toggle. */ + readonly locale = input<'de' | 'en'>('en'); + + /** Every settled commit, for the page's event console. */ + readonly emitted = output<{ name: string; payload: unknown }>(); + + protected timeModel = signal<{ starts: string | null; shift: DbTimeRange | null }>({ + starts: composeDbEntry('2026-07-20', '09:30'), + shift: { + start: composeDbEntry('2026-07-21', '22:00'), + end: composeDbEntry('2026-07-22', '01:30'), + }, + }); + protected timeForm = form(this.timeModel); + + /** Native mode: the fields themselves open the OS picker — no 🕐 suffix. */ + protected nativeTimePicker = signal(false); + + /** The locale pinned to 24 h — military time survives `en`. */ + protected militaryLocale = computed(() => `${this.locale()}-u-hc-h23`); + + protected logEmit(name: string, payload: unknown) { + this.emitted.emit({ name, payload }); + } +} From 7e62402cf1462897ff14cfd31831356f6a9dfc76 Mon Sep 17 00:00:00 2001 From: Hong Knop Date: Fri, 10 Jul 2026 16:20:48 +0200 Subject: [PATCH 38/48] fix(scroll): absolutely-positioned box no longer contributes to scrollable overflow --- ROADMAP.md | 12 ++++++++++ .../angular-inline-date.scss | 9 ++++++++ .../angular-inline-date.spec.ts | 17 ++++++++++++++ .../angular-inline-duration.scss | 9 ++++++++ .../angular-inline-duration.spec.ts | 18 +++++++++++++++ .../angular-inline-time.scss | 9 ++++++++ .../angular-inline-time.spec.ts | 18 +++++++++++++++ .../mat-table-card/mat-table-card.spec.ts | 22 +++++++++++++++++++ 8 files changed, 114 insertions(+) create mode 100644 projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.spec.ts diff --git a/ROADMAP.md b/ROADMAP.md index db18840..1a9db96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -593,6 +593,18 @@ mat-form-field hosting for all three controls (via iusta's drag/Ctrl+click gestures and the linked `DateTimeRangeGroup` (day/start/end/duration speaking to each other), and datetime+timezones. +### THE PHANTOM-SCROLL TRAP (fixed + guarded 2026-07-10) + +A visually-hidden `position: absolute` element with NO offsets keeps its +STATIC position and contributes scrollable overflow to its CONTAINING +BLOCK — which can resolve far up the tree (nothing in a table cell is +positioned). Hundreds of rows of the temporal controls' 1px aria-live +`__sr` spans inflated a host app's scroller by thousands of px. Every +visually-hidden absolute box now carries `top: 0; left: 0` (documented in +each scss rule), and each temporal control's spec GUARDS it via +`getComputedStyle(sr).top === '0px'` — the guard verifiably fails without +the fix. Apply the same pin to any future visually-hidden element. + ### Manual QA — Safari / iOS pass The `plaintext-only` probe falls back to `contenteditable="true"` + manual diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss index 0c3535a..c76b82c 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss @@ -104,6 +104,15 @@ .inline-date__sr { position: absolute; + // Pinned to the containing block's origin ON PURPOSE: an absolutely + // positioned box with NO offsets keeps its static (in-flow) position and + // contributes scrollable overflow to its CONTAINING BLOCK — which may sit + // far up the tree (nothing in a table cell is positioned). Hundreds of + // rows of these 1px spans inflated the scroller's scrollHeight by + // thousands of px (the flex-table phantom-scroll bug). With offsets the + // box adds ZERO overflow; aria-live announcements don't care where it is. + top: 0; + left: 0; width: 1px; height: 1px; overflow: hidden; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts index 88574b3..92fc7c8 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -664,3 +664,20 @@ describe('AngularInlineDate two-field range', () => { expect(h.panel()).toBeNull(); }); }); + +// ============================================================================= +// Visually-hidden safety — the phantom-scroll regression guard +// ============================================================================= + +describe('the aria-live announcer (visually hidden)', () => { + it('is PINNED to its containing block — an offset-less absolute box keeps its static position and inflates a far-away scroller (the flex-table phantom-scroll bug)', () => { + const h = setupHost(DateFormHost); + const sr = h.fixture.nativeElement.querySelector('.inline-date__sr') as HTMLElement; + expect(sr).not.toBeNull(); + + const style = getComputedStyle(sr); + expect(style.position).toBe('absolute'); + expect(style.top).toBe('0px'); + expect(style.left).toBe('0px'); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss index 96f3042..7ddee8a 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss @@ -73,6 +73,15 @@ .inline-duration__sr { position: absolute; + // Pinned to the containing block's origin ON PURPOSE: an absolutely + // positioned box with NO offsets keeps its static (in-flow) position and + // contributes scrollable overflow to its CONTAINING BLOCK — which may sit + // far up the tree (nothing in a table cell is positioned). Hundreds of + // rows of these 1px spans inflated the scroller's scrollHeight by + // thousands of px (the flex-table phantom-scroll bug). With offsets the + // box adds ZERO overflow; aria-live announcements don't care where it is. + top: 0; + left: 0; width: 1px; height: 1px; overflow: hidden; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts index 13dfb7f..56d9f10 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts @@ -180,3 +180,21 @@ describe('AngularInlineDuration (input rehost)', () => { expect(h.host.sessions).toEqual([{ value: null, changed: true }]); }); }); + + +// ============================================================================= +// Visually-hidden safety — the phantom-scroll regression guard +// ============================================================================= + +describe('the aria-live announcer (visually hidden)', () => { + it('is PINNED to its containing block — an offset-less absolute box keeps its static position and inflates a far-away scroller (the flex-table phantom-scroll bug)', () => { + const h = setup(); + const sr = h.fixture.nativeElement.querySelector('.inline-duration__sr') as HTMLElement; + expect(sr).not.toBeNull(); + + const style = getComputedStyle(sr); + expect(style.position).toBe('absolute'); + expect(style.top).toBe('0px'); + expect(style.left).toBe('0px'); + }); +}); diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss index 762600b..270f3fe 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss @@ -120,6 +120,15 @@ .inline-time__sr { position: absolute; + // Pinned to the containing block's origin ON PURPOSE: an absolutely + // positioned box with NO offsets keeps its static (in-flow) position and + // contributes scrollable overflow to its CONTAINING BLOCK — which may sit + // far up the tree (nothing in a table cell is positioned). Hundreds of + // rows of these 1px spans inflated the scroller's scrollHeight by + // thousands of px (the flex-table phantom-scroll bug). With offsets the + // box adds ZERO overflow; aria-live announcements don't care where it is. + top: 0; + left: 0; width: 1px; height: 1px; overflow: hidden; diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts index f2d522c..52e9570 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -836,3 +836,21 @@ describe('AngularInlineTime with the seconds format', () => { s.fixture.detectChanges(); }); }); + + +// ============================================================================= +// Visually-hidden safety — the phantom-scroll regression guard +// ============================================================================= + +describe('the aria-live announcer (visually hidden)', () => { + it('is PINNED to its containing block — an offset-less absolute box keeps its static position and inflates a far-away scroller (the flex-table phantom-scroll bug)', () => { + const h = setup(); + const sr = h.fixture.nativeElement.querySelector('.inline-time__sr') as HTMLElement; + expect(sr).not.toBeNull(); + + const style = getComputedStyle(sr); + expect(style.position).toBe('absolute'); + expect(style.top).toBe('0px'); + expect(style.left).toBe('0px'); + }); +}); diff --git a/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.spec.ts b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.spec.ts new file mode 100644 index 0000000..ed9dad7 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/mat-table-card/mat-table-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MatTableCard } from './mat-table-card'; + +describe('MatTableCard', () => { + let component: MatTableCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MatTableCard], + }).compileComponents(); + + fixture = TestBed.createComponent(MatTableCard); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); From ac9457319eb94baabc9db926d1a70462debedb50 Mon Sep 17 00:00:00 2001 From: Hong Date: Thu, 16 Jul 2026 22:30:31 +0200 Subject: [PATCH 39/48] refactor(Example): re structure example for better maintenance --- .claude/launch.json | 6 +- .../angular-inline-text.ts | 22 +- .../src/lib/styles/_editable-text.scss | 15 +- projects/app/src/app/app.html | 85 +- projects/app/src/app/app.routes.ts | 60 +- projects/app/src/app/app.scss | 70 +- projects/app/src/app/app.ts | 67 +- projects/app/src/app/docs/api-page.html | 92 ++ projects/app/src/app/docs/api-page.ts | 23 + projects/app/src/app/docs/doc-page.scss | 100 ++ projects/app/src/app/docs/docs-data.ts | 938 ++++++++++++++++++ projects/app/src/app/docs/theming-page.html | 41 + projects/app/src/app/docs/theming-page.ts | 22 + 13 files changed, 1491 insertions(+), 50 deletions(-) create mode 100644 projects/app/src/app/docs/api-page.html create mode 100644 projects/app/src/app/docs/api-page.ts create mode 100644 projects/app/src/app/docs/doc-page.scss create mode 100644 projects/app/src/app/docs/docs-data.ts create mode 100644 projects/app/src/app/docs/theming-page.html create mode 100644 projects/app/src/app/docs/theming-page.ts diff --git a/.claude/launch.json b/.claude/launch.json index 17ea6db..93bd124 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,10 +3,8 @@ "configurations": [ { "name": "app", - "runtimeExecutable": "/Users/hongknop/.nvm/versions/node/v26.1.0/bin/node", - "runtimeArgs": [ - "/private/tmp/claude-501/-Users-hongknop-Documents-private-repo-angular-inline-select/ea149141-dbd9-44c1-ad8d-37c7afcb4f07/scratchpad/static-server.mjs" - ], + "runtimeExecutable": "npm", + "runtimeArgs": ["start", "--", "--port", "4202"], "port": 4202, "autoPort": true } diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts index f32194e..b4af0dd 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -512,18 +512,16 @@ export class AngularInlineText implements FormValueControl { this.#panelPadding.set(Number.isFinite(px) && px >= 0 ? px : PANEL_PADDING_FALLBACK); } - protected panelOverlayConfig = computed( - (): CdkConnectedOverlayConfig => ({ - origin: this.display(), - positions: panelPositions(this.#panelPadding()), - hasBackdrop: true, - backdropClass: 'editable-scrim', - viewportMargin: 16, - push: true, - disableClose: true, // Escape is handled by the panel (revert semantics) - disposeOnNavigation: true, - }), - ); + protected panelOverlayConfig = computed((): CdkConnectedOverlayConfig => ({ + origin: this.display(), + positions: panelPositions(this.#panelPadding()), + hasBackdrop: true, + backdropClass: 'editable-scrim', + viewportMargin: 16, + push: true, + disableClose: true, // Escape is handled by the panel (revert semantics) + disposeOnNavigation: true, + })); // --------------------------------------------------------------------------- // Elevation: pristine display → floating editor diff --git a/projects/angular-inline-select/src/lib/styles/_editable-text.scss b/projects/angular-inline-select/src/lib/styles/_editable-text.scss index 6f93d17..24cffdc 100644 --- a/projects/angular-inline-select/src/lib/styles/_editable-text.scss +++ b/projects/angular-inline-select/src/lib/styles/_editable-text.scss @@ -65,8 +65,11 @@ font-size: inherit; color: inherit; - // Per-line dashed underline that stops where the text stops (multiline-safe) - text-decoration-line: underline; + // Per-line dashed underline that stops where the text stops (multiline-safe). + // `--editable-text-underline: none` opts out of the resting affordance — + // focus-visible and the idle error state re-assert their underlines below, + // so hiding the idle line never hides the a11y/error signals. + text-decoration-line: var(--editable-text-underline, underline); text-decoration-style: dashed; text-decoration-thickness: 0.0625rem; text-underline-offset: 0.4em; @@ -74,8 +77,11 @@ transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); - // Pristine focus affordance: solid underline, nothing moves. + // Pristine focus affordance: solid underline, nothing moves. Explicit + // `underline` — keyboard focus stays visible even when the consumer hides + // the resting affordance via `--editable-text-underline: none`. &:focus-visible { + text-decoration-line: underline; text-decoration-style: solid; text-decoration-thickness: 0.125rem; } @@ -110,7 +116,10 @@ // Idle error state — the mat red-underline analogue: the field is invalid // and the field says errors show (touched / save attempt). Color only — // the dashed style stays, so the field still reads as inline-editable. + // Explicit `underline` — an error never renders invisibly, even when the + // resting affordance is hidden via `--editable-text-underline: none`. .editable-text--invalid & { + text-decoration-line: underline; text-decoration-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); } diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html index 5270aa7..7dcf003 100644 --- a/projects/app/src/app/app.html +++ b/projects/app/src/app/app.html @@ -1,4 +1,8 @@ + + - -
@@ -31,4 +26,74 @@
- + + + + @for (page of pages; track page.path) { + {{ page.label }} + } + + + + + + + + + + + + diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts index 43f945f..0863682 100644 --- a/projects/app/src/app/app.routes.ts +++ b/projects/app/src/app/app.routes.ts @@ -1,25 +1,69 @@ import { Routes } from '@angular/router'; +/** + * Every section carries the same documentation children: the playground at + * the root, plus the registry-driven API and Theming pages. The generic doc + * pages read `data.section` to pick their content from the DOCS registry. + */ +const docChildren = (section: string): Routes => [ + { + path: 'api', + loadComponent: () => import('./docs/api-page').then((m) => m.ApiPage), + data: { section }, + }, + { + path: 'theming', + loadComponent: () => import('./docs/theming-page').then((m) => m.ThemingPage), + data: { section }, + }, +]; + export const routes: Routes = [ { path: 'text', - loadComponent: () => - import('./pages/text-playground/text-playground').then((m) => m.TextPlayground), + children: [ + { + path: '', + loadComponent: () => + import('./pages/text-playground/text-playground').then((m) => m.TextPlayground), + }, + ...docChildren('text'), + ], }, { path: 'number', - loadComponent: () => - import('./pages/number-playground/number-playground').then((m) => m.NumberPlayground), + children: [ + { + path: '', + loadComponent: () => + import('./pages/number-playground/number-playground').then((m) => m.NumberPlayground), + }, + ...docChildren('number'), + ], }, { path: 'phone', - loadComponent: () => - import('./pages/phone-playground/phone-playground').then((m) => m.PhonePlayground), + children: [ + { + path: '', + loadComponent: () => + import('./pages/phone-playground/phone-playground').then((m) => m.PhonePlayground), + }, + ...docChildren('phone'), + ], }, { path: 'temporal', - loadComponent: () => - import('./pages/temporal-playground/temporal-playground').then((m) => m.TemporalPlayground), + children: [ + { + path: '', + loadComponent: () => + import('./pages/temporal-playground/temporal-playground').then( + (m) => m.TemporalPlayground, + ), + }, + ...docChildren('temporal'), + ], }, { path: '', pathMatch: 'full', redirectTo: 'text' }, ]; diff --git a/projects/app/src/app/app.scss b/projects/app/src/app/app.scss index 307f012..6cb8b29 100644 --- a/projects/app/src/app/app.scss +++ b/projects/app/src/app/app.scss @@ -1,5 +1,15 @@ @use '@angular/material' as mat; +// Fixed shell: toolbar on top, the sidenav layout fills the rest. Scrolling +// happens inside mat-sidenav-content (a CdkScrollable, so CDK overlays keep +// repositioning correctly), never on the document. +:host { + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} + .toolbar { @include mat.toolbar-overrides( ( @@ -13,22 +23,17 @@ top: 0; z-index: 10; gap: 8px; + flex: 0 0 auto; } .app-title { font: var(--mat-sys-title-large); + min-width: 0; // allow the single-line title to ellipsize instead of pushing max-width: min(50ch, 50vw); -} - -.toolbar-nav { - display: flex; - gap: 4px; - margin-left: 16px; - .toolbar-nav__active { - background: var(--mat-sys-secondary-container); - color: var(--mat-sys-on-secondary-container); - } + // Reads as plain heading text — no dashed affordance. Keyboard focus (solid + // underline) and any error state still surface, by design of the token. + --editable-text-underline: none; } .spacer { @@ -37,5 +42,50 @@ .toolbar-actions { display: flex; + flex: 0 0 auto; // the actions keep their width; the title yields first gap: 8px; } + +// Phones: the title no longer needs half the bar — cap it so the actions +// (theme toggle + Sign In) always fit and the toolbar never bleeds. +@media (max-width: 599px) { + .app-title { + max-width: 32vw; + } + + .toolbar { + gap: 4px; + } +} + +// ----------------------------------------------------------------------------- +// Sidenav shell +// ----------------------------------------------------------------------------- +.shell { + flex: 1 1 auto; + min-height: 0; + background: transparent; +} + +.shell__nav { + width: 220px; + border-inline-end: 1px solid var(--mat-sys-outline-variant, #e0e0e0); + padding: 8px; +} + +.shell__content { + min-width: 0; +} + +// Section-view tabs (Playground / API / Theming) — pinned to the top of the +// content scroll area, just under the toolbar. Its own row: it never competes +// with the toolbar for horizontal space, and the nav bar paginates on overflow. +.section-tabs { + position: sticky; + top: 0; + z-index: 5; + + padding-inline: 8px; + background: var(--mat-sys-surface); + border-block-end: 1px solid var(--mat-sys-outline-variant, #e0e0e0); +} diff --git a/projects/app/src/app/app.ts b/projects/app/src/app/app.ts index ab02eec..114d22a 100644 --- a/projects/app/src/app/app.ts +++ b/projects/app/src/app/app.ts @@ -6,22 +6,34 @@ import { // Signals signal, computed, + linkedSignal, } from '@angular/core'; import { DOCUMENT } from '@angular/common'; -import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { filter, map } from 'rxjs'; + +// CDK +import { BreakpointObserver } from '@angular/cdk/layout'; // Material import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatListModule } from '@angular/material/list'; +import { MatTabsModule } from '@angular/material/tabs'; import { MatDialog } from '@angular/material/dialog'; // Components import { AngularInlineText } from '../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; +import { PAGES } from './docs/docs-data'; /** - * The shell: sticky toolbar (editable title, page navigation, theme, login) - * around a router outlet. The playgrounds live in lazy pages. + * The shell: sticky toolbar (editable title, section documentation nav, + * theme, login) over a sidenav layout. The left sidenav lists the playground + * pages (over + backdrop ≤1024px, side-by-side above); the toolbar nav is + * contextual to the active section: Playground | API | Theming. */ @Component({ selector: '[app-root]', @@ -38,6 +50,9 @@ import { AngularInlineText } from '../../../angular-inline-select/src/lib/angula MatToolbarModule, MatButtonModule, MatIconModule, + MatSidenavModule, + MatListModule, + MatTabsModule, // Components AngularInlineText, @@ -49,12 +64,58 @@ import { AngularInlineText } from '../../../angular-inline-select/src/lib/angula export class App { #document = inject(DOCUMENT); #dialog = inject(MatDialog); + #router = inject(Router); /** * The toolbar title. Editable in place — and set by the login dialog. */ protected readonly title = signal('Inline Text Playground'); + // --------------------------------------------------------------------------- + // Sidenav: playground pages + // --------------------------------------------------------------------------- + /** The sidenav items — sourced from the docs registry, one entry per playground. */ + protected readonly pages = PAGES; + + /** Narrow viewport (<1024px): the sidenav overlays instead of pushing. */ + #isNarrow = toSignal( + inject(BreakpointObserver) + .observe('(max-width: 1023.98px)') + .pipe(map((state) => state.matches)), + { initialValue: false }, + ); + + protected sidenavMode = computed(() => (this.#isNarrow() ? 'over' : 'side')); + + /** Follows the breakpoint (open on wide, closed on narrow) until the user toggles. */ + protected sidenavOpened = linkedSignal(() => !this.#isNarrow()); + + protected toggleSidenav() { + this.sidenavOpened.update((open) => !open); + } + + /** In over mode a navigation should dismiss the drawer; in side mode it stays. */ + protected handleSidenavNavigation() { + if (this.#isNarrow()) this.sidenavOpened.set(false); + } + + // --------------------------------------------------------------------------- + // Contextual documentation nav: Playground | API | Theming for the section + // --------------------------------------------------------------------------- + #url = toSignal( + this.#router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + map((event) => event.urlAfterRedirects), + ), + { initialValue: this.#router.url }, + ); + + /** The active section — the first URL segment ('text', 'number', …). */ + protected section = computed(() => { + const [segment] = this.#url().split(/[?#]/)[0].split('/').filter(Boolean); + return segment ?? 'text'; + }); + // --------------------------------------------------------------------------- // Login // --------------------------------------------------------------------------- diff --git a/projects/app/src/app/docs/api-page.html b/projects/app/src/app/docs/api-page.html new file mode 100644 index 0000000..0058c33 --- /dev/null +++ b/projects/app/src/app/docs/api-page.html @@ -0,0 +1,92 @@ +
+
+

{{ docs().title }} · API

+

Two-way models, inputs, and outputs of every component in this section.

+
+ + @for (component of docs().components; track component.name) { +
+

+ {{ component.selector }} + {{ component.name }} +

+

{{ component.summary }}

+ + @if (component.models.length > 0) { +

Models (two-way)

+
+ + + + + + + + + + + @for (member of component.models; track member.name) { + + + + + + + } + +
NameTypeDefaultDescription
{{ member.name }}{{ member.type }}{{ member.default }}{{ member.description }}
+
+ } + + @if (component.inputs.length > 0) { +

Inputs

+
+ + + + + + + + + + + @for (member of component.inputs; track member.name) { + + + + + + + } + +
NameTypeDefaultDescription
{{ member.name }}{{ member.type }}{{ member.default }}{{ member.description }}
+
+ } + + @if (component.outputs.length > 0) { +

Outputs (events)

+
+ + + + + + + + + + @for (member of component.outputs; track member.name) { + + + + + + } + +
NamePayloadDescription
{{ member.name }}{{ member.type }}{{ member.description }}
+
+ } +
+ } +
diff --git a/projects/app/src/app/docs/api-page.ts b/projects/app/src/app/docs/api-page.ts new file mode 100644 index 0000000..70088fc --- /dev/null +++ b/projects/app/src/app/docs/api-page.ts @@ -0,0 +1,23 @@ +import { Component, ChangeDetectionStrategy, inject, computed } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { DOCS } from './docs-data'; + +/** + * Generic API documentation page: renders the inputs, two-way models, and + * outputs of every component in the active section, straight from the + * `DOCS` registry — the route's `data.section` picks the section. + */ +@Component({ + selector: 'app-api-page', + templateUrl: './api-page.html', + styleUrl: './doc-page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApiPage { + #route = inject(ActivatedRoute); + #data = toSignal(this.#route.data, { initialValue: this.#route.snapshot.data }); + + protected docs = computed(() => DOCS[this.#data()['section'] as string]); +} diff --git a/projects/app/src/app/docs/doc-page.scss b/projects/app/src/app/docs/doc-page.scss new file mode 100644 index 0000000..379e900 --- /dev/null +++ b/projects/app/src/app/docs/doc-page.scss @@ -0,0 +1,100 @@ +// Shared layout for the API and Theming documentation pages. +:host { + display: block; +} + +.doc { + max-width: 72rem; + margin-inline: auto; + padding: calc(var(--mat-sys-inner-spacing, 16px) * 1.5) var(--mat-sys-inner-spacing, 16px) + calc(var(--mat-sys-inner-spacing, 16px) * 4); +} + +.doc__header { + margin-block-end: calc(var(--mat-sys-inner-spacing, 16px) * 2); + + h1 { + font: var(--mat-sys-headline-medium); + margin: 0 0 0.5rem; + } + + p { + font: var(--mat-sys-body-medium); + color: var(--mat-sys-on-surface-variant); + max-width: 70ch; + margin: 0; + } +} + +.doc__section { + margin-block-end: calc(var(--mat-sys-inner-spacing, 16px) * 3); + + h2 { + font: var(--mat-sys-title-large); + margin: 0 0 0.25rem; + } + + h3 { + font: var(--mat-sys-title-medium); + margin: 1.5rem 0 0.5rem; + } +} + +.doc__selector { + display: flex; + align-items: baseline; + gap: 0.75rem; + flex-wrap: wrap; +} + +.doc__class { + font: var(--mat-sys-body-medium); + color: var(--mat-sys-on-surface-variant); +} + +.doc__summary { + font: var(--mat-sys-body-medium); + color: var(--mat-sys-on-surface-variant); + max-width: 70ch; + margin: 0 0 1rem; +} + +// Wide tables scroll inside their own container, never the page. +.doc__table-scroll { + overflow-x: auto; + border: 1px solid var(--mat-sys-outline-variant, #e0e0e0); + border-radius: var(--mat-sys-corner-medium, 0.5rem); +} + +.doc__table { + width: 100%; + border-collapse: collapse; + font: var(--mat-sys-body-medium); + + th, + td { + text-align: left; + vertical-align: top; + padding: calc(var(--mat-sys-inner-spacing, 16px) * 0.5) var(--mat-sys-inner-spacing, 16px); + border-block-end: 1px solid var(--mat-sys-outline-variant, #e0e0e0); + } + + th { + font: var(--mat-sys-title-small); + color: var(--mat-sys-on-surface-variant); + white-space: nowrap; + } + + tbody tr:last-child td { + border-block-end: none; + } + + code { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace; + font-size: 0.8125em; + background: var(--mat-sys-surface-container, rgba(0, 0, 0, 0.04)); + padding: 0.0625rem 0.3125rem; + border-radius: var(--mat-sys-corner-extra-small, 0.25rem); + overflow-wrap: anywhere; + } +} diff --git a/projects/app/src/app/docs/docs-data.ts b/projects/app/src/app/docs/docs-data.ts new file mode 100644 index 0000000..12d8f1a --- /dev/null +++ b/projects/app/src/app/docs/docs-data.ts @@ -0,0 +1,938 @@ +/** + * The documentation registry — THE single place that grows with the library. + * + * Adding a component: add a page entry to `PAGES`, a `ComponentApi` to its + * section, and its tokens to a token group. Adding a token to an existing + * component: extend its section's token group. The API and Theming pages + * render whatever lives here — no page code changes. + */ + +export interface ApiMember { + name: string; + type: string; + default?: string; + description: string; +} + +export interface ComponentApi { + /** Class name, e.g. `AngularInlineText`. */ + name: string; + selector: string; + summary: string; + /** Two-way bindable `model()` signals. */ + models: ApiMember[]; + inputs: ApiMember[]; + /** `output()` events — the payload goes in `type`. */ + outputs: ApiMember[]; +} + +export interface ThemingToken { + token: string; + /** The rest of the resolution chain when the token is unset. */ + fallback: string; + description: string; +} + +export interface TokenGroup { + title: string; + description?: string; + tokens: ThemingToken[]; +} + +export interface SectionDocs { + title: string; + components: ComponentApi[]; + tokenGroups: TokenGroup[]; +} + +/** The sidenav source of truth: every playground page, in nav order. */ +export const PAGES = [ + { path: 'text', label: 'Text' }, + { path: 'number', label: 'Number' }, + { path: 'phone', label: 'Phone' }, + { path: 'temporal', label: 'Temporal' }, +] as const; + +// ----------------------------------------------------------------------------- +// Shared API rows — the Form Value Contract every inline control implements. +// ----------------------------------------------------------------------------- +const FORM_CONTRACT_INPUTS: ApiMember[] = [ + { + name: 'disabled', + type: 'boolean', + default: 'false', + description: 'Form Value Contract: the control is not interactive.', + }, + { + name: 'readonly', + type: 'boolean', + default: 'false', + description: 'Form Value Contract: visible and focusable, but not editable.', + }, + { + name: 'required', + type: 'boolean', + default: 'false', + description: + 'Form Value Contract: marks the field required (also hides the clear bubble — a guaranteed-doomed clear stays unavailable).', + }, + { + name: 'errors', + type: 'readonly ValidationError.WithOptionalFieldTree[]', + default: '[]', + description: + 'Form Value Contract: validation errors from the bound field. Message-carrying errors render in the panel footer when errors are visible.', + }, + { + name: 'invalid', + type: 'boolean', + default: 'false', + description: "Form Value Contract: the bound field's verdict on validity.", + }, + { + name: 'touched', + type: 'boolean', + default: 'false', + description: + 'Form Value Contract: the bound field dictates when errors may show (the `form.submitted` half of the ErrorStateMatcher analogue).', + }, + { + name: 'hidden', + type: 'boolean', + default: 'false', + description: 'Form Value Contract: removes the control from the layout (`display: none`).', + }, +]; + +const TOUCH_OUTPUT: ApiMember = { + name: 'touch', + type: 'void', + description: + 'Form Value Contract: emitted on the closing edge of an edit session (the blur analogue), on a failed save attempt, and on clear.', +}; + +const AFFIX_INPUTS: ApiMember[] = [ + { + name: 'prefixTemplate', + type: 'TemplateRef | undefined', + default: 'undefined', + description: + 'Prefix template (matPrefix analogue) for composition; direct consumers use `ng-template[editablePrefix]` content instead. Rendered aria-hidden, outside the editable text.', + }, + { + name: 'suffixTemplate', + type: 'TemplateRef | undefined', + default: 'undefined', + description: + 'Suffix template (matSuffix analogue) for composition; direct consumers use `ng-template[editableSuffix]` content instead.', + }, +]; + +const ARIA_LABEL_INPUT: ApiMember = { + name: 'ariaLabel', + type: 'string | undefined', + default: 'undefined', + description: + 'Accessible name for the field — contenteditable has no native label association. Falls back to the placeholder.', +}; + +const EDITING_MODEL: ApiMember = { + name: 'editing', + type: 'boolean', + default: 'false', + description: 'Whether an edit session is open (the field is elevated). Two-way bindable.', +}; + +// ----------------------------------------------------------------------------- +// Shared token groups +// ----------------------------------------------------------------------------- + +/** Tokens of the inline-text surfaces — also picked up by number and phone, which render through the same surfaces. */ +const TEXT_SURFACE_TOKENS: TokenGroup = { + title: 'Field surfaces', + description: + 'The in-flow display and the elevated editor. Resolution order: ' + + 'var(--editable-text-, var(--mat-sys-, )).', + tokens: [ + { + token: '--editable-text-underline', + fallback: 'underline', + description: + 'The resting dashed underline’s text-decoration-line. Set to `none` to hide the inline-editable affordance; keyboard focus (solid underline) and the idle error underline re-assert themselves and stay visible.', + }, + { + token: '--editable-text-underline-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Color of the dashed affordance underline (and the solid focus underline).', + }, + { + token: '--editable-text-color', + fallback: 'inherit', + description: 'Text color of a filled (non-empty) field.', + }, + { + token: '--editable-text-error-color', + fallback: 'var(--mat-sys-error, #dc3545)', + description: 'Underline color while the field is invalid and errors are visible.', + }, + { + token: '--editable-text-placeholder-opacity', + fallback: '0.3875', + description: 'Opacity of the placeholder (shown when the field is empty).', + }, + { + token: '--editable-text-affix-color', + fallback: 'var(--mat-sys-on-surface-variant, inherit)', + description: 'Color of prefix/suffix affixes.', + }, + { + token: '--editable-text-dim-opacity', + fallback: '0.35', + description: 'Opacity of the in-flow field while its elevated editor is open.', + }, + { + token: '--editable-text-editor-color', + fallback: 'var(--mat-sys-on-surface, inherit)', + description: 'Text color inside the elevated editor.', + }, + { + token: '--editable-text-caret-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Caret color of the editable surfaces.', + }, + { + token: '--editable-ease-standard', + fallback: 'cubic-bezier(0.4, 0, 0.2, 1)', + description: 'Easing for the field opacity transitions.', + }, + ], +}; + +const CHROME_TOKENS: TokenGroup = { + title: 'Shared chrome (panel, scrim, menu, messages, actions)', + description: 'The elevated-editing chrome every inline control shares.', + tokens: [ + { + token: '--editable-scrim-color', + fallback: 'oklch(from var(--mat-sys-surface) l c h / 0.55)', + description: 'Backdrop behind the elevated panel.', + }, + { + token: '--editable-panel-width', + fallback: 'min(60ch, calc(100dvw - 2 * var(--mat-sys-inner-spacing, 16px)))', + description: 'The panel’s readable measure — a constant, never measured.', + }, + { + token: '--editable-panel-background', + fallback: 'var(--mat-sys-surface-container, #fff)', + description: 'Panel card background.', + }, + { + token: '--editable-panel-border-color', + fallback: 'color-mix(in oklch, var(--mat-sys-on-surface, #000) 20%, var(--mat-sys-surface-container, #fff))', + description: 'Panel card border.', + }, + { + token: '--editable-panel-radius', + fallback: 'var(--mat-sys-corner-large, var(--radius, 0.625rem))', + description: 'Panel corner radius.', + }, + { + token: '--editable-panel-shadow', + fallback: 'layered soft shadow (4 stops)', + description: 'Panel elevation shadow.', + }, + { + token: '--editable-menu-max-height', + fallback: '40vh', + description: 'Max height of the slash-command menu before it scrolls.', + }, + { + token: '--editable-menu-active-background', + fallback: 'var(--mat-sys-secondary-container, #d7e3ff)', + description: 'Background of the keyboard-active menu option.', + }, + { + token: '--editable-menu-active-color', + fallback: 'var(--mat-sys-on-secondary-container, #001b3f)', + description: 'Text color of the keyboard-active menu option.', + }, + { + token: '--editable-message-error-color', + fallback: 'var(--mat-sys-error, #dc3545)', + description: 'Color of control-rendered error messages in the panel footer.', + }, + { + token: '--editable-message-hint-color', + fallback: 'var(--mat-sys-outline, #6b7280)', + description: 'Color of hint messages (live hints, “Unsaved changes”).', + }, + { + token: '--editable-error-font', + fallback: 'var(--mat-sys-body-small-font, inherit)', + description: 'Font family of projected [editable-error] content.', + }, + { + token: '--editable-error-size', + fallback: 'var(--mat-sys-body-small-size, 0.75rem)', + description: 'Font size of projected [editable-error] content.', + }, + { + token: '--editable-error-weight', + fallback: 'var(--mat-sys-body-small-weight, 400)', + description: 'Font weight of projected [editable-error] content.', + }, + { + token: '--editable-error-line-height', + fallback: 'var(--mat-sys-body-small-line-height, 1rem)', + description: 'Line height of projected [editable-error] content.', + }, + { + token: '--editable-error-tracking', + fallback: 'var(--mat-sys-body-small-tracking, 0.025rem)', + description: 'Letter spacing of projected [editable-error] content.', + }, + { + token: '--editable-error-color', + fallback: 'var(--mat-sys-error, #dc3545)', + description: 'Color of projected [editable-error] content.', + }, + { + token: '--editable-ease-emphasized', + fallback: 'cubic-bezier(0, 0, 0.2, 1)', + description: 'Easing for panel lift, message and bubble enter animations.', + }, + { + token: '--editable-bubble-pad', + fallback: 'calc(var(--mat-sys-inner-spacing, 16px) * 0.75)', + description: 'Transparent pad around the floating bubble — the visual gap and the forgiving hit halo.', + }, + { + token: '--editable-text-action-background', + fallback: 'oklch(from var(--mat-sys-surface-container-highest, #eee) l c h / 0.75)', + description: 'Background of the pill action buttons (Discard, Clear).', + }, + { + token: '--editable-text-action-color', + fallback: 'var(--mat-sys-on-surface-variant, #5f6368)', + description: 'Text color of the pill action buttons.', + }, + { + token: '--editable-text-action-hover-background', + fallback: 'var(--mat-sys-surface-container-highest, #eee)', + description: 'Hover background of the pill action buttons.', + }, + { + token: '--editable-text-action-hover-color', + fallback: 'var(--mat-sys-on-surface-variant, #5f6368)', + description: 'Hover text color of the pill action buttons.', + }, + { + token: '--editable-text-action-save-background', + fallback: 'var(--mat-sys-primary, #4285f4)', + description: 'Background of the Save button.', + }, + { + token: '--editable-text-action-save-color', + fallback: 'var(--mat-sys-on-primary, #fff)', + description: 'Text color of the Save button.', + }, + { + token: '--editable-text-action-save-hover-background', + fallback: 'var(--mat-sys-primary, #4285f4)', + description: 'Hover background of the Save button.', + }, + { + token: '--editable-text-action-save-hover-color', + fallback: 'var(--mat-sys-on-primary, #fff)', + description: 'Hover text color of the Save button.', + }, + ], +}; + +/** The temporal components style their own surfaces but consume the same token names. */ +const TEMPORAL_TOKENS: TokenGroup = { + title: 'Temporal surfaces', + description: + 'Date, time and duration render their own field surfaces but resolve the same --editable-text-* names, so a theme written for the text field carries over.', + tokens: [ + { + token: '--editable-text-underline-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Dashed border-bottom affordance color.', + }, + { + token: '--editable-text-error-color', + fallback: 'var(--mat-sys-error, #dc3545)', + description: 'Border-bottom color while invalid and errors are visible.', + }, + { + token: '--editable-text-caret-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Caret color of the editable segments.', + }, + { + token: '--editable-text-placeholder-opacity', + fallback: '0.3875', + description: 'Opacity of empty-segment placeholders.', + }, + { + token: '--editable-text-affix-color', + fallback: 'var(--mat-sys-on-surface-variant, inherit)', + description: 'Color of prefix/suffix affixes.', + }, + { + token: '--editable-panel-container-color', + fallback: 'var(--mat-sys-surface-container, #fff)', + description: 'Background of the temporal picker containers (calendar, time list).', + }, + ], +}; + +// ----------------------------------------------------------------------------- +// Sections +// ----------------------------------------------------------------------------- + +export const DOCS: Record = { + text: { + title: 'Inline Text', + components: [ + { + name: 'AngularInlineText', + selector: 'angular-inline-text', + summary: + 'A static in-flow text that elevates into a floating editor on the first real edit. The page never reflows while typing; the value commits on Save / Enter (single-line) / Ctrl+Enter.', + models: [ + { + name: 'value', + type: 'string', + default: "''", + description: + 'The committed value channel. Follows every keystroke while a session is open (live draft), settles on commit, and rolls back on discard.', + }, + EDITING_MODEL, + ], + inputs: [ + ...FORM_CONTRACT_INPUTS, + { + name: 'isSingleLine', + type: 'boolean', + default: 'false', + description: + 'Single-line mode: strips line breaks, accepts on Enter, ellipsizes instead of wrapping.', + }, + { + name: 'placeholder', + type: 'string', + default: "'N/A'", + description: 'Placeholder shown while empty; also the aria-label fallback.', + }, + ARIA_LABEL_INPUT, + { + name: 'inputMode', + type: 'string | undefined', + default: 'undefined', + description: + "Virtual-keyboard hint for mobile ('decimal', 'tel', 'email', …) applied to both editable surfaces.", + }, + { + name: 'normalizeValue', + type: 'boolean', + default: 'false', + description: + 'Trims leading/trailing whitespace on commit. Interior spacing is never touched.', + }, + ...AFFIX_INPUTS, + { + name: 'hintTemplate', + type: 'TemplateRef | undefined', + default: 'undefined', + description: + 'Live per-keystroke feedback rendered in the panel footer; direct consumers use `ng-template[editableHint]` content instead.', + }, + { + name: 'menuTemplate', + type: 'TemplateRef | undefined', + default: 'undefined', + description: + 'Slash-command menu template — dormant unless provided. The consumer owns options and filtering; the control owns trigger, keyboard navigation, and combobox ARIA. Content sugar: `ng-template[editableMenu]`.', + }, + ], + outputs: [ + { + name: 'savedModelChange', + type: '{ value: string }', + description: + 'THE consumer commit event: fires once per changed settlement (accept-timed, change-gated) with the model.', + }, + { + name: 'saved', + type: 'InlineTextSaved — { value: string; changed: boolean }', + description: + 'The machinery channel: exactly one emission per settled edit session — Save, Discard, and clear alike. For wrapping controls and adapters; app consumers bind savedModelChange.', + }, + TOUCH_OUTPUT, + { + name: 'reverted', + type: 'string', + description: + 'Emitted when a draft is discarded, with the discarded draft text. Deprecated — superseded by `saved`; kept during the Roadmap Phase 3 transition.', + }, + ], + }, + ], + tokenGroups: [TEXT_SURFACE_TOKENS, CHROME_TOKENS], + }, + + number: { + title: 'Inline Number', + components: [ + { + name: 'AngularInlineNumber', + selector: 'angular-inline-number', + summary: + 'The inline-text machinery specialized for numbers: a parse/format codec pair turns the drafted string into a numeric model on commit.', + models: [ + { + name: 'value', + type: 'number | string | null', + default: 'null', + description: 'The committed numeric value (string passthrough for unparseable drafts).', + }, + EDITING_MODEL, + ], + inputs: [ + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string', + default: "'N/A'", + description: 'Placeholder shown while empty.', + }, + ARIA_LABEL_INPUT, + { + name: 'parse', + type: '(raw: string) => number | null | undefined', + default: 'defaultParseNumber', + description: + 'Draft → number. Return undefined to reject the draft (parse error), null for an intentional empty.', + }, + { + name: 'format', + type: '(value: number | null) => string', + default: 'defaultFormatNumber', + description: 'Number → display string for the in-flow text.', + }, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: '{ value: number | null }', + description: 'The consumer commit event: once per changed settlement, with the numeric model.', + }, + { + name: 'saved', + type: 'InlineNumberSaved', + description: 'One emission per settled edit session, changed or not.', + }, + TOUCH_OUTPUT, + ], + }, + ], + tokenGroups: [TEXT_SURFACE_TOKENS, CHROME_TOKENS], + }, + + phone: { + title: 'Inline Phone', + components: [ + { + name: 'AngularInlinePhone', + selector: 'angular-inline-phone', + summary: + 'Phone editing on the inline-text machinery: a pluggable codec (e.g. libphonenumber) parses/formats E.164 values, with a flag affordance and a country slash-menu.', + models: [ + { + name: 'value', + type: 'string | null', + default: 'null', + description: 'The committed phone number in E.164, or null when empty.', + }, + ], + inputs: [ + { + name: 'codec', + type: 'PhoneCodec', + default: '— (required)', + description: 'The parsing/formatting engine. Required — the component ships no engine of its own.', + }, + { + name: 'defaultCountry', + type: 'PhoneCountry | undefined', + default: 'undefined', + description: 'Country assumed for national-format input.', + }, + { + name: 'displayFormat', + type: "'national' | 'international'", + default: "'international'", + description: 'How the committed value renders in flow.', + }, + { + name: 'numberKind', + type: 'PhoneNumberKind', + default: "'fixed-or-mobile'", + description: 'Which kinds of numbers validate.', + }, + { + name: 'showFlag', + type: 'boolean', + default: 'true', + description: 'Show the country flag prefix affordance.', + }, + { + name: 'showCountryMenu', + type: 'boolean', + default: 'true', + description: 'Enable the `/` country slash-menu inside the editor.', + }, + { + name: 'menuLocale', + type: 'string | string[] | undefined', + default: 'undefined', + description: 'Locale(s) for country display names in the menu.', + }, + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string | undefined', + default: 'undefined', + description: 'Placeholder shown while empty.', + }, + ARIA_LABEL_INPUT, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: '{ value: string | null }', + description: 'The consumer commit event: once per changed settlement, with the E.164 model.', + }, + { + name: 'saved', + type: 'InlinePhoneSaved', + description: 'One emission per settled edit session, changed or not.', + }, + TOUCH_OUTPUT, + ], + }, + ], + tokenGroups: [TEXT_SURFACE_TOKENS, CHROME_TOKENS], + }, + + temporal: { + title: 'Temporal', + components: [ + { + name: 'AngularInlineDate', + selector: 'angular-inline-date', + summary: + 'Inline date (and date-range) editing with an optional calendar overlay and quick-pick commands.', + models: [ + { + name: 'value', + type: 'InlineDateValue', + default: 'null', + description: 'The committed date or date range.', + }, + EDITING_MODEL, + { + name: 'overlayOrigin', + type: 'ElementRef | HTMLElement | null', + default: 'null', + description: 'External anchor for the calendar overlay (defaults to the field itself).', + }, + ], + inputs: [ + { + name: 'ranged', + type: 'boolean', + default: 'false', + description: 'Range mode: start and end dates.', + }, + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string | undefined', + default: 'undefined', + description: 'Placeholder for the (start) date.', + }, + { + name: 'endPlaceholder', + type: 'string | undefined', + default: 'undefined', + description: 'Placeholder for the end date in range mode.', + }, + ARIA_LABEL_INPUT, + { + name: 'clearBubbleSide', + type: 'BubbleMenuSide | undefined', + default: 'undefined', + description: "Which edge the clear bubble grows from. Unset, the leaf role decides ('start' for inline-start leaves), else 'end'.", + }, + { + name: 'locale', + type: 'string | string[] | undefined', + default: 'undefined', + description: 'Locale(s) for parsing and formatting.', + }, + { + name: 'zone', + type: 'string | undefined', + default: 'undefined', + description: 'IANA time zone for “today” resolution.', + }, + { + name: 'showCalendar', + type: 'boolean', + default: 'true', + description: 'Show the calendar overlay while editing.', + }, + { + name: 'quickPicks', + type: 'readonly DateCommand[] | undefined', + default: 'undefined', + description: 'Quick-pick commands (Today, Tomorrow, …) offered in the editor.', + }, + { + name: 'now', + type: '() => Date', + default: '() => new Date()', + description: 'Clock source — injectable for tests and fixed-time demos.', + }, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: 'DateSavedDetails', + description: 'The consumer commit event with the date details model.', + }, + { + name: 'saved', + type: 'InlineDateSaved', + description: 'One emission per settled edit session, changed or not.', + }, + TOUCH_OUTPUT, + ], + }, + { + name: 'AngularInlineTime', + selector: 'angular-inline-time', + summary: 'Inline time (and time-range) editing with an optional picker list or native input.', + models: [ + { + name: 'value', + type: 'InlineTimeValue', + default: 'null', + description: 'The committed time or time range.', + }, + EDITING_MODEL, + ], + inputs: [ + { + name: 'ranged', + type: 'boolean', + default: 'false', + description: 'Range mode: start and end times.', + }, + { + name: 'format', + type: "'HH:mm' | 'HH:mm:ss'", + default: "'HH:mm'", + description: 'Display and parse precision.', + }, + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string', + default: "'time'", + description: 'Placeholder for the (start) time.', + }, + { + name: 'endPlaceholder', + type: 'string | undefined', + default: 'undefined', + description: 'Placeholder for the end time in range mode.', + }, + ARIA_LABEL_INPUT, + { + name: 'clearBubbleSide', + type: 'BubbleMenuSide | undefined', + default: 'undefined', + description: "Which edge the clear bubble grows from. Unset, the leaf role decides ('start' for inline-start leaves), else 'end'.", + }, + { + name: 'locale', + type: 'string | string[] | undefined', + default: 'undefined', + description: 'Locale(s) for parsing and formatting.', + }, + { + name: 'zone', + type: 'string | undefined', + default: 'undefined', + description: 'IANA time zone for “now” resolution.', + }, + { + name: 'step', + type: 'number', + default: '60', + description: 'Granularity of the native picker, in seconds (forwarded to its `step`).', + }, + { + name: 'pickerMin', + type: 'string | undefined', + default: 'undefined', + description: "Native picker lower bound ('HH:mm'), forwarded to the OS input's `min`.", + }, + { + name: 'pickerMax', + type: 'string | undefined', + default: 'undefined', + description: "Native picker upper bound ('HH:mm'), forwarded to the OS input's `max`.", + }, + { + name: 'native', + type: 'boolean', + default: 'false', + description: 'Use the native time input instead of the picker list.', + }, + { + name: 'now', + type: '() => Date', + default: '() => new Date()', + description: 'Clock source — injectable for tests and fixed-time demos.', + }, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: 'TimeSavedDetails', + description: 'The consumer commit event with the time details model.', + }, + { + name: 'saved', + type: 'InlineTimeSaved', + description: 'One emission per settled edit session, changed or not.', + }, + TOUCH_OUTPUT, + ], + }, + { + name: 'AngularInlineDuration', + selector: 'angular-inline-duration', + summary: 'Inline duration editing (h:mm and friends) committing a minute count.', + models: [ + { + name: 'value', + type: 'number | null', + default: 'null', + description: 'The committed duration in seconds, or null.', + }, + EDITING_MODEL, + ], + inputs: [ + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string', + default: "'0:00'", + description: 'Placeholder shown while empty.', + }, + ARIA_LABEL_INPUT, + { + name: 'clearBubbleSide', + type: 'BubbleMenuSide | undefined', + default: 'undefined', + description: "Which edge the clear bubble grows from. Unset, the leaf role decides ('start' for inline-start leaves), else 'end'.", + }, + { + name: 'durationFormat', + type: 'DurationFormat', + default: "'h:mm'", + description: 'How colon notation reads and how committed values render.', + }, + { + name: 'step', + type: 'number', + default: '1', + description: 'Snap committed values to a multiple of this many seconds (1 = off).', + }, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: 'DurationSavedDetails', + description: 'The consumer commit event with the duration details model.', + }, + { + name: 'saved', + type: 'InlineDurationSaved', + description: 'One emission per settled edit session, changed or not.', + }, + TOUCH_OUTPUT, + ], + }, + { + name: 'TemporalRangeGroup', + selector: '[temporalRangeGroup]', + summary: + 'Composes independent date/time/duration fields into one coherent range model via the range* item directives (rangeDay, rangeStart, rangeEnd, rangeTimes, rangeEndDay, rangeLength).', + models: [ + { + name: 'value', + type: 'TemporalRangeValue | null', + default: 'null', + description: 'The composed range model.', + }, + ], + inputs: [ + { + name: 'zone', + type: 'string | undefined', + default: 'undefined', + description: 'IANA time zone the composition math runs in.', + }, + ...FORM_CONTRACT_INPUTS.filter((m) => !['required', 'hidden'].includes(m.name)), + ], + outputs: [ + { + name: 'savedModelChange', + type: 'TemporalRangeValue | null', + description: 'The consumer commit event with the composed range.', + }, + { + name: 'dateRangeChange', + type: 'ComposedDateRange | null', + description: 'Composed date range, on every settlement.', + }, + { + name: 'timeRangeChange', + type: 'ComposedTimeRange | null', + description: 'Composed time range, on every settlement.', + }, + { + name: 'durationChange', + type: 'number | null', + description: 'Composed duration in minutes, on every settlement.', + }, + TOUCH_OUTPUT, + ], + }, + ], + tokenGroups: [TEMPORAL_TOKENS, CHROME_TOKENS], + }, +}; diff --git a/projects/app/src/app/docs/theming-page.html b/projects/app/src/app/docs/theming-page.html new file mode 100644 index 0000000..1b95703 --- /dev/null +++ b/projects/app/src/app/docs/theming-page.html @@ -0,0 +1,41 @@ +
+
+

{{ docs().title }} · Theming

+

+ Every visual property resolves through the token chain + var(--editable-<token>, var(--mat-sys-<token>, <fallback>)) — set the + --editable-* custom property on any ancestor to override; leave it unset and the + Material system token (or its hardcoded fallback) applies. +

+
+ + @for (group of docs().tokenGroups; track group.title) { +
+

{{ group.title }}

+ @if (group.description) { +

{{ group.description }}

+ } + +
+ + + + + + + + + + @for (token of group.tokens; track token.token) { + + + + + + } + +
TokenFallbackDescription
{{ token.token }}{{ token.fallback }}{{ token.description }}
+
+
+ } +
diff --git a/projects/app/src/app/docs/theming-page.ts b/projects/app/src/app/docs/theming-page.ts new file mode 100644 index 0000000..ccbb623 --- /dev/null +++ b/projects/app/src/app/docs/theming-page.ts @@ -0,0 +1,22 @@ +import { Component, ChangeDetectionStrategy, inject, computed } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; + +import { DOCS } from './docs-data'; + +/** + * Generic Theming documentation page: renders every CSS custom property the + * active section's components resolve, grouped as in the `DOCS` registry. + */ +@Component({ + selector: 'app-theming-page', + templateUrl: './theming-page.html', + styleUrl: './doc-page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ThemingPage { + #route = inject(ActivatedRoute); + #data = toSignal(this.#route.data, { initialValue: this.#route.snapshot.data }); + + protected docs = computed(() => DOCS[this.#data()['section'] as string]); +} From 7bd8ba2fae5ef8591e504d61979e110568eb80a6 Mon Sep 17 00:00:00 2001 From: Hong Date: Fri, 17 Jul 2026 22:41:51 +0200 Subject: [PATCH 40/48] feat(AngularInlineJson): added new editable to edit json --- angular.json | 1 + package-lock.json | 119 ++++ package.json | 7 + .../json/ng-package.json | 5 + .../json/src/angular-inline-json.html | 50 ++ .../json/src/angular-inline-json.scss | 3 + .../json/src/angular-inline-json.spec.ts | 297 ++++++++ .../json/src/angular-inline-json.ts | 641 ++++++++++++++++++ .../json/src/json-codec.spec.ts | 106 +++ .../json/src/json-codec.ts | 133 ++++ .../json/src/json-doc.spec.ts | 170 +++++ .../json/src/json-doc.ts | 288 ++++++++ .../json/src/json-editor.ts | 169 +++++ .../json/src/json-preview.spec.ts | 32 + .../json/src/json-preview.ts | 160 +++++ .../json/src/json-session.html | 55 ++ .../json/src/json-session.ts | 103 +++ .../json/src/public-api.ts | 15 + projects/angular-inline-select/package.json | 30 +- .../lib/angular-inline-text/editable-error.ts | 21 +- .../src/lib/styles/_editable-dialog.scss | 118 ++++ .../src/lib/styles/_editable-json.scss | 214 ++++++ .../src/lib/styles/_editable-scrollbar.scss | 98 +++ .../src/lib/styles/_index.scss | 13 +- .../editable-dialog/editable-dialog.spec.ts | 137 ++++ .../utils/editable-dialog/editable-dialog.ts | 161 +++++ .../angular-inline-select/src/public-api.ts | 2 + .../angular-inline-select/tsconfig.lib.json | 1 + .../angular-inline-select/tsconfig.spec.json | 2 + projects/app/src/app/app.routes.ts | 11 + projects/app/src/app/docs/docs-data.ts | 159 +++++ .../json-playground/json-playground.html | 85 +++ .../json-playground/json-playground.scss | 1 + .../pages/json-playground/json-playground.ts | 77 +++ tsconfig.json | 1 + 35 files changed, 3481 insertions(+), 4 deletions(-) create mode 100644 projects/angular-inline-select/json/ng-package.json create mode 100644 projects/angular-inline-select/json/src/angular-inline-json.html create mode 100644 projects/angular-inline-select/json/src/angular-inline-json.scss create mode 100644 projects/angular-inline-select/json/src/angular-inline-json.spec.ts create mode 100644 projects/angular-inline-select/json/src/angular-inline-json.ts create mode 100644 projects/angular-inline-select/json/src/json-codec.spec.ts create mode 100644 projects/angular-inline-select/json/src/json-codec.ts create mode 100644 projects/angular-inline-select/json/src/json-doc.spec.ts create mode 100644 projects/angular-inline-select/json/src/json-doc.ts create mode 100644 projects/angular-inline-select/json/src/json-editor.ts create mode 100644 projects/angular-inline-select/json/src/json-preview.spec.ts create mode 100644 projects/angular-inline-select/json/src/json-preview.ts create mode 100644 projects/angular-inline-select/json/src/json-session.html create mode 100644 projects/angular-inline-select/json/src/json-session.ts create mode 100644 projects/angular-inline-select/json/src/public-api.ts create mode 100644 projects/angular-inline-select/src/lib/styles/_editable-dialog.scss create mode 100644 projects/angular-inline-select/src/lib/styles/_editable-json.scss create mode 100644 projects/angular-inline-select/src/lib/styles/_editable-scrollbar.scss create mode 100644 projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.spec.ts create mode 100644 projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.ts create mode 100644 projects/app/src/app/pages/json-playground/json-playground.html create mode 100644 projects/app/src/app/pages/json-playground/json-playground.scss create mode 100644 projects/app/src/app/pages/json-playground/json-playground.ts diff --git a/angular.json b/angular.json index 46f9b8c..dd03b86 100644 --- a/angular.json +++ b/angular.json @@ -97,6 +97,7 @@ "include": [ "**/*.spec.ts", "../phone/src/**/*.spec.ts", + "../json/src/**/*.spec.ts", "../temporal/src/**/*.spec.ts", "../temporal-mat/src/**/*.spec.ts" ] diff --git a/package-lock.json b/package-lock.json index 98940fc..68d7a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,13 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", + "@chenglou/pretext": "0.0.8", + "@codemirror/commands": "6.10.4", + "@codemirror/language": "6.12.4", + "@codemirror/lint": "6.9.7", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.6", + "@lezer/highlight": "1.2.3", "libphonenumber-js": "^1.13.8", "luxon": "^3.7.2", "rxjs": "~7.8.0", @@ -1678,6 +1685,70 @@ "specificity": "bin/cli.js" } }, + "node_modules/@chenglou/pretext": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.8.tgz", + "integrity": "sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==", + "license": "MIT" + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", @@ -2886,6 +2957,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.3.tgz", @@ -3001,6 +3096,12 @@ "win32" ] }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -5751,6 +5852,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9628,6 +9735,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -10612,6 +10725,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index e1b6988..2fc7d60 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,13 @@ "@angular/material": "^22.0.2", "@angular/platform-browser": "^22.0.3", "@angular/router": "^22.0.3", + "@chenglou/pretext": "0.0.8", + "@codemirror/commands": "6.10.4", + "@codemirror/language": "6.12.4", + "@codemirror/lint": "6.9.7", + "@codemirror/state": "6.7.1", + "@codemirror/view": "6.43.6", + "@lezer/highlight": "1.2.3", "libphonenumber-js": "^1.13.8", "luxon": "^3.7.2", "rxjs": "~7.8.0", diff --git a/projects/angular-inline-select/json/ng-package.json b/projects/angular-inline-select/json/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/angular-inline-select/json/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/angular-inline-select/json/src/angular-inline-json.html b/projects/angular-inline-select/json/src/angular-inline-json.html new file mode 100644 index 0000000..79a673c --- /dev/null +++ b/projects/angular-inline-select/json/src/angular-inline-json.html @@ -0,0 +1,50 @@ + + + @if (prefixTpl(); as prefix) { + + } + + + @if (isEmpty()) { + {{ placeholder() }} + } @else { + {{ displayedPreview() }} + } + + + @if (suffixTpl(); as suffix) { + + } + + + + + + + + diff --git a/projects/angular-inline-select/json/src/angular-inline-json.scss b/projects/angular-inline-select/json/src/angular-inline-json.scss new file mode 100644 index 0000000..b59db76 --- /dev/null +++ b/projects/angular-inline-select/json/src/angular-inline-json.scss @@ -0,0 +1,3 @@ +// The preview block, the elevated editor, and the panel action buttons share +// the global `.editable-json*` / `.editable-action*` chrome in +// styles/_editable-json.scss and styles/_editable.scss. diff --git a/projects/angular-inline-select/json/src/angular-inline-json.spec.ts b/projects/angular-inline-select/json/src/angular-inline-json.spec.ts new file mode 100644 index 0000000..3dd1d4a --- /dev/null +++ b/projects/angular-inline-select/json/src/angular-inline-json.spec.ts @@ -0,0 +1,297 @@ +import { ApplicationRef, Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EditorView } from '@codemirror/view'; + +import { AngularInlineJson, type InlineJsonSaved } from './angular-inline-json'; + +@Component({ + imports: [AngularInlineJson], + template: ` + + `, +}) +class ValueBindingHost { + value = signal(''); + editing = signal(false); + disabled = signal(false); + + saved: { value: string }[] = []; + sessions: InlineJsonSaved[] = []; + touchCount = 0; +} + +function setup() { + TestBed.configureTestingModule({ imports: [ValueBindingHost] }); + const fixture: ComponentFixture = TestBed.createComponent(ValueBindingHost); + fixture.detectChanges(); + + return { fixture, host: fixture.componentInstance }; +} + +/** + * Renders pending work: the fixture, the ApplicationRef-attached overlay + * views, and a macrotask hop for the untracked `await import(…)` boundary. + */ +async function settle(fixture: ComponentFixture) { + fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); +} + +/** Opens the session dialog (lazy import + CM mount) and waits until the editor exists. */ +async function openSession(fixture: ComponentFixture) { + const display = fixture.nativeElement.querySelector('.editable-json__display') as HTMLElement; + display.click(); + + for (let i = 0; i < 20 && document.querySelector('.cm-editor') === null; i++) { + await settle(fixture); + } + if (document.querySelector('.cm-editor') === null) throw new Error('session never mounted'); +} + +/** The mounted CodeMirror view — the session's source of truth for the draft. */ +function editorView(): EditorView { + const view = EditorView.findFromDOM(document.querySelector('.cm-editor') as HTMLElement); + if (!view) throw new Error('no EditorView mounted'); + return view; +} + +/** Replaces the whole editor document, as typing would. */ +function typeDraft(fixture: ComponentFixture, text: string) { + const view = editorView(); + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } }); + fixture.detectChanges(); +} + +describe('AngularInlineJson', () => { + it('creates and starts idle', () => { + const { host } = setup(); + expect(host.editing()).toBe(false); + }); + + it('shows the placeholder when empty', () => { + const { fixture } = setup(); + const placeholder = fixture.nativeElement.querySelector('.editable-json__placeholder'); + expect(placeholder?.textContent?.trim()).toBe('null'); + }); + + it('renders a small committed value whole, flowing as its compact text', () => { + const { fixture, host } = setup(); + host.value.set('{"a":1,"b":2}'); + fixture.detectChanges(); + + const preview = fixture.nativeElement.querySelector('.editable-json__preview'); + expect(preview?.textContent).toBe('{"a":1,"b":2}'); + expect(preview?.textContent).not.toContain('⋯'); + }); + + it('middle-ellipses a huge value — real head, real tail, bounded output', () => { + const huge: Record = {}; + for (let i = 0; i < 5000; i++) huge[`key${i}`] = i; + + const { fixture, host } = setup(); + host.value.set(JSON.stringify(huge)); + fixture.detectChanges(); + + const text = fixture.nativeElement.querySelector('.editable-json__preview')?.textContent ?? ''; + expect(text).toContain('⋯'); + expect(text.startsWith('{"key0":0')).toBe(true); + expect(text.endsWith('"key4999":4999}')).toBe(true); + expect(text.length).toBeLessThan(1000); // bounded, never the whole document + }); + + it('opens the session dialog lazily on click and mounts CodeMirror seeded with the editing form', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + + expect(host.editing()).toBe(true); + expect(document.querySelector('.editable-dialog')).toBeTruthy(); + expect(editorView().state.doc.toString()).toBe('{\n a: 1\n}'); // pretty, bare keys + }); + + it('does not elevate when disabled', () => { + const { fixture, host } = setup(); + host.disabled.set(true); + fixture.detectChanges(); + + const display = fixture.nativeElement.querySelector('.editable-json__display') as HTMLElement; + display.click(); + fixture.detectChanges(); + + expect(host.editing()).toBe(false); + }); + + it('commits a changed, valid draft on Save as canonical strict JSON', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + typeDraft(fixture, '{a: 2}'); + + (document.querySelector('.editable-action-save') as HTMLElement).click(); + await settle(fixture); + + expect(host.editing()).toBe(false); + expect(host.value()).toBe('{"a":2}'); + expect(host.saved).toEqual([{ value: '{"a":2}' }]); + expect(host.sessions).toEqual([{ value: '{"a":2}', changed: true }]); + expect(host.touchCount).toBe(1); // the closing edge is the blur analogue + expect(document.querySelector('.editable-dialog')).toBeNull(); + }); + + it('commits a bare-key draft as strict double-quoted compact JSON', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + typeDraft(fixture, '{role: "admin", tags: [1, 2]}'); + + (document.querySelector('.editable-action-save') as HTMLElement).click(); + await settle(fixture); + + expect(host.value()).toBe('{"role":"admin","tags":[1,2]}'); + expect(host.saved).toEqual([{ value: '{"role":"admin","tags":[1,2]}' }]); + }); + + it('blocks Save on invalid JSON (trailing comma), keeps the dialog open, reveals the error', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + typeDraft(fixture, '{"a":1,}'); + + (document.querySelector('.editable-action-save') as HTMLElement).click(); + await settle(fixture); + + expect(host.editing()).toBe(true); + expect(host.saved).toEqual([]); + expect(host.touchCount).toBe(1); // a failed save attempt marks the field touched + expect(document.querySelector('.editable-dialog')).toBeTruthy(); + expect(document.querySelector('.editable-panel__message--error')).toBeTruthy(); + }); + + it('a save with no semantic change restores the baseline text untouched (reformat is not a change)', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1,"b":2}'); + fixture.detectChanges(); + + await openSession(fixture); + // No edits — the editor holds the seeded reformat (pretty, bare keys). + + (document.querySelector('.editable-action-save') as HTMLElement).click(); + await settle(fixture); + + expect(host.editing()).toBe(false); + expect(host.value()).toBe('{"a":1,"b":2}'); // exactly the pre-session text + expect(host.saved).toEqual([]); // no changed settlement + expect(host.sessions).toEqual([{ value: '{"a":1,"b":2}', changed: false }]); + }); + + it('reverts to the baseline on Discard without committing', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + typeDraft(fixture, '{"a":999}'); + + (document.querySelector('.editable-action-reset') as HTMLElement).click(); + await settle(fixture); + + expect(host.editing()).toBe(false); + expect(host.value()).toBe('{"a":1}'); + expect(host.sessions).toEqual([{ value: '{"a":1}', changed: false }]); + expect(host.touchCount).toBe(1); // discard settles the session — touched + }); + + it('destroying the component closes an open session dialog (no orphaned overlay)', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + expect(document.querySelector('.editable-dialog')).toBeTruthy(); + + fixture.destroy(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(document.querySelector('.editable-dialog')).toBeNull(); + }); + + it('an external editing.set(false) closes SILENTLY — reverts, no touch, no settle, no focus steal', async () => { + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + await openSession(fixture); + typeDraft(fixture, '{"a":999}'); + + host.editing.set(false); // programmatic — an instruction, not an interaction + await settle(fixture); + + expect(document.querySelector('.editable-dialog')).toBeNull(); + expect(host.value()).toBe('{"a":1}'); // draft rolled back + expect(host.touchCount).toBe(0); + expect(host.sessions).toEqual([]); // no settlement emission (family: the text control's detach path) + expect( + (document.activeElement as HTMLElement | null)?.classList?.contains('editable-json__display') ?? false, + ).toBe(false); + }); + + it('clicking while selecting preview text does NOT open the session — the preview is real, copyable text', () => { + const { fixture, host } = setup(); + host.value.set('{"a":1,"b":2}'); + fixture.detectChanges(); + + const preview = fixture.nativeElement.querySelector('.editable-json__preview') as HTMLElement; + const range = document.createRange(); + range.selectNodeContents(preview); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + const display = fixture.nativeElement.querySelector('.editable-json__display') as HTMLElement; + display.click(); + fixture.detectChanges(); + + expect(host.editing()).toBe(false); + + selection.removeAllRanges(); + display.click(); + fixture.detectChanges(); + expect(host.editing()).toBe(true); // without a selection the click elevates as before + }); + + it('clears the value via the clear affordance', () => { + // The clear button lives inside BubbleMenu's hover-gated CDK overlay + // (only rendered on a real pointer hover, which jsdom never fires) — call + // the handler the button's (clear) output wires directly, matching the + // established pattern in angular-inline-text.spec.ts. + const { fixture, host } = setup(); + host.value.set('{"a":1}'); + fixture.detectChanges(); + + const instance = fixture.debugElement.children[0].componentInstance as AngularInlineJson; + (instance as unknown as { clearValue(event: Event): void }).clearValue(new Event('click')); + fixture.detectChanges(); + + expect(host.value()).toBe(''); + expect(host.saved).toEqual([{ value: '' }]); + expect(host.touchCount).toBe(1); + }); +}); diff --git a/projects/angular-inline-select/json/src/angular-inline-json.ts b/projects/angular-inline-select/json/src/angular-inline-json.ts new file mode 100644 index 0000000..6c7ef98 --- /dev/null +++ b/projects/angular-inline-select/json/src/angular-inline-json.ts @@ -0,0 +1,641 @@ +import { + Component, + DestroyRef, + ElementRef, + TemplateRef, + inject, + + // Signals + computed, + output, + model, + viewChild, + contentChild, + input, + effect, + afterRenderEffect, + signal, + untracked, + linkedSignal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; + +// Shared chrome — generic (dialog service/actions/affixes/clear bubble), not text-specific. +import { + BubbleMenu, + EditableClearButton, + EditableDialog, + EditableDialogRef, + EditableErrorTemplate, + EditablePrefix, + EditableSuffix, +} from 'angular-inline-select'; + +import { printEditableJson } from './json-doc'; +import { canonicalJson, parseJsonDraft } from './json-codec'; +import { + fallbackTruncate, + truncateToVisualLines, + type InlinePreviewGeometry, +} from './json-preview'; +import type { JsonSessionData } from './json-session'; + +/** Payload of the `saved` output: one emission per settled edit session. */ +export interface InlineJsonSaved { + /** The value the session settled on — raw JSON text (DB-friendly), or the restored baseline. */ + value: string; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * Inline JSON: the committed value flows in the page as ordinary paragraph + * text (a bounded, middle-ellipsed preview) and edits in a MODAL + * `editable-dialog` hosting a CodeMirror JSON editor. The committed model is + * always canonical strict JSON text — a plain string, MySQL/Postgres- + * friendly, correctly typing primitives via native `JSON.parse`/`stringify`. + * + * Contract: + * - The idle preview is NOT editable in place (unlike the plain-text field): + * it may already be a lossy, truncated summary of a huge value, so there is + * no coherent caret position to type into. Click, Enter, or Space opens + * the dialog — deliberately NOT the anchored in-place panel of the text + * family: a code editor wants a stable, centered (full-screen on touch) + * surface, not one glued to a text run. + * - The editor accepts bare identifier keys (`role:`), everything else is + * strict — a trailing comma never commits. Commit canonicalizes through + * `JSON.stringify` (compact, double-quoted). + */ +@Component({ + selector: 'angular-inline-json', + imports: [ + NgTemplateOutlet, + + BubbleMenu, + EditableClearButton, + ], + templateUrl: './angular-inline-json.html', + styleUrl: './angular-inline-json.scss', + host: { + class: 'editable-json', + '[class.editable-json--editing]': 'editing()', + '[class.editable-json--invalid]': 'errorsVisible()', + '[style.display]': 'hidden() ? "none" : null', + '(focus)': 'focus()', + }, +}) +export class AngularInlineJson implements FormValueControl { + /** The idle preview block. Focusable, keyboard-activatable — clicking/Enter/Space elevates. */ + protected display = viewChild.required>('display'); + + /** The in-flow field area (prefix + display + suffix) — the bubble's anchor. */ + protected fieldArea = viewChild.required>('fieldArea'); + + /** The modal session host — MatDialog-shaped service, lazily fed the session component. */ + #dialog = inject(EditableDialog); + + /** The committed value channel: raw JSON text (DB-friendly), never re-serialized on commit. */ + value = model(''); + + /** Form Value Contract */ + disabled = input(false); + readonly = input(false); + required = input(false); + errors = input([]); + invalid = input(false); + touched = input(false); + hidden = input(false); + touch = output(); + + /** Whether the field is elevated (an edit session is open). Two-way bindable. */ + editing = model(false); + + placeholder = input('null'); + + /** Accessible name for the field (the preview has no native label association). */ + ariaLabel = input(undefined); + + /** Idle-preview budget: hard cap on rendered VISUAL lines at the current width. */ + maxPreviewLines = input(5); + + /** Affix templates — same dual channel (input or `ng-template[editablePrefix/Suffix]` content) as every inline control. */ + prefixTemplate = input | undefined>(undefined); + suffixTemplate = input | undefined>(undefined); + + private contentPrefix = contentChild(EditablePrefix); + private contentSuffix = contentChild(EditableSuffix); + + protected prefixTpl = computed(() => this.prefixTemplate() ?? this.contentPrefix()?.templateRef); + protected suffixTpl = computed(() => this.suffixTemplate() ?? this.contentSuffix()?.templateRef); + + /** + * Consumer error content — the mat-error analogue. A TEMPLATE (not element + * projection) because the session UI renders in a portaled dialog + * component where `` cannot reach. Same dual channel as every + * other slot: input for composition, `ng-template[editableError]` content + * sugar for direct use. + */ + errorTemplate = input | undefined>(undefined); + + private contentError = contentChild(EditableErrorTemplate); + + protected errorTpl = computed(() => this.errorTemplate() ?? this.contentError()?.templateRef); + + /** + * THE consumer commit event — fires once per changed settlement with the + * raw JSON text model. + */ + savedModelChange = output<{ value: string }>(); + + /** + * The MACHINERY channel: exactly one emission per settled edit session — + * Save, Discard, and clear alike, changed or not. Wrapping controls bind + * this; app consumers should bind `savedModelChange`. + */ + saved = output(); + + /** + * The session baseline: follows the committed value while idle and freezes + * for the duration of an edit session, exactly like the text field. + */ + previous = linkedSignal({ + source: () => this.value() ?? '', + computation: (source, prev) => (this.editing() ? (prev?.value ?? '') : source), + }); + + isEmpty = computed(() => (this.value() ?? '') === ''); + + /** + * The canonical (strict, compact, double-quoted) reading of the session + * baseline. An unparseable baseline (set externally) canonicalizes to + * itself, so it still compares meaningfully against a fixed draft. + */ + #canonicalBaseline = computed(() => canonicalJson(this.previous()) ?? this.previous()); + + /** + * Session-scoped dirty — SEMANTIC, not textual: the seeded reformat and + * bare-key sugar never count as changes; only a draft that canonicalizes + * differently (or does not parse at all) is dirty. + */ + protected isDirty = computed(() => { + const draft = canonicalJson(this.value() ?? ''); + return draft === null || draft !== this.#canonicalBaseline(); + }); + + /** The draft parse — the same gate the commit path and the editor's lint diagnostic both run. */ + protected parseResult = computed(() => parseJsonDraft(this.value() ?? '')); + protected parseFailed = computed(() => this.parseResult().error !== undefined); + + /** + * The field dictates validity: the bound field's verdict, external errors, + * OR an unparseable draft — a syntax error (trailing comma included) is + * invalid on its own, with no external schema required to say so. + */ + protected isInvalid = computed( + () => this.invalid() || this.errors().length > 0 || this.parseFailed(), + ); + + #selfTouched = signal(false); + #saveAttempted = signal(false); + protected errorsVisible = computed( + () => this.isInvalid() && (this.touched() || this.#selfTouched() || this.#saveAttempted()), + ); + + /** Contract error messages plus the live parse error (if any) while errors are visible. */ + protected errorMessages = computed(() => { + const messages = this.errors() + .filter((error) => !!error.message) + .map((error) => error.message!); + const parseError = this.parseResult().error; + return parseError !== undefined ? [...messages, parseError] : messages; + }); + + // NOTE: unlike the text control — whose `editing` model has many writers + // and needs an edge-detecting effect — every session here opens through + // `#openSession()` and settles through `#handleSessionClosed()`, so the + // per-session flags and the `touch` emission live at those choke points. + + /** + * The in-flow display freezes at the session baseline while editing, so + * live draft propagation through `value` never reflows the preview. + */ + protected previewText = computed(() => (this.editing() ? this.previous() : (this.value() ?? ''))); + + /** + * What the preview flows as: the COMPACT canonical serialization — the + * value presented exactly as stringified-JSON text, running inline with + * the surrounding paragraph. An unparseable value (set externally) shows + * its raw text so it can at least be recognized and fixed. + */ + #previewSource = computed(() => { + const text = this.previewText(); + if (text === '') return ''; + return canonicalJson(text) ?? text; + }); + + /** + * The measured middle-ellipsis truncation for the CURRENT geometry, tagged + * with the source it was computed from so a source change can never flash + * a stale cut. `null` until the first post-render measurement (or where + * measurement is impossible — SSR, jsdom). + */ + #measuredPreview = signal<{ source: string; text: string } | null>(null); + + /** Bumped by the ResizeObserver when the containing block's width changes. */ + #measureTick = signal(0); + + /** + * The rendered preview text: the measured visual-line truncation when one + * exists for this source, else the measurement-free character-budget cut + * (refined on the very next render pass). + */ + protected displayedPreview = computed(() => { + const source = this.#previewSource(); + if (source === '') return ''; + + const measured = this.#measuredPreview(); + if (measured !== null && measured.source === source) return measured.text; + + return fallbackTruncate(source, this.maxPreviewLines()); + }); + + /** + * Re-measures after every render in which the source, the line budget, or + * the container width (tick) changed. Runs in the render READ phase — all + * layout reads, zero writes; the truncation itself is pure canvas-metric + * arithmetic (pretext), so no reflow is ever forced here. + */ + #measurePreviewEffect = afterRenderEffect({ + read: () => { + const source = this.#previewSource(); + const maxLines = this.maxPreviewLines(); + this.#measureTick(); + + untracked(() => { + const text = source === '' ? null : this.#measurePreview(source, maxLines); + this.#measuredPreview.set(text === null ? null : { source, text }); + }); + }, + }); + + #measurePreview(source: string, maxLines: number): string | null { + const geometry = this.#resolvePreviewGeometry(); + if (geometry === null) return null; + + try { + return truncateToVisualLines(source, maxLines, geometry); + } catch { + return null; // no canvas text metrics (jsdom/SSR) — the fallback stands + } + } + + /** + * The paragraph slot the preview flows in: the containing block's content + * width (every wrapped line) and the width remaining on the line the + * preview STARTS on (it begins mid-paragraph, after whatever copy precedes + * it). The span's start position does not depend on its own content, so + * measuring while the fallback text is rendered is sound. + */ + #resolvePreviewGeometry(): InlinePreviewGeometry | null { + const el = this.display().nativeElement; + + let block = el.parentElement; + + while (block !== null && getComputedStyle(block).display.startsWith('inline')) { + block = block.parentElement; + } + + if (block === null) return null; + + const blockStyle = getComputedStyle(block); + const blockRect = block.getBoundingClientRect(); + const contentLeft = + blockRect.left + + (parseFloat(blockStyle.paddingLeft) || 0) + + (parseFloat(blockStyle.borderLeftWidth) || 0); + const contentRight = + blockRect.right - + (parseFloat(blockStyle.paddingRight) || 0) - + (parseFloat(blockStyle.borderRightWidth) || 0); + + const lineWidth = contentRight - contentLeft; + if (!(lineWidth > 48)) return null; + + this.#observeBlockResize(block); + + const startX = el.getClientRects()[0]?.left ?? contentLeft; + const firstLineWidth = Math.max(contentRight - startX, 0); + + const style = getComputedStyle(el); + const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const letterSpacing = parseFloat(style.letterSpacing); + + return { + firstLineWidth, + lineWidth, + font, + letterSpacing: Number.isFinite(letterSpacing) ? letterSpacing : undefined, + }; + } + + #resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => this.#measureTick.update((tick) => tick + 1)); + + #observedBlock: Element | null = null; + + #observeBlockResize(block: Element) { + if (this.#resizeObserver === null || this.#observedBlock === block) return; + + if (this.#observedBlock !== null) this.#resizeObserver.unobserve(this.#observedBlock); + this.#resizeObserver.observe(block); + this.#observedBlock = block; + } + + // --------------------------------------------------------------------------- + // Elevation: bounded preview → lazily-loaded session in the modal dialog + // --------------------------------------------------------------------------- + protected elevate() { + if (this.editing() || this.disabled() || this.readonly()) return; + + // Pin the baseline: `previous` derives from `value` while idle — reading + // it here syncs it to the committed value before `editing` freezes it. + this.previous(); + + this.editing.set(true); + } + + /** + * Click opens the session — UNLESS the user is selecting preview text. + * The preview presents as ordinary paragraph text (the soul of inline), + * and real text is selectable/copyable; a selection gesture must never + * cost the user a dialog. Keyboard activation (below) always elevates. + */ + protected handleActivateClick() { + const displayEl = this.display().nativeElement; + const selection = displayEl.ownerDocument.getSelection(); + + if ( + selection !== null && + !selection.isCollapsed && + selection.anchorNode !== null && + displayEl.contains(selection.anchorNode) + ) { + return; + } + + this.elevate(); + } + + /** Enter/Space activate the preview exactly like a click; Space must not scroll the page. */ + protected handleActivateKey(event: Event) { + event.preventDefault(); + this.elevate(); + } + + #dialogRef: EditableDialogRef | null = null; + #opening = false; + + /** + * `editing` is the ONE session switch — every path (click, keyboard, an + * external `editing.set(true)`) funnels through it, and this effect maps + * it onto the dialog: open lazily on the rising edge, close the ref on the + * falling one (e.g. a programmatic `reset()`). + */ + #syncDialog = effect(() => { + const open = this.editing(); + + untracked(() => { + if (open && this.#dialogRef === null && !this.#opening) void this.#openSession(); + else if (!open && this.#dialogRef !== null) this.#dialogRef.close(); + }); + }); + + /** + * Opens the session dialog. The session component — and CodeMirror behind + * it — loads via `await import(…)`: consumers pay for the editor the first + * time a session opens, not on page load. + * + * Opens reformatted into the EDITING form — pretty-printed, bare + * identifier keys (`printEditableJson`) — when the stored text parses; an + * unparseable stored value (set externally) opens as-is so it can be + * fixed. Semantic dirty/commit comparison means the reformat never counts + * as a change. + */ + /** + * The baseline captured as a PLAIN value at session open. `previous()` is + * only frozen while `editing` is true — a programmatic `editing.set(false)` + * unfreezes it BEFORE the close handler runs, collapsing it to the live + * draft; reverting against it would silently leak the draft into the + * committed model. This field survives that edge. + */ + #sessionBaseline = ''; + + async #openSession() { + this.#opening = true; + + try { + // Rising edge of EVERY open path (elevate or an external + // `editing.set(true)`): a stale save attempt never flashes errors + // onto the fresh draft. + this.#saveAttempted.set(false); + + // `previous()` is frozen at the committed value here (editing is true). + this.#sessionBaseline = this.previous(); + + const draft = this.value() ?? ''; + const parsed = parseJsonDraft(draft); + const seeded = + parsed.error === undefined && parsed.value !== undefined + ? printEditableJson(parsed.value) + : draft; + + if (seeded !== draft) this.value.set(seeded); + + const { JsonSession } = await import('./json-session'); + + // The import is an async gap: the session may have been cancelled + // (editing flipped back) or the component destroyed while the editor + // loaded — opening now would orphan a dialog nothing owns. + if (this.#destroyed || !this.editing()) return; + + const data: JsonSessionData = { + seed: seeded, + onDraftChange: (text) => this.value.set(text), + + errorsVisible: this.errorsVisible, + errorMessages: this.errorMessages, + errorTemplate: this.errorTpl, + isDirty: this.isDirty, + + prefixTemplate: this.prefixTpl, + suffixTemplate: this.suffixTpl, + + close: (sessionDraft) => this.#acceptSession(sessionDraft), + cancel: () => this.#dialogRef?.close(), + }; + + const ref = this.#dialog.open(JsonSession, { + ariaLabel: this.ariaLabel() ?? 'Edit JSON', + data, + }); + this.#dialogRef = ref; + + // The settlement safety net: runs exactly once per session for EVERY + // close path — accept, discard, Escape, scrim click, navigation. + void ref.closed.then(() => this.#handleSessionClosed()); + } finally { + this.#opening = false; + } + } + + #handleSessionClosed() { + this.#dialogRef = null; + + // Destroy teardown: the owner is gone — no rollback, no emissions. + if (this.#destroyed) return; + + // An interaction or an instruction? User paths (Save, Discard, Escape, + // scrim, navigation) close the ref while `editing` is still true; + // programmatic paths (`reset()`, an external `editing.set(false)`) flip + // `editing` FIRST — so the value here IS the distinction. + const userSettled = this.editing(); + + if (this.accepted) { + this.accepted = false; + } else { + this.revert(); + } + + if (!userSettled) return; + + this.editing.set(false); + + // The blur analogue (mat semantics), then focus returns to the in-flow + // display for Tab continuity — user-driven settles only. + this.#selfTouched.set(true); + this.touch.emit(); + this.display().nativeElement.focus(); + } + + // --------------------------------------------------------------------------- + // Commit / revert + // --------------------------------------------------------------------------- + accepted = false; + + /** + * THE accept path — handed to the session as its `close(draft)` callback. + * Canonicalizes the draft back to strict JSON text and settles the rest + * state; an invalid draft (trailing comma included) keeps the dialog open + * and reveals the errors instead. + */ + #acceptSession(draft: string) { + // Sync the live channel first — Save must judge exactly what was typed. + if ((this.value() ?? '') !== draft) this.value.set(draft); + + // Mat-style submit attempt: an invalid draft doesn't commit — it reveals + // the errors (through the signals the session renders) so the user can + // react. Checked FIRST: an unparseable draft has no canonical form. + if (this.isInvalid()) { + this.#saveAttempted.set(true); + this.#selfTouched.set(true); + this.touch.emit(); + return; + } + + // The committed form is CANONICAL strict JSON — compact, double-quoted — + // regardless of how the draft was typed (bare keys, editor indentation). + // That is what the model carries and what lands in the database. + const canonical = canonicalJson(draft) ?? ''; + const baseline = this.previous(); + const changed = canonical !== this.#canonicalBaseline(); + + if (!changed) { + this.accepted = true; + // The live channel holds the editing-form draft (seeded reformat, bare + // keys); an unchanged close restores the untouched baseline text. + if ((this.value() ?? '') !== baseline) this.value.set(baseline); + this.#dialogRef?.close(); + this.saved.emit({ value: baseline, changed: false }); + return; + } + + this.accepted = true; + this.value.set(canonical); + + this.savedModelChange.emit({ value: canonical }); + this.saved.emit({ value: canonical, changed: true }); + this.#dialogRef?.close(); + } + + /** + * Restores the baseline (dismissals: Discard, Escape, scrim click, + * navigation, programmatic close). Uses the CAPTURED session baseline — + * `previous()` may already have unfrozen on programmatic closes. + */ + protected revert() { + const draft = this.value() ?? ''; + const baseline = this.#sessionBaseline; + + if (draft !== baseline) this.value.set(baseline); + if (this.editing()) this.saved.emit({ value: baseline, changed: false }); + } + + // --------------------------------------------------------------------------- + // Clear affordance (the floating bubble lives in BubbleMenu) + // --------------------------------------------------------------------------- + protected bubbleMenuCanShow = computed( + () => + !this.required() && + !this.disabled() && + !this.readonly() && + !this.isEmpty() && + !this.editing(), + ); + + protected clearValue(event: Event) { + event.preventDefault(); + event.stopPropagation(); + if (this.editing()) return; + + this.value.set(''); + this.savedModelChange.emit({ value: '' }); + this.saved.emit({ value: '', changed: true }); + + this.#selfTouched.set(true); + this.touch.emit(); + } + + // --------------------------------------------------------------------------- + // FormUiControl contract + // --------------------------------------------------------------------------- + focus(options?: FocusOptions) { + this.display().nativeElement.focus(options); + } + + reset() { + this.#selfTouched.set(false); + this.#saveAttempted.set(false); + + if (!this.editing()) return; + + const baseline = this.previous(); + if ((this.value() ?? '') !== baseline) this.value.set(baseline); + + this.accepted = true; // suppress the revert — the reset already restored the baseline + this.editing.set(false); // flipping first marks the close as programmatic: no touch, no focus steal + } + + #destroyed = false; + + #cleanupOnDestroy = inject(DestroyRef).onDestroy(() => { + this.#destroyed = true; + // The dialog lives in the app-rooted overlay — without this it would + // OUTLIVE a destroyed component (an @if removing the control) with dead + // callbacks. Navigation disposal is covered by the overlay itself. + this.#dialogRef?.close(); + this.#resizeObserver?.disconnect(); + }); +} diff --git a/projects/angular-inline-select/json/src/json-codec.spec.ts b/projects/angular-inline-select/json/src/json-codec.spec.ts new file mode 100644 index 0000000..d05a27d --- /dev/null +++ b/projects/angular-inline-select/json/src/json-codec.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalJson, parseJsonDraft, quoteBareKeys } from './json-codec'; + +describe('quoteBareKeys', () => { + it('quotes a bare identifier key', () => { + expect(quoteBareKeys('{a: 1}')).toBe('{"a": 1}'); + }); + + it('quotes multiple keys including $ and _ identifiers', () => { + expect(quoteBareKeys('{foo: 1, _bar: 2, $baz3: 3}')).toBe('{"foo": 1, "_bar": 2, "$baz3": 3}'); + }); + + it('is idempotent on strict JSON (already-quoted keys untouched)', () => { + const strict = '{"a": 1, "b": {"c": [1, 2]}}'; + expect(quoteBareKeys(strict)).toBe(strict); + }); + + it('never touches string CONTENT that looks like a key', () => { + expect(quoteBareKeys('{"s": "a: 1, {b: 2}"}')).toBe('{"s": "a: 1, {b: 2}"}'); + }); + + it('handles escaped quotes inside strings', () => { + const text = '{"s": "he said \\" x: 1"}'; + expect(quoteBareKeys(text)).toBe(text); + }); + + it('does not quote true/false/null in VALUE position', () => { + expect(quoteBareKeys('{a: true, b: null}')).toBe('{"a": true, "b": null}'); + }); + + it('does not touch identifiers in arrays (stay errors for strict parse)', () => { + expect(quoteBareKeys('[a, b]')).toBe('[a, b]'); + }); + + it('quotes nested object keys at any depth', () => { + expect(quoteBareKeys('{a: {b: {c: 1}}}')).toBe('{"a": {"b": {"c": 1}}}'); + }); + + it('handles URLs (a // inside a string is not special)', () => { + const text = '{url: "https://example.com/x?y=1"}'; + expect(quoteBareKeys(text)).toBe('{"url": "https://example.com/x?y=1"}'); + }); + + it('handles whitespace between key and colon', () => { + expect(quoteBareKeys('{a : 1}')).toBe('{"a" : 1}'); + }); + + it('quotes keys after a nested container closes', () => { + expect(quoteBareKeys('{a: [1], b: 2}')).toBe('{"a": [1], "b": 2}'); + }); +}); + +describe('parseJsonDraft — bare-key leniency, otherwise strict', () => { + it('accepts bare identifier keys', () => { + expect(parseJsonDraft('{role: "admin", active: true}').value).toEqual({ role: 'admin', active: true }); + }); + + it('still types primitives natively', () => { + const parsed = parseJsonDraft('{n: 42, s: "42", b: false, nil: null}').value as Record; + expect(parsed['n']).toBe(42); + expect(parsed['s']).toBe('42'); + expect(parsed['b']).toBe(false); + expect(parsed['nil']).toBeNull(); + }); + + it('REJECTS a trailing comma — this is not JSON5', () => { + expect(parseJsonDraft('{"a": 1,}').error).toBeDefined(); + expect(parseJsonDraft('{a: 1,}').error).toBeDefined(); + expect(parseJsonDraft('[1, 2,]').error).toBeDefined(); + }); + + it('rejects single-quoted strings', () => { + expect(parseJsonDraft("{a: 'x'}").error).toBeDefined(); + }); + + it('rejects unquoted string VALUES', () => { + expect(parseJsonDraft('{a: admin}').error).toBeDefined(); + }); + + it('rejects comments', () => { + expect(parseJsonDraft('{"a": 1} // note').error).toBeDefined(); + }); + + it('treats empty/whitespace text as no value, not an error', () => { + expect(parseJsonDraft('')).toEqual({}); + expect(parseJsonDraft(' \n ')).toEqual({}); + }); +}); + +describe('canonicalJson', () => { + it('serializes to compact, double-quoted strict JSON', () => { + expect(canonicalJson('{ role : "admin",\n active: true }')).toBe('{"role":"admin","active":true}'); + }); + + it('is null for unparseable text', () => { + expect(canonicalJson('{a: 1,}')).toBeNull(); + }); + + it('is the empty string for empty text', () => { + expect(canonicalJson(' ')).toBe(''); + }); + + it('agrees across typing styles — bare vs quoted keys canonicalize identically', () => { + expect(canonicalJson('{a: 1}')).toBe(canonicalJson('{"a": 1}')); + }); +}); diff --git a/projects/angular-inline-select/json/src/json-codec.ts b/projects/angular-inline-select/json/src/json-codec.ts new file mode 100644 index 0000000..49d5942 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-codec.ts @@ -0,0 +1,133 @@ +import type { JsonValue } from './json-doc'; + +export interface JsonParseResult { + value?: JsonValue; + error?: string; +} + +/** + * The ONE deliberate leniency: bare identifier keys. `{ role: "admin" }` + * reads and types better than `{ "role": "admin" }`, and quoting keys is + * where most hand-typing errors happen — so the draft may omit key quotes. + * + * Everything else stays strict `JSON.parse`: trailing commas, single-quoted + * strings, comments, unquoted string VALUES all remain errors. This is NOT + * JSON5 (JSON5 would silently accept trailing commas, which we specifically + * want rejected) — it is a single, string-aware pre-pass that wraps bare + * keys in double quotes and hands the result to the strict parser. + */ +export function quoteBareKeys(text: string): string { + let out = ''; + let i = 0; + let inString = false; + const containers: string[] = []; + let lastSignificant = ''; + + while (i < text.length) { + const ch = text[i]; + + if (inString) { + if (ch === '\\') { + // Copy the escape pair atomically so an escaped quote never ends the string. + out += ch + (text[i + 1] ?? ''); + i += 2; + continue; + } + if (ch === '"') inString = false; + out += ch; + i++; + continue; + } + + if (ch === '"') { + inString = true; + out += ch; + lastSignificant = ch; + i++; + continue; + } + + if (ch === '{' || ch === '[') { + containers.push(ch); + out += ch; + lastSignificant = ch; + i++; + continue; + } + + if (ch === '}' || ch === ']') { + containers.pop(); + out += ch; + lastSignificant = ch; + i++; + continue; + } + + // A bare identifier in object-KEY position: directly inside `{…}`, right + // after `{` or `,`, and followed (after whitespace) by `:`. Identifiers + // in VALUE position (true/false/null after a `:`) never match — their + // lastSignificant is `:`. + if ( + /[A-Za-z_$]/.test(ch) && + containers[containers.length - 1] === '{' && + (lastSignificant === '{' || lastSignificant === ',') + ) { + let end = i; + while (end < text.length && /[A-Za-z0-9_$]/.test(text[end])) end++; + + let next = end; + while (next < text.length && /\s/.test(text[next])) next++; + + if (text[next] === ':') { + out += `"${text.slice(i, end)}"`; + lastSignificant = '"'; + i = end; + continue; + } + + // Not a key (no colon follows) — copy as-is; strict parse will judge it. + out += text.slice(i, end); + lastSignificant = text[end - 1]; + i = end; + continue; + } + + out += ch; + if (!/\s/.test(ch)) lastSignificant = ch; + i++; + } + + return out; +} + +/** + * Draft parsing: bare-key leniency (above), then STRICT `JSON.parse` — a + * trailing comma, an unquoted string value, a single-quoted string: all + * rejected exactly as `JSON.parse` rejects them. Empty (whitespace-only) + * text is "no value", not an error — the empty draft never raises the gate. + */ +export function parseJsonDraft(text: string): JsonParseResult { + const trimmed = text.trim(); + if (trimmed === '') return {}; + + try { + return { value: JSON.parse(quoteBareKeys(trimmed)) as JsonValue }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Invalid JSON' }; + } +} + +/** + * The canonical committed form: strict, compact, double-quoted `JSON.stringify` + * of the parsed value — what actually lands in the model (and the database). + * `null` means the text does not parse; `''` means empty. + */ +export function canonicalJson(text: string): string | null { + const trimmed = text.trim(); + if (trimmed === '') return ''; + + const parsed = parseJsonDraft(trimmed); + if (parsed.error !== undefined || parsed.value === undefined) return null; + + return JSON.stringify(parsed.value); +} diff --git a/projects/angular-inline-select/json/src/json-doc.spec.ts b/projects/angular-inline-select/json/src/json-doc.spec.ts new file mode 100644 index 0000000..410d189 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-doc.spec.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { previewJsonLines, printEditableJson, printJson, type JsonValue } from './json-doc'; +import { parseJsonDraft } from './json-codec'; + +describe('printJson', () => { + it('pretty-prints with 2-space indent, always expanded', () => { + expect(printJson({ a: 1, b: [1, 2] })).toBe('{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}'); + }); + + it('round-trips primitive typing', () => { + const value = { n: 42, s: 'hi', b: true, nil: null }; + expect(JSON.parse(printJson(value))).toEqual(value); + }); +}); + +describe('printEditableJson — the bare-key editing form', () => { + it('prints identifier keys without quotes', () => { + expect(printEditableJson({ role: 'admin', active: true })).toBe( + '{\n role: "admin",\n active: true\n}', + ); + }); + + it('keeps quoting keys that are not valid identifiers', () => { + expect(printEditableJson({ 'a-b': 1, '1x': 2, 'has space': 3 })).toBe( + '{\n "a-b": 1,\n "1x": 2,\n "has space": 3\n}', + ); + }); + + it('nests and matches printJson layout otherwise', () => { + expect(printEditableJson({ a: { b: [1, 2] } })).toBe('{\n a: {\n b: [\n 1,\n 2\n ]\n }\n}'); + }); + + it('prints empty containers compactly', () => { + expect(printEditableJson({})).toBe('{}'); + expect(printEditableJson([])).toBe('[]'); + }); + + it('round-trips through the codec back to the same value', () => { + const value: JsonValue = { role: 'admin', 'a-b': 1, nested: { list: [1, 'x', null] } }; + expect(parseJsonDraft(printEditableJson(value)).value).toEqual(value); + }); +}); + +describe('previewJsonLines — flat cases', () => { + it('renders a small flat object on one line', () => { + const result = previewJsonLines({ a: 1, b: 2 }); + expect(result.truncated).toBe(false); + expect(result.lines).toEqual(['{ "a": 1, "b": 2 }']); + }); + + it('renders scalars on one line regardless of maxLines', () => { + expect(previewJsonLines('hello').lines).toEqual(['"hello"']); + expect(previewJsonLines(42).lines).toEqual(['42']); + expect(previewJsonLines(true).lines).toEqual(['true']); + expect(previewJsonLines(null).lines).toEqual(['null']); + }); + + it('preserves native primitive typing — numbers unquoted, strings quoted', () => { + const result = previewJsonLines({ count: 5, label: '5' }); + expect(result.lines[0]).toContain('"count": 5'); + expect(result.lines[0]).toContain('"label": "5"'); + }); + + it('renders empty containers compactly', () => { + expect(previewJsonLines({}).lines).toEqual(['{}']); + expect(previewJsonLines([]).lines).toEqual(['[]']); + }); +}); + +describe('previewJsonLines — expands when it fits within budget, no ellipsis', () => { + it('expands a small object that does not flatten within flatWidth', () => { + const value = { longKeyNameOne: 'a fairly long value string', longKeyNameTwo: 2 }; + const result = previewJsonLines(value, { flatWidth: 20, maxLines: 5 }); + expect(result.truncated).toBe(false); + expect(result.lines).toEqual([ + '{', + ' "longKeyNameOne": "a fairly long value string",', + ' "longKeyNameTwo": 2', + '}', + ]); + }); +}); + +describe('previewJsonLines — truncation with real head AND real tail content', () => { + it('truncates a large flat object to 5 lines with head, ellipsis, tail', () => { + const value: JsonValue = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7 }; + const result = previewJsonLines(value, { maxLines: 5, flatWidth: 10 }); + + expect(result.truncated).toBe(true); + expect(result.lines.length).toBeLessThanOrEqual(5); + expect(result.lines[0]).toBe('{'); + expect(result.lines.at(-1)).toBe('}'); + + // Real head content (from the front) and real tail content (from the back) — + // not synthesized closing brackets standing in for them. + expect(result.lines.some((l) => l.includes('"a": 1'))).toBe(true); + expect(result.lines.some((l) => l.includes('"g": 7'))).toBe(true); + expect(result.lines.some((l) => /⋯ \d+ more/.test(l))).toBe(true); + + // The middle keys never got materialized into the preview. + expect(result.lines.some((l) => l.includes('"d": 4'))).toBe(false); + }); + + it('never exceeds maxLines regardless of how large the value is', () => { + const huge: Record = {}; + for (let i = 0; i < 5000; i++) huge[`key${i}`] = i; + + const result = previewJsonLines(huge, { maxLines: 5 }); + expect(result.lines.length).toBeLessThanOrEqual(5); + expect(result.truncated).toBe(true); + expect(result.lines.some((l) => l.includes('"key0": 0'))).toBe(true); + expect(result.lines.some((l) => l.includes('"key4999": 4999'))).toBe(true); + }); + + it('truncates arrays the same way, preserving element order at head and tail', () => { + const value = Array.from({ length: 20 }, (_, i) => i); + const result = previewJsonLines(value, { maxLines: 5, flatWidth: 5 }); + + expect(result.truncated).toBe(true); + expect(result.lines[0]).toBe('['); + expect(result.lines.at(-1)).toBe(']'); + expect(result.lines.some((l) => l.trim() === '0,')).toBe(true); + expect(result.lines.some((l) => l.trim() === '19')).toBe(true); + }); + + it('gives the last shown line no trailing comma', () => { + const value: JsonValue = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }; + const result = previewJsonLines(value, { maxLines: 5, flatWidth: 10 }); + const lastContentLine = result.lines[result.lines.length - 2]; + expect(lastContentLine.endsWith(',')).toBe(false); + }); +}); + +describe('previewJsonLines — nested truncation', () => { + it('collapses a deeply/widely nested child to a summary marker when its own sub-budget is tiny', () => { + const bigNested: Record = {}; + for (let i = 0; i < 50; i++) bigNested[`k${i}`] = i; + + const value = { small: 1, huge: bigNested, other: 2, another: 3, more: 4 }; + const result = previewJsonLines(value, { maxLines: 5, flatWidth: 15 }); + + expect(result.lines.length).toBeLessThanOrEqual(5); + // The nested huge object degrades gracefully instead of blowing the budget. + expect(result.lines.some((l) => l.includes('⋯50⋯') || l.includes('⋯'))).toBe(true); + }); + + it('renders a small nested object correctly at depth with correct bracket alignment', () => { + const value = { outer: { inner: 1 } }; + const result = previewJsonLines(value, { maxLines: 5, flatWidth: 5 }); + expect(result.lines).toEqual(['{', ' "outer": {', ' "inner": 1', ' }', '}']); + }); +}); + +describe('previewJsonLines — budget edge cases', () => { + it('collapses to a one-line summary when maxLines is too small for any structure', () => { + // flatWidth forces expansion (does not trivially fit on one line), so + // the tight maxLines budget is what forces the collapse, not tryFlat. + const value = { a: 1, b: 2, c: 3 }; + const result = previewJsonLines(value, { maxLines: 2, flatWidth: 5 }); + expect(result.lines.length).toBe(1); + expect(result.truncated).toBe(true); + expect(result.lines[0]).toContain('⋯3⋯'); + }); + + it('maxLines defaults to 5', () => { + const value: JsonValue = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 }; + const result = previewJsonLines(value, { flatWidth: 10 }); + expect(result.lines.length).toBeLessThanOrEqual(5); + }); +}); diff --git a/projects/angular-inline-select/json/src/json-doc.ts b/projects/angular-inline-select/json/src/json-doc.ts new file mode 100644 index 0000000..a2c67df --- /dev/null +++ b/projects/angular-inline-select/json/src/json-doc.ts @@ -0,0 +1,288 @@ +/** + * A tiny, hand-rolled Wadler/Lindig-style pretty-printer scoped to exactly + * one job: JSON. Not a general Doc algebra — there is no `text`/`line`/ + * `group` combinator library here, just direct recursion over parsed JSON + * values, because that is all this problem needs and it keeps the whole + * module (and the `json` entry point's bundle) small. + * + * Two printers live here, with very different cost profiles: + * - `printJson` — full pretty-print (paste/reformat), O(document size). + * - `previewJsonLines` — bounded idle-display preview, O(maxLines) ALWAYS, + * regardless of how large the underlying value is. It never materializes + * (builds indented text for) more than a `maxLines`-worth of content: a + * `tryFlat` attempt bails the instant its accumulated width exceeds the + * budget (never visits the remainder of a wide/huge value), and container + * truncation only recurses into the handful of entries actually chosen + * for the head/tail slices — the skipped middle is never visited at all. + */ + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +const INDENT_UNIT = ' '; + +function indentStr(depth: number): string { + return INDENT_UNIT.repeat(depth); +} + +function isContainer(value: JsonValue): value is JsonValue[] | { [key: string]: JsonValue } { + return value !== null && typeof value === 'object'; +} + +function entriesOf(value: JsonValue[] | { [key: string]: JsonValue }): Array<[string | null, JsonValue]> { + return Array.isArray(value) + ? value.map((v): [string | null, JsonValue] => [null, v]) + : Object.entries(value); +} + +// ----------------------------------------------------------------------------- +// Full pretty-print — paste/reformat. Native JSON.stringify already produces +// exactly what a code editor's "prettify" is expected to look like (always +// expanded, one entry per line); no group/fits-flat decisions belong here — +// those exist only to make the tight preview budget below worth the lines. +// ----------------------------------------------------------------------------- +export function printJson(value: JsonValue, indent = 2): string { + return JSON.stringify(value, null, indent); +} + +/** Keys safe to print bare in the EDITOR — same shape `quoteBareKeys` re-quotes on parse. */ +const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * The EDITOR's pretty-print: identical layout to `printJson`, but identifier + * keys render bare (`role:` not `"role":`) — the typing-friendly form the + * codec's bare-key leniency accepts back. The committed model never sees + * this form: commit canonicalizes through strict `JSON.stringify` (double + * quotes), so what lands in the database is always strict JSON. + */ +export function printEditableJson(value: JsonValue, depth = 0): string { + if (!isContainer(value)) return JSON.stringify(value); + + const pad = INDENT_UNIT.repeat(depth + 1); + const close = INDENT_UNIT.repeat(depth); + + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const body = value.map((child) => pad + printEditableJson(child, depth + 1)).join(',\n'); + return `[\n${body}\n${close}]`; + } + + const entries = Object.entries(value); + if (entries.length === 0) return '{}'; + + const body = entries + .map(([key, child]) => { + const printedKey = BARE_KEY.test(key) ? key : JSON.stringify(key); + return `${pad}${printedKey}: ${printEditableJson(child, depth + 1)}`; + }) + .join(',\n'); + + return `{\n${body}\n${close}}`; +} + +// ----------------------------------------------------------------------------- +// Bounded preview — the idle in-flow display. +// ----------------------------------------------------------------------------- +export interface JsonPreviewOptions { + /** Hard cap on the number of rendered lines. Default 5. */ + maxLines?: number; + /** Max characters a value may flatten to before a container is forced to expand. Default 60. */ + flatWidth?: number; +} + +export interface JsonPreview { + lines: string[]; + truncated: boolean; +} + +interface ResolvedOptions { + maxLines: number; + flatWidth: number; +} + +export function previewJsonLines(value: JsonValue, options?: JsonPreviewOptions): JsonPreview { + const opts: ResolvedOptions = { + maxLines: options?.maxLines ?? 5, + flatWidth: options?.flatWidth ?? 60, + }; + + return renderBounded(value, Math.max(opts.maxLines, 1), 0, opts); +} + +/** + * Tries to render `value` as one flat, single-line string no longer than + * `flatWidth` (measured from `currentLength`, so nesting inside an + * already-long prefix bails sooner). Returns `null` the moment the + * accumulated prefix exceeds the budget — it never finishes walking a + * value that was always going to be too wide, so a huge sibling doesn't + * cost anything once the budget is blown. + */ +function tryFlat(value: JsonValue, flatWidth: number, currentLength = 0): string | null { + if (currentLength > flatWidth) return null; + + if (!isContainer(value)) return JSON.stringify(value); + + const entries = entriesOf(value); + const isArr = Array.isArray(value); + if (entries.length === 0) return isArr ? '[]' : '{}'; + + let acc = `${isArr ? '[' : '{'} `; + + for (let i = 0; i < entries.length; i++) { + if (currentLength + acc.length > flatWidth) return null; + + const [key, child] = entries[i]; + const keyPart = key !== null ? `${JSON.stringify(key)}: ` : ''; + const sep = i > 0 ? ', ' : ''; + const prefix = sep + keyPart; + + const flatChild = tryFlat(child, flatWidth, currentLength + acc.length + prefix.length); + if (flatChild === null) return null; + + acc += prefix + flatChild; + } + + acc += ` ${isArr ? ']' : '}'}`; + return currentLength + acc.length <= flatWidth ? acc : null; +} + +/** One logical unit in a container's body: the lines for one entry, or the ellipsis marker. */ +type Block = string[]; + +/** Appends a trailing comma to every block's last line except the final block's. */ +function assembleBody(blocks: Block[]): string[] { + const out: string[] = []; + blocks.forEach((block, i) => { + const isLastBlock = i === blocks.length - 1; + block.forEach((line, j) => { + const isLastLineOfBlock = j === block.length - 1; + out.push(isLastLineOfBlock && !isLastBlock ? `${line},` : line); + }); + }); + return out; +} + +/** Renders one entry (object `"key": value` or array element) to fully-indented lines. */ +function renderEntry( + key: string | null, + child: JsonValue, + budgetLines: number, + depth: number, + opts: ResolvedOptions, +): string[] { + if (key === null) { + return renderBounded(child, budgetLines, depth, opts).lines; + } + + // The key shares the child's first line, so the child is rendered at + // depth 0 (unindented) and the entry's own indent is prepended to every + // line afterward — this stacks correctly since indentStr is just spaces. + const prefix = `${JSON.stringify(key)}: `; + const child0 = renderBounded(child, budgetLines, 0, opts); + const [first, ...rest] = child0.lines; + + return [indentStr(depth) + prefix + first, ...rest.map((line) => indentStr(depth) + line)]; +} + +/** Greedily collects entries[from, to) rendered forward, stopping once the next entry would exceed budget. */ +function collectForward( + entries: Array<[string | null, JsonValue]>, + from: number, + to: number, + budgetLines: number, + depth: number, + opts: ResolvedOptions, +): { blocks: Block[]; count: number } { + const blocks: Block[] = []; + let used = 0; + + for (let i = from; i < to; i++) { + const remaining = budgetLines - used; + if (remaining <= 0) break; + + const [key, child] = entries[i]; + const lines = renderEntry(key, child, remaining, depth, opts); + if (lines.length > remaining) break; + + blocks.push(lines); + used += lines.length; + } + + return { blocks, count: blocks.length }; +} + +/** How many entries, walked from the end backward, fit within budgetLines (without exceeding `stopBeforeIndex`). */ +function countBackward( + entries: Array<[string | null, JsonValue]>, + stopBeforeIndex: number, + budgetLines: number, + depth: number, + opts: ResolvedOptions, +): number { + let used = 0; + let count = 0; + + for (let i = entries.length - 1; i >= stopBeforeIndex; i--) { + const remaining = budgetLines - used; + if (remaining <= 0) break; + + const [key, child] = entries[i]; + const lines = renderEntry(key, child, remaining, depth, opts); + if (lines.length > remaining) break; + + used += lines.length; + count++; + } + + return count; +} + +function renderBounded(value: JsonValue, budgetLines: number, depth: number, opts: ResolvedOptions): JsonPreview { + const flat = tryFlat(value, opts.flatWidth); + if (flat !== null) { + return { lines: [indentStr(depth) + flat], truncated: false }; + } + + const container = value as JsonValue[] | { [key: string]: JsonValue }; + const isArr = Array.isArray(container); + const entries = entriesOf(container); + const openTok = isArr ? '[' : '{'; + const closeTok = isArr ? ']' : '}'; + + if (entries.length === 0) { + return { lines: [indentStr(depth) + openTok + closeTok], truncated: false }; + } + + const open = indentStr(depth) + openTok; + const close = indentStr(depth) + closeTok; + + // Too little budget to show any real structure — collapse to a one-line summary. + if (budgetLines < 3) { + return { + lines: [`${indentStr(depth)}${openTok} ⋯${entries.length}⋯ ${closeTok}`], + truncated: true, + }; + } + + const bodyBudget = budgetLines - 2; // minus open/close lines + + // Attempt 1: everything fits, no ellipsis needed. + const full = collectForward(entries, 0, entries.length, bodyBudget, depth + 1, opts); + if (full.count === entries.length) { + return { lines: [open, ...assembleBody(full.blocks), close], truncated: false }; + } + + // Attempt 2: reserve one line for the ellipsis, split the rest head/tail. + const remaining = Math.max(bodyBudget - 1, 0); + const headBudget = Math.ceil(remaining / 2); + const tailBudget = remaining - headBudget; + + const head = collectForward(entries, 0, entries.length, headBudget, depth + 1, opts); + const tailCount = countBackward(entries, head.count, tailBudget, depth + 1, opts); + const tail = collectForward(entries, entries.length - tailCount, entries.length, tailBudget, depth + 1, opts); + + const skipped = entries.length - head.count - tail.count; + const ellipsis: Block = [`${indentStr(depth + 1)}⋯ ${skipped} more`]; + + const blocks = [...head.blocks, ellipsis, ...tail.blocks]; + return { lines: [open, ...assembleBody(blocks), close], truncated: true }; +} diff --git a/projects/angular-inline-select/json/src/json-editor.ts b/projects/angular-inline-select/json/src/json-editor.ts new file mode 100644 index 0000000..71c4a75 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-editor.ts @@ -0,0 +1,169 @@ +import { EditorState, type Extension } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { + HighlightStyle, + StreamLanguage, + bracketMatching, + indentOnInput, + syntaxHighlighting, + type StreamParser, +} from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { linter, lintGutter, lintKeymap, type Diagnostic } from '@codemirror/lint'; + +import { parseJsonDraft } from './json-codec'; + +// ----------------------------------------------------------------------------- +// Tokenizer — hand-rolled for EXACTLY our dialect (strict JSON + bare +// identifier keys). The Lezer JSON grammar mis-parses bare keys (error +// recovery re-tags neighbors — values steal the key color, keys go plain), +// so highlighting around them never read as JSON. A stream tokenizer with an +// object/array stack classifies every token deterministically instead. +// ----------------------------------------------------------------------------- + +interface JsonStreamState { + /** Open containers, innermost last. */ + stack: string[]; +} + +const jsonStreamParser: StreamParser = { + name: 'json', + + startState: () => ({ stack: [] }), + copyState: (state) => ({ stack: state.stack.slice() }), + + token(stream, state) { + if (stream.eatSpace()) return null; + + const inObject = state.stack[state.stack.length - 1] === '{'; + + // Strings — a key when inside an object and a colon follows. + if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) { + return inObject && stream.match(/^\s*:/, false) ? 'propertyName' : 'string'; + } + + if (stream.match(/^-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?/)) return 'number'; + if (stream.match(/^(?:true|false)\b/)) return 'bool'; + if (stream.match(/^null\b/)) return 'null'; + + // Bare identifiers: a KEY in key position (the one leniency), an error anywhere else. + if (stream.match(/^[A-Za-z_$][A-Za-z0-9_$]*/)) { + return inObject && stream.match(/^\s*:/, false) ? 'propertyName' : 'invalid'; + } + + const ch = stream.next(); + switch (ch) { + case '{': + case '[': + state.stack.push(ch); + return ch === '{' ? 'brace' : 'squareBracket'; + case '}': + case ']': + state.stack.pop(); + return ch === '}' ? 'brace' : 'squareBracket'; + case ':': + case ',': + return 'punctuation'; + default: + return 'invalid'; + } + }, + + indent(state, textAfter, context) { + const closing = /^[}\]]/.test(textAfter); + return (state.stack.length - (closing ? 1 : 0)) * context.unit; + }, + + languageData: { + indentOnInput: /^\s*[}\]]$/, + }, +}; + +const jsonLanguage = StreamLanguage.define(jsonStreamParser); + +/** + * GitHub syntax colors, BOTH schemes: `light-dark(primer-light, primer-dark)` + * follows the app's `color-scheme` automatically, and every color remains + * overridable via its `--editable-json-syntax-*` token. GitHub renders JSON + * constants (numbers, booleans, null) in the same accent as keys — that + * near-monochrome blue/navy split IS the GitHub JSON look. + */ +const githubJsonHighlight = HighlightStyle.define([ + { + tag: tags.propertyName, + color: 'var(--editable-json-syntax-property, light-dark(#0550ae, #79c0ff))', + }, + { tag: tags.string, color: 'var(--editable-json-syntax-string, light-dark(#0a3069, #a5d6ff))' }, + { tag: tags.number, color: 'var(--editable-json-syntax-number, light-dark(#0550ae, #79c0ff))' }, + { + tag: [tags.bool, tags.null], + color: 'var(--editable-json-syntax-keyword, light-dark(#0550ae, #79c0ff))', + }, + { + tag: tags.invalid, + color: 'var(--editable-json-syntax-invalid, light-dark(#82071e, #ffa198))', + }, +]); + +/** + * The commit gate as a live diagnostic: reruns the exact same parse the + * commit path uses (bare-key leniency, otherwise strict), so bare keys are + * never flagged while a trailing comma — or any other real syntax error — + * surfaces while typing. One parser, one verdict. + */ +const jsonLinter = linter((view) => { + const text = view.state.doc.toString(); + const parsed = parseJsonDraft(text); + if (parsed.error === undefined) return []; + + const diagnostic: Diagnostic = { from: 0, to: text.length, severity: 'error', message: parsed.error }; + return [diagnostic]; +}); + +export interface JsonEditorCallbacks { + onChange: (text: string) => void; +} + +export interface JsonEditorOptions { + /** + * Whether the surrounding theme is DARK. CodeMirror cannot see the app's + * `color-scheme` — its injected base theme defaults to light (black + * caret, white tooltips: invisible/unreadable on a dark surface). This + * flag switches CM's own `&dark` base theme wholesale, so caret, + * tooltips, selection and panels all follow — no per-selector overrides. + */ + dark?: boolean; +} + +/** + * The elevated editing surface's extensions: line numbers, GitHub-flavored + * highlighting over our own tokenizer, bracket matching, auto-indent, and + * the strict lint diagnostic above. + */ +export function createJsonEditorState( + doc: string, + callbacks: JsonEditorCallbacks, + options: JsonEditorOptions = {}, +): EditorState { + const extensions: Extension[] = [ + jsonLanguage, + lineNumbers(), + // An (empty) theme whose only job is declaring the scheme — flips every + // `&dark` rule in CM's base theme. + EditorView.theme({}, { dark: options.dark ?? false }), + syntaxHighlighting(githubJsonHighlight), + bracketMatching(), + indentOnInput(), + history(), + EditorView.lineWrapping, + jsonLinter, + lintGutter(), + keymap.of([...defaultKeymap, ...historyKeymap, ...lintKeymap, indentWithTab]), + EditorView.updateListener.of((update) => { + if (update.docChanged) callbacks.onChange(update.state.doc.toString()); + }), + ]; + + return EditorState.create({ doc, extensions }); +} diff --git a/projects/angular-inline-select/json/src/json-preview.spec.ts b/projects/angular-inline-select/json/src/json-preview.spec.ts new file mode 100644 index 0000000..c3c25d3 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-preview.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { fallbackTruncate, PREVIEW_ELLIPSIS } from './json-preview'; + +// `truncateToVisualLines` needs real canvas text metrics (pretext measures +// with Canvas 2D), which jsdom does not provide — it is exercised in the +// browser. The measurement-free fallback is fully testable here. + +describe('fallbackTruncate', () => { + it('returns short text unchanged', () => { + expect(fallbackTruncate('{"a":1}', 5)).toBe('{"a":1}'); + }); + + it('middle-ellipses long text with real head and tail content', () => { + const huge: Record = {}; + for (let i = 0; i < 5000; i++) huge[`key${i}`] = i; + const text = JSON.stringify(huge); + + const result = fallbackTruncate(text, 5); + + expect(result.length).toBeLessThan(text.length); + expect(result).toContain(PREVIEW_ELLIPSIS); + expect(result.startsWith('{"key0":0')).toBe(true); + expect(result.endsWith('"key4999":4999}')).toBe(true); + }); + + it('scales its budget with maxLines', () => { + const text = 'x'.repeat(1000); + const five = fallbackTruncate(text, 5); + const two = fallbackTruncate(text, 2); + expect(two.length).toBeLessThan(five.length); + }); +}); diff --git a/projects/angular-inline-select/json/src/json-preview.ts b/projects/angular-inline-select/json/src/json-preview.ts new file mode 100644 index 0000000..986f034 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-preview.ts @@ -0,0 +1,160 @@ +import { + prepareWithSegments, + layoutNextLineRange, + materializeLineRange, + measureNaturalWidth, + type LayoutCursor, +} from '@chenglou/pretext'; + +/** + * The idle preview is PARAGRAPH TEXT: the compact JSON string flows inline + * exactly like the surrounding copy (that is the whole point of "inline"), + * wraps at whatever width the container currently has, and — when it would + * exceed the visual-line budget — ellipses IN THE MIDDLE with real head and + * real tail content. + * + * "Visual line" is the operative word: the budget is measured against the + * rendered layout (font, container width, the mid-paragraph start of the + * first line), not against any pre-formatted line structure. The measurement + * runs on @chenglou/pretext — canvas-metric text layout, zero DOM reflow — + * so a multi-megabyte value costs a bounded head/tail slice of measurement, + * never a full render. + */ + +export const PREVIEW_ELLIPSIS = ' ⋯'; + +/** The geometry of the paragraph slot the preview renders into. */ +export interface InlinePreviewGeometry { + /** Width remaining on the line the preview STARTS on (it begins mid-paragraph). */ + firstLineWidth: number; + /** Full content width of the containing block — every following line. */ + lineWidth: number; + /** Canvas font shorthand of the rendered text, e.g. `400 16px Roboto`. */ + font: string; + /** Letter spacing in px, when the computed style carries one. */ + letterSpacing?: number; +} + +const ORIGIN: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }; + +/** + * How many rendered lines `prepared` occupies with a distinct first-line + * width, stopping early once the count exceeds `limit` (never walks a huge + * text past the budget). + */ +function countLines( + prepared: ReturnType, + geometry: InlinePreviewGeometry, + limit: number, +): number { + let cursor = ORIGIN; + let count = 0; + + while (count <= limit) { + const width = count === 0 ? Math.max(geometry.firstLineWidth, 24) : geometry.lineWidth; + const range = layoutNextLineRange(prepared, cursor, width); + if (range === null) return count; + + cursor = range.end; + count++; + } + + return count; // limit + 1 — enough to know it does not fit +} + +/** + * Middle-ellipsis truncation measured in VISUAL lines. + * + * Layout plan for a budget of N lines (N ≥ 2): + * - head: lines 1..⌈N/2⌉ — line 1 at the partial first-line width, the last + * head line reserving the ellipsis width so " ⋯" lands on it; + * - a hard break after the ellipsis; + * - tail: the LAST ⌊N/2⌋ rendered lines of the value at full width. Greedy + * wrapping is memoryless from a line start, so the final line-range starts + * of the tail slice are exactly the final rendered lines of the full text. + * + * Head and tail measure bounded SLICES sized from a probe of the actual + * font's average character width — the middle of a huge value is never + * prepared, measured, or materialized. + */ +export function truncateToVisualLines( + text: string, + maxLines: number, + geometry: InlinePreviewGeometry, +): string { + const options = { whiteSpace: 'pre-wrap' as const, letterSpacing: geometry.letterSpacing }; + const budget = Math.max(maxLines, 2); + + // Probe the font: average character width over a JSON-typical sample. + const probeText = '{"abcdefgh": 12345, "x": true},'; + const probe = prepareWithSegments(probeText, geometry.font, options); + const averageCharWidth = Math.max(measureNaturalWidth(probe) / probeText.length, 1); + const charsPerLine = Math.max(Math.ceil(geometry.lineWidth / averageCharWidth), 8); + + // Fits check on a bounded prefix: only a text short enough to possibly fit + // is ever fully measured. + const fitBudgetChars = charsPerLine * (budget + 1) * 2; + if (text.length <= fitBudgetChars) { + const whole = prepareWithSegments(text, geometry.font, options); + if (countLines(whole, geometry, budget) <= budget) return text; + } + + const headLines = Math.ceil(budget / 2); + const tailLines = budget - headLines; + + const ellipsis = prepareWithSegments(PREVIEW_ELLIPSIS, geometry.font, options); + const ellipsisWidth = measureNaturalWidth(ellipsis); + + // HEAD — walk exactly headLines ranges with per-line widths. + const headSlice = text.slice(0, charsPerLine * (headLines + 1) * 2); + const headPrepared = prepareWithSegments(headSlice, geometry.font, options); + + let head = ''; + let cursor = ORIGIN; + for (let i = 0; i < headLines; i++) { + const width = + i === 0 + ? Math.max(geometry.firstLineWidth, 24) + : i === headLines - 1 + ? Math.max(geometry.lineWidth - ellipsisWidth, 24) + : geometry.lineWidth; + + const range = layoutNextLineRange(headPrepared, cursor, width); + if (range === null) break; + + head += materializeLineRange(headPrepared, range).text; + cursor = range.end; + } + + // TAIL — the last tailLines rendered lines of the value at full width. + const tailSlice = text.slice(-(charsPerLine * (tailLines + 1) * 2)); + const tailPrepared = prepareWithSegments(tailSlice, geometry.font, options); + + const tailLineTexts: string[] = []; + let tailCursor = ORIGIN; + for (;;) { + const range = layoutNextLineRange(tailPrepared, tailCursor, geometry.lineWidth); + if (range === null) break; + + tailLineTexts.push(materializeLineRange(tailPrepared, range).text); + tailCursor = range.end; + } + + const tail = tailLineTexts.slice(-Math.max(tailLines, 1)).join(''); + + return `${head}${PREVIEW_ELLIPSIS}\n${tail}`; +} + +/** + * Measurement-free fallback (SSR, jsdom, a not-yet-laid-out container): a + * character-budget middle ellipsis. Same shape, coarser cut — the rendered + * result is refined by `truncateToVisualLines` as soon as geometry exists. + */ +export function fallbackTruncate(text: string, maxLines: number, charsPerLine = 80): string { + const budget = Math.max(maxLines, 2) * charsPerLine; + if (text.length <= budget) return text; + + const headChars = Math.ceil(budget * 0.6); + const tailChars = budget - headChars; + return `${text.slice(0, headChars)}${PREVIEW_ELLIPSIS}\n${text.slice(-tailChars)}`; +} diff --git a/projects/angular-inline-select/json/src/json-session.html b/projects/angular-inline-select/json/src/json-session.html new file mode 100644 index 0000000..b0d2594 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-session.html @@ -0,0 +1,55 @@ +
+
+ @if (data.prefixTemplate(); as prefix) { + + } +
+ @if (data.suffixTemplate(); as suffix) { + + } +
+ + +
diff --git a/projects/angular-inline-select/json/src/json-session.ts b/projects/angular-inline-select/json/src/json-session.ts new file mode 100644 index 0000000..cc162f8 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-session.ts @@ -0,0 +1,103 @@ +import { + Component, + DestroyRef, + ElementRef, + Signal, + TemplateRef, + afterNextRender, + inject, + viewChild, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +import { EDITABLE_DIALOG_DATA } from 'angular-inline-select'; + +import { EditorView } from '@codemirror/view'; + +import { createJsonEditorState } from './json-editor'; + +/** + * Everything the session needs, passed through the dialog's `data` channel + * (values, live SIGNALS from the owner, and the two settlement callbacks). + */ +export interface JsonSessionData { + /** The editor's initial text — the editing form (pretty, bare keys). */ + seed: string; + /** Live draft channel: called on every keystroke with the full text. */ + onDraftChange: (text: string) => void; + + /** Mat-form-field rule: the OWNER decides when errors show. */ + errorsVisible: Signal; + /** Fallback error texts (contract messages + the live parse error). */ + errorMessages: Signal; + /** Consumer-provided error template — takes over the error slot entirely. */ + errorTemplate: Signal | undefined>; + /** Whether the draft semantically differs from the baseline. */ + isDirty: Signal; + + /** Affix templates, rendered beside the editor. */ + prefixTemplate: Signal | undefined>; + suffixTemplate: Signal | undefined>; + + /** + * THE accept path — the owner's commit: canonicalizes the draft back to + * strict JSON text (JSON.stringify) and settles the rest state, closing + * the dialog on success. An invalid draft keeps the dialog open and the + * error signals above light up instead. + */ + close: (draft: string) => void; + /** Discard the session (the owner reverts to the baseline and closes). */ + cancel: () => void; +} + +/** + * The JSON editing session — a self-contained component PORTALED by + * `editable-dialog` (never rendered inline), so it and CodeMirror behind it + * load lazily via `await import(…)` only when a session actually opens. + */ +@Component({ + selector: 'angular-inline-json-session', + imports: [NgTemplateOutlet], + templateUrl: './json-session.html', + // The host box disappears (like the dialog container's): the editor line + // and footer participate DIRECTLY in the dialog card's column flex — a + // host box in between would be an unshrinkable flex item (min-height:auto + // = content size) and push the footer's actions off screen. + styles: ':host { display: contents; }', +}) +export class JsonSession { + protected data = inject(EDITABLE_DIALOG_DATA) as JsonSessionData; + + protected editorContainer = viewChild.required>('editorContainer'); + + #view: EditorView | null = null; + + #mount = afterNextRender(() => { + const container = this.editorContainer().nativeElement; + + // Read the app's ACTIVE scheme off the mount point (it inherits through + // the overlay container) so CodeMirror's own base theme — caret, + // tooltips, selection — flips with dark mode instead of assuming light. + const dark = getComputedStyle(container).colorScheme.includes('dark'); + + const state = createJsonEditorState( + this.data.seed, + { onChange: (text) => this.data.onDraftChange(text) }, + { dark }, + ); + + this.#view = new EditorView({ state, parent: container }); + this.#view.focus(); + }); + + #destroyView = inject(DestroyRef).onDestroy(() => this.#view?.destroy()); + + /** Save: hand the CM document (the source of truth) to the owner's accept path. */ + protected save() { + this.data.close(this.#view?.state.doc.toString() ?? this.data.seed); + } + + protected discard() { + this.data.cancel(); + } +} diff --git a/projects/angular-inline-select/json/src/public-api.ts b/projects/angular-inline-select/json/src/public-api.ts new file mode 100644 index 0000000..c33f978 --- /dev/null +++ b/projects/angular-inline-select/json/src/public-api.ts @@ -0,0 +1,15 @@ +/* + * Public API Surface of angular-inline-select/json + * + * Secondary entry point: apps that never import it carry zero CodeMirror + * bytes. CodeMirror (@codemirror/state, /view, /language, /commands, /lint) + * and @lezer/highlight are optional peer dependencies of this subpath only. + */ + +export * from './json-doc'; +export * from './json-codec'; +export * from './json-preview'; +export * from './angular-inline-json'; +// Type-only: the session COMPONENT stays behind `await import(…)` so its +// CodeMirror payload loads on first open, never eagerly. +export type { JsonSessionData } from './json-session'; diff --git a/projects/angular-inline-select/package.json b/projects/angular-inline-select/package.json index ce73019..da5e86d 100644 --- a/projects/angular-inline-select/package.json +++ b/projects/angular-inline-select/package.json @@ -8,7 +8,14 @@ "@angular/cdk": "^22.0.2", "@angular/material": "^22.0.2", "libphonenumber-js": "^1.13.0", - "luxon": "^3.0.0" + "luxon": "^3.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.43.0", + "@codemirror/language": "^6.12.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/lint": "^6.9.0", + "@lezer/highlight": "^1.2.0", + "@chenglou/pretext": "^0.0.8" }, "peerDependenciesMeta": { "libphonenumber-js": { @@ -19,6 +26,27 @@ }, "@angular/material": { "optional": true + }, + "@codemirror/state": { + "optional": true + }, + "@codemirror/view": { + "optional": true + }, + "@codemirror/language": { + "optional": true + }, + "@lezer/highlight": { + "optional": true + }, + "@codemirror/commands": { + "optional": true + }, + "@codemirror/lint": { + "optional": true + }, + "@chenglou/pretext": { + "optional": true } }, "dependencies": { diff --git a/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts index e08733c..39a47b2 100644 --- a/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts @@ -1,4 +1,4 @@ -import { Directive } from '@angular/core'; +import { Directive, TemplateRef, inject } from '@angular/core'; /** * Marker for parent-provided error content — the mat-error analogue. @@ -23,3 +23,22 @@ import { Directive } from '@angular/core'; selector: '[editable-error]', }) export class EditableError {} + +/** + * TEMPLATE variant of {@link EditableError} — for controls whose session UI + * renders in a PORTALED component (the JSON control's modal dialog) where + * `` projection cannot reach. Same ownership split: the consumer + * decides what the error says, the control decides when it shows. + * + * ```html + * + * Metadata is required. + * + * ``` + */ +@Directive({ + selector: 'ng-template[editableError]', +}) +export class EditableErrorTemplate { + readonly templateRef = inject>(TemplateRef); +} diff --git a/projects/angular-inline-select/src/lib/styles/_editable-dialog.scss b/projects/angular-inline-select/src/lib/styles/_editable-dialog.scss new file mode 100644 index 0000000..4060caf --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-dialog.scss @@ -0,0 +1,118 @@ +// ============================================================================= +// editable-dialog — the reusable modal editing surface (see editable-dialog.ts). +// +// All placement lives HERE, not in a position strategy: the pane is a fixed +// full-viewport flex container that centers the card on pointer-precise +// viewports and goes FULL-SCREEN on touch/narrow ones. The pane itself is +// click-transparent (pointer-events: none) so outside clicks land on the +// CDK backdrop (.editable-scrim) and scrim-click semantics keep working. +// ============================================================================= + +// Doubled class beats CDK's own `.cdk-overlay-pane { pointer-events: auto }`. +.editable-dialog-pane.editable-dialog-pane { + position: fixed; + inset: 0; + box-sizing: border-box; + + display: flex; + align-items: center; + justify-content: center; + padding: var(--mat-sys-inner-spacing, 16px); + + pointer-events: none; +} + +.editable-dialog { + pointer-events: auto; + box-sizing: border-box; + + display: flex; + flex-direction: column; + gap: var(--mat-sys-form-field-gap, calc(var(--mat-sys-spacing, 0.25rem) * 2)); + + // Readable measure: 600px whenever the viewport affords it plus the pane's + // inline padding, else the full pane width. + width: var(--editable-dialog-width, min(632px, 100%)); + + // Height may use the WHOLE small viewport (svh, not vh/dvh): 100vh on + // mobile extends under the browser chrome and swallows the action row — + // the classic MatDialog failure. svh is the height that is ALWAYS visible. + max-height: min(calc(100svh - 2 * var(--mat-sys-inner-spacing, 16px)), 100%); + overflow: hidden; // content (e.g. an editor) scrolls internally + + background: var(--editable-panel-background, var(--mat-sys-surface-container, #fff)); + border: 1px solid + var( + --editable-panel-border-color, + color-mix( + in oklch, + var(--mat-sys-on-surface, #000) 20%, + var(--mat-sys-surface-container, #fff) + ) + ); + border-radius: var(--editable-panel-radius, var(--mat-sys-corner-large, var(--radius, 0.625rem))); + + box-shadow: var( + --editable-panel-shadow, + 0.5px 0.5px 1px hsl(0deg 0% 0% / 0.05), + 1px 1px 2px hsl(0deg 0% 0% / 0.05), + 2px 2px 4px hsl(0deg 0% 0% / 0.05), + 4px 4px 6px hsl(0deg 0% 0% / 0.04) + ); +} + +// Touch or narrow viewports: the dialog IS the screen — no card chrome. +// Height is svh + safe-area so the footer's buttons are ALWAYS on screen, +// above the browser chrome and the home indicator. +@media (max-width: 599.98px), (pointer: coarse) { + .editable-dialog-pane.editable-dialog-pane { + padding: 0; + } + + .editable-dialog { + width: 100%; + height: 100svh; + max-height: 100svh; + border: none; + border-radius: 0; + box-shadow: none; + + padding-block-end: calc( + var(--mat-sys-inner-spacing, 16px) * 0.75 + env(safe-area-inset-bottom, 0px) + ); + } + + // The content line stretches to fill the screen; its internal scroller + // (e.g. CodeMirror) absorbs the overflow, so the footer stays put at the + // bottom instead of being pushed off. + .editable-dialog .editable-panel__line { + flex: 1 1 auto; + min-height: 0; + align-items: stretch; + } +} + +// ----------------------------------------------------------------------------- +// ANIMATION: dialog enter +// ----------------------------------------------------------------------------- +@keyframes editable-dialog-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.99); + } + to { + opacity: 1; + transform: none; + } +} + +.editable-dialog-enter { + animation: editable-dialog-enter 0.15s var(--editable-ease-emphasized, cubic-bezier(0, 0, 0.2, 1)); + will-change: opacity, transform; +} + +@media (prefers-reduced-motion: reduce) { + .editable-dialog-enter { + animation: none; + } +} diff --git a/projects/angular-inline-select/src/lib/styles/_editable-json.scss b/projects/angular-inline-select/src/lib/styles/_editable-json.scss new file mode 100644 index 0000000..63ba819 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-json.scss @@ -0,0 +1,214 @@ +// ============================================================================= +// editable-json-* — the inline JSON component: the bounded preview and the +// elevated CodeMirror editing surface. +// +// Design contract — SAME as the text field: at rest the preview looks and +// flows exactly like the inline-text control (per-line dashed underline, +// inherited typography, no box) — the value IS a JSON-stringified string, so +// it presents as text. The visual property block below mirrors +// _editable-text.scss's `.editable-text__display` on purpose; keep them in +// sync when the text affordance changes. +// +// Shared concerns resolve the SAME --editable-text-*/--editable-panel-* +// tokens as every other control — one theme customization covers all. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// HOST: +// ----------------------------------------------------------------------------- +.editable-json { + display: inline; +} + +.editable-json__field { + display: inline; + + transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + + .editable-json--editing & { + opacity: var(--editable-text-dim-opacity, 0.35); + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: the bounded preview — visually the inline-text display (mirrors +// _editable-text.scss). Multi-line pre-wrap text with a per-line dashed +// underline; activation (click/Enter/Space) elevates instead of typing. +// ----------------------------------------------------------------------------- +.editable-json__display { + display: inline; + white-space: pre-wrap; + overflow-wrap: anywhere; + cursor: text; + outline: none; + + font-family: inherit; + font-size: inherit; + color: var(--editable-text-color, inherit); + + // Per-line dashed underline that stops where the text stops — identical to + // the text field, same opt-out token, same focus/error re-assertions. + text-decoration-line: var(--editable-text-underline, underline); + text-decoration-style: dashed; + text-decoration-thickness: 0.0625rem; + text-underline-offset: 0.4em; + text-decoration-color: var(--editable-text-underline-color, var(--mat-sys-primary, #428bca)); + + transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + + &:focus-visible { + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: 0.125rem; + } + + &[aria-disabled='true'] { + cursor: default; + } + + .editable-json--invalid & { + text-decoration-line: underline; + text-decoration-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); + } + + // The dashed affordance rests while the elevated editor is open — the + // whole field area dims via `.editable-json__field`. + .editable-json--editing & { + text-decoration-line: none; + } +} + +.editable-json__placeholder { + font-style: italic; + opacity: var(--editable-text-placeholder-opacity, 0.3875); +} + +// ----------------------------------------------------------------------------- +// ELEMENT: editing session content (inside the editable-dialog) +// ----------------------------------------------------------------------------- + +// Transparent wrapper (carries the ctrl+enter listener): its children — the +// editor line and the footer — lay out as direct flex children of the dialog. +.editable-json__session { + display: contents; +} + +// COMPONENT-owned spacing (never the generic dialog's): the action row keeps +// clear of every edge — the shadcn/MatDialog lesson learned the hard way. +.editable-json__session .editable-panel__footer { + padding: var(--mat-sys-inner-spacing, 16px); +} + +.editable-json__editor { + flex: 1 1 auto; + min-width: 0; + + // This file already renders unencapsulated (loaded once, globally) so these + // target CodeMirror's own classes directly. The scroll recipe is CM6's + // documented one: bound the editor's height, let .cm-scroller overflow. + // Type story is GitHub's: their monospace stack, 1.5 line height; the + // Primer syntax colors live in json-editor.ts (--editable-json-syntax-*). + .cm-editor { + min-height: 4em; + max-height: 60vh; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.875em; + line-height: 1.5; + color: var(--editable-text-editor-color, var(--mat-sys-on-surface, inherit)); + } + + .cm-editor.cm-focused { + outline: none; // the panel itself is the focus affordance + } + + .cm-scroller { + overflow: auto; + font-family: inherit; + line-height: inherit; + } + + // The code BODY breathes — especially the top, where CM's default 4px + // leaves the first line glued to the edge. + .cm-content { + padding-block: var(--mat-sys-inner-spacing, 16px); + padding-inline: calc(var(--mat-sys-inner-spacing, 16px) * 0.5) + var(--mat-sys-inner-spacing, 16px); + } + + // GitHub-style gutter: quiet numbers, no chrome of its own. light-dark() + // follows the app's color-scheme like the syntax palette does. + .cm-gutters { + background: transparent; + border: none; + color: var(--editable-json-gutter-color, light-dark(#8c959f, #6e7681)); + } + + // Numbers ride the same block padding as the code body they label. + .cm-gutter { + padding-block: var(--mat-sys-inner-spacing, 16px); + } + + .cm-lineNumbers .cm-gutterElement { + padding-inline: var(--mat-sys-inner-spacing, 16px) calc(var(--mat-sys-inner-spacing, 16px) * 0.25); + min-width: 3ch; + } + + // CodeMirror uses the NATIVE caret (no drawSelection extension). The dark + // base theme (json-editor.ts passes the scheme at mount) already keeps it + // visible; this pins it to the family caret token on top. + .cm-editor .cm-scroller .cm-content { + caret-color: var(--editable-text-caret-color, var(--mat-sys-primary, #428bca)); + } + + // Lint/hover tooltips (the inline error message) ride the panel surface + // tokens — readable in BOTH schemes instead of CM's light-only defaults. + .cm-tooltip { + background: var( + --editable-panel-background, + var(--mat-sys-surface-container, light-dark(#fff, #1c2020)) + ); + color: var(--mat-sys-on-surface, light-dark(#1f1f1f, #e3e3e3)); + border: 1px solid + var( + --editable-panel-border-color, + color-mix( + in oklch, + var(--mat-sys-on-surface, #000) 20%, + var(--mat-sys-surface-container, #fff) + ) + ); + border-radius: var(--mat-sys-corner-small, 0.4rem); + } + + .cm-diagnostic-error { + border-inline-start-color: var(--editable-text-error-color, var(--mat-sys-error, #dc3545)); + } +} + +// Full-screen dialog mode (same query as _editable-dialog.scss): the editor +// fills the stretched line instead of capping at 60vh — the line's flex +// height bounds it and .cm-scroller absorbs the overflow, keeping the +// footer's actions on screen. +@media (max-width: 599.98px), (pointer: coarse) { + .editable-json__editor { + display: flex; + flex-direction: column; + + .cm-editor { + flex: 1 1 auto; + max-height: none; + min-height: 0; + } + } +} + +// ----------------------------------------------------------------------------- +// Reduced motion +// ----------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .editable-json__field, + .editable-json__display { + transition: none; + } +} diff --git a/projects/angular-inline-select/src/lib/styles/_editable-scrollbar.scss b/projects/angular-inline-select/src/lib/styles/_editable-scrollbar.scss new file mode 100644 index 0000000..f189ba7 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-scrollbar.scss @@ -0,0 +1,98 @@ +// ============================================================================= +// editable-scrollbar — quiet, token-driven scrollbar for scroll containers. +// +// Opt-in: put `editable-scrollbar` on any scroll container. The library's own +// scrollers (slash-menu, the JSON editor's CodeMirror scroller) are included +// below. Tokens: +// --editable-scrollbar-thumb (default --mat-sys-outline) +// --editable-scrollbar-focus (default --mat-sys-primary) +// +// Behavior: translucent pill thumb in an inset gutter; more opaque while the +// container is hovered, full strength on the thumb itself; tinted while +// keyboard focus is on (or inside) the container. +// ============================================================================= + +@mixin editable-scrollbar { + // Resolve the chain ONCE per container; states below read the private var. + --_scrollbar-thumb: var(--editable-scrollbar-thumb, var(--mat-sys-outline, #9aa0a6)); + --_scrollbar-focus: var(--editable-scrollbar-focus, var(--mat-sys-primary, #6750a4)); + + &::-webkit-scrollbar { + width: 10px; + height: 10px; + background: transparent; + } + + &::-webkit-scrollbar-corner { + background: transparent; + } + + /* Inset gutter without knowing the background: transparent border, paint + clipped inside it. States below must override background-COLOR only — + the `background` shorthand would reset the clip and fill the border. */ + &::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--_scrollbar-thumb), transparent 50%); + background-clip: padding-box; + border: 2px solid transparent; + border-radius: 1e3px; + } + + @media (hover) { + &:hover::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--_scrollbar-thumb), transparent 25%); + } + + &::-webkit-scrollbar-thumb:hover, + &::-webkit-scrollbar-thumb:active { + background-color: var(--_scrollbar-thumb); + } + } + + /* Keyboard focus on the container or anywhere inside it tints the thumb. + Last so it wins the equal-specificity tie against the hover states. */ + &:is(:focus-visible, :has(:focus-visible))::-webkit-scrollbar-thumb { + background-color: var(--_scrollbar-focus); + } + + /* Firefox has no scrollbar pseudo-elements — the standard properties carry + the same color story (thin; no pill radius, platform limit). Scoped to + Firefox because in Chromium any non-auto standard value would switch OFF + the richer ::-webkit painting above. */ + @supports (-moz-appearance: none) { + & { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--_scrollbar-thumb), transparent 50%) transparent; + transition: scrollbar-color 0.3s ease; + } + + @media (hover) { + &:hover { + scrollbar-color: var(--_scrollbar-thumb) transparent; + } + } + + &:is(:focus-visible, :has(:focus-visible)) { + scrollbar-color: var(--_scrollbar-focus) transparent; + } + + @media (prefers-reduced-motion: reduce) { + & { + transition: none; + } + } + } +} + +// The consumer-facing opt-in. +.editable-scrollbar { + @include editable-scrollbar; +} + +// The library's own scroll containers. +.editable-menu { + @include editable-scrollbar; +} + +.editable-json__editor .cm-scroller { + @include editable-scrollbar; +} diff --git a/projects/angular-inline-select/src/lib/styles/_index.scss b/projects/angular-inline-select/src/lib/styles/_index.scss index b43ded6..f1da9ab 100644 --- a/projects/angular-inline-select/src/lib/styles/_index.scss +++ b/projects/angular-inline-select/src/lib/styles/_index.scss @@ -6,8 +6,17 @@ // // @use '/src/lib/styles'; // -// - editable → reusable chrome (panel, scrim, bubble), --editable-* tokens -// - editable-text → the text component surfaces, --editable-text-* tokens +// - editable → reusable chrome (panel, scrim, bubble), --editable-* tokens +// - editable-text → the text component surfaces, --editable-text-* tokens +// - editable-dialog → the reusable modal editing surface (centered card / +// full-screen on touch) +// - editable-scrollbar → opt-in quiet scrollbar for scroll containers +// (`.editable-scrollbar`), --editable-scrollbar-* tokens +// - editable-json → the JSON component surfaces (secondary entry point; +// safe to include even if the app never imports it) // ============================================================================= @use './editable'; @use './editable-text'; +@use './editable-dialog'; +@use './editable-scrollbar'; +@use './editable-json'; diff --git a/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.spec.ts b/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.spec.ts new file mode 100644 index 0000000..fcadbe0 --- /dev/null +++ b/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.spec.ts @@ -0,0 +1,137 @@ +import { ApplicationRef, Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { EDITABLE_DIALOG_DATA, EditableDialog, EditableDialogRef } from './editable-dialog'; + +interface ProbeData { + label: string; + close: (draft: string) => void; +} + +/** A minimal session-style content component: injects data + ref like MatDialog. */ +@Component({ + template: ` +

{{ data.label }}

+ + + `, +}) +class Probe { + protected data = inject(EDITABLE_DIALOG_DATA) as ProbeData; + protected ref = inject(EditableDialogRef) as EditableDialogRef; +} + +function setup() { + TestBed.configureTestingModule({}); + const dialog = TestBed.inject(EditableDialog); + const appRef = TestBed.inject(ApplicationRef); + // The overlay attaches views to the ApplicationRef (no fixture) — render + // them explicitly after every open/interaction. + const tick = () => appRef.tick(); + return { dialog, tick }; +} + +describe('EditableDialog (service)', () => { + it('opens a component into the container with backdrop and pane', () => { + const { dialog, tick } = setup(); + dialog.open(Probe, { ariaLabel: 'Probe dialog', data: { label: 'x', close: () => {} } }); + tick(); + + const card = document.querySelector('.editable-dialog'); + expect(card).toBeTruthy(); + expect(card?.getAttribute('role')).toBe('dialog'); + expect(card?.getAttribute('aria-label')).toBe('Probe dialog'); + expect(document.querySelector('.editable-scrim')).toBeTruthy(); + expect(document.querySelector('.editable-dialog-pane')).toBeTruthy(); + }); + + it('injects data into the content component (MAT_DIALOG_DATA-style)', () => { + const { dialog, tick } = setup(); + dialog.open(Probe, { data: { label: 'hello data', close: () => {} } }); + tick(); + + expect(document.querySelector('.probe-label')?.textContent).toBe('hello data'); + }); + + it('the house pattern: content calls the accept callback passed in data', () => { + const { dialog, tick } = setup(); + const received: string[] = []; + const ref = dialog.open(Probe, { + data: { label: 'x', close: (draft) => received.push(draft) }, + }); + tick(); + + (document.querySelector('.probe-save') as HTMLElement).click(); + + tick(); + + expect(received).toEqual(['draft-text']); + // The OWNER closes on successful commit — the callback alone does not. + expect(document.querySelector('.editable-dialog')).toBeTruthy(); + ref.close(); + }); + + it('content can close itself through the injected ref; closed resolves with the result', async () => { + const { dialog, tick } = setup(); + const ref = dialog.open(Probe, { data: { label: 'x', close: () => {} } }); + tick(); + + (document.querySelector('.probe-self-close') as HTMLElement).click(); + + tick(); + + await expect(ref.closed).resolves.toBe('self'); + expect(document.querySelector('.editable-dialog')).toBeNull(); + }); + + it('scrim click dismisses — closed resolves undefined', async () => { + const { dialog, tick } = setup(); + const ref = dialog.open(Probe, { data: { label: 'x', close: () => {} } }); + tick(); + + (document.querySelector('.editable-scrim') as HTMLElement).click(); + + tick(); + + await expect(ref.closed).resolves.toBeUndefined(); + expect(document.querySelector('.editable-dialog')).toBeNull(); + }); + + it('Escape inside the dialog dismisses', async () => { + const { dialog, tick } = setup(); + const ref = dialog.open(Probe, { data: { label: 'x', close: () => {} } }); + tick(); + + const card = document.querySelector('.editable-dialog') as HTMLElement; + card.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + await expect(ref.closed).resolves.toBeUndefined(); + expect(document.querySelector('.editable-dialog')).toBeNull(); + }); + + it('close(result) resolves closed with the result exactly once', async () => { + const { dialog, tick } = setup(); + const ref = dialog.open(Probe, { data: { label: 'x', close: () => {} } }); + tick(); + + ref.close('the result'); + ref.close('a second close is a no-op'); + + await expect(ref.closed).resolves.toBe('the result'); + }); + + it('supports multiple sequential dialogs', async () => { + const { dialog, tick } = setup(); + + const first = dialog.open(Probe, { data: { label: 'first', close: () => {} } }); + + tick(); + first.close(); + await first.closed; + + dialog.open(Probe, { data: { label: 'second', close: () => {} } }); + + tick(); + expect(document.querySelector('.probe-label')?.textContent).toBe('second'); + }); +}); diff --git a/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.ts b/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.ts new file mode 100644 index 0000000..d1f4ab3 --- /dev/null +++ b/projects/angular-inline-select/src/lib/utils/editable-dialog/editable-dialog.ts @@ -0,0 +1,161 @@ +import { + Component, + Injectable, + InjectionToken, + Injector, + Type, + inject, +} from '@angular/core'; +import { NgComponentOutlet } from '@angular/common'; + +// CDK +import { Overlay, OverlayRef } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { A11yModule } from '@angular/cdk/a11y'; + +/** + * The data passed to `EditableDialog.open(…, { data })` — inject it in the + * content component exactly like MAT_DIALOG_DATA: + * + * ```ts + * protected data = inject(EDITABLE_DIALOG_DATA); + * ``` + * + * Data can carry anything — values, signals, and CALLBACKS. The house + * pattern for editing sessions: the opener passes its accept path as a + * `close(draft)` callback; the content component calls it on Save, and the + * opener commits (canonicalize/serialize) and closes the ref on success. + */ +export const EDITABLE_DIALOG_DATA = new InjectionToken('editable-dialog.data'); + +/** Internal: which component the container portals, and its accessible name. */ +const EDITABLE_DIALOG_CONTENT = new InjectionToken>('editable-dialog.content'); +const EDITABLE_DIALOG_ARIA_LABEL = new InjectionToken( + 'editable-dialog.aria-label', +); + +/** + * Handle for one open dialog — injectable by the content component (like + * MatDialogRef) and returned from `open()`. + */ +export class EditableDialogRef { + #overlayRef: OverlayRef; + #result: R | undefined; + #resolveClosed!: (result: R | undefined) => void; + + /** + * Resolves exactly once when the dialog is gone — with `close(result)`'s + * value, or `undefined` for dismissals (Escape, scrim click, navigation + * disposal). The opener's revert safety net lives here. + */ + readonly closed = new Promise((resolve) => (this.#resolveClosed = resolve)); + + constructor(overlayRef: OverlayRef) { + this.#overlayRef = overlayRef; + // One settlement channel for EVERY teardown path, including + // dispose-on-navigation — never resolves twice, never leaks. + overlayRef.detachments().subscribe(() => this.#resolveClosed(this.#result)); + } + + close(result?: R) { + this.#result = result; + this.#overlayRef.dispose(); + } +} + +/** + * Internal container: the dialog card chrome (focus trap, role, enter + * animation, Escape-to-dismiss) around the portaled content component. + * Placement is pure CSS — centered card on pointer-precise viewports, + * full-screen on touch/narrow (_editable-dialog.scss). + */ +@Component({ + selector: 'editable-dialog-container', + imports: [NgComponentOutlet, A11yModule], + // The host box disappears: the CARD is the pane's direct flex item, so its + // percentage width resolves against the pane — with a host box in between, + // the unknown element shrink-wraps and 100% collapses to content width. + styles: ':host { display: contents; }', + template: ` + + `, +}) +export class EditableDialogContainer { + protected content = inject(EDITABLE_DIALOG_CONTENT); + protected ariaLabel = inject(EDITABLE_DIALOG_ARIA_LABEL, { optional: true }) ?? undefined; + + #ref = inject(EditableDialogRef); + + protected dismiss(event: Event) { + event.stopPropagation(); + this.#ref.close(); + } +} + +export interface EditableDialogConfig { + /** Anything the content component needs — injected via EDITABLE_DIALOG_DATA. */ + data?: D; + /** Accessible name of the dialog. */ + ariaLabel?: string; +} + +/** + * MatDialog-shaped, house-flavored: open any component as a modal — no + * NgModule, no template declaration, and the component type can arrive + * lazily: + * + * ```ts + * const { JsonSession } = await import('./json-session'); + * const ref = this.dialog.open(JsonSession, { data: { seed, close: (draft) => … } }); + * const result = await ref.closed; // undefined on dismissal + * ``` + * + * The dialog owns only the modal mechanics (overlay, scrim, focus trap, + * Escape/scrim dismissal, full-screen-on-touch layout); the opener keeps its + * session semantics through the `data` callbacks and `ref.closed`. + */ +@Injectable({ providedIn: 'root' }) +export class EditableDialog { + #overlay = inject(Overlay); + #injector = inject(Injector); + + open( + component: Type, + config: EditableDialogConfig = {}, + ): EditableDialogRef { + const overlayRef = this.#overlay.create({ + hasBackdrop: true, + backdropClass: 'editable-scrim', + panelClass: 'editable-dialog-pane', + scrollStrategy: this.#overlay.scrollStrategies.block(), + disposeOnNavigation: true, + }); + + const ref = new EditableDialogRef(overlayRef); + overlayRef.backdropClick().subscribe(() => ref.close()); + + const injector = Injector.create({ + parent: this.#injector, + providers: [ + { provide: EditableDialogRef, useValue: ref }, + { provide: EDITABLE_DIALOG_DATA, useValue: config.data }, + { provide: EDITABLE_DIALOG_CONTENT, useValue: component }, + { provide: EDITABLE_DIALOG_ARIA_LABEL, useValue: config.ariaLabel }, + ], + }); + + overlayRef.attach(new ComponentPortal(EditableDialogContainer, null, injector)); + return ref; + } +} diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts index 6ff5ef0..92813c1 100644 --- a/projects/angular-inline-select/src/public-api.ts +++ b/projects/angular-inline-select/src/public-api.ts @@ -9,5 +9,7 @@ export * from './lib/angular-inline-text/editable-hint'; export * from './lib/angular-inline-text/editable-menu'; export * from './lib/bubble-menu/bubble-menu'; export * from './lib/bubble-menu/editable-clear'; +// Shared utilities — control-agnostic building blocks any control may use. +export * from './lib/utils/editable-dialog/editable-dialog'; export * from './lib/angular-inline-text/caret'; export * from './lib/angular-inline-number/angular-inline-number'; diff --git a/projects/angular-inline-select/tsconfig.lib.json b/projects/angular-inline-select/tsconfig.lib.json index 8faeca0..c13ded4 100644 --- a/projects/angular-inline-select/tsconfig.lib.json +++ b/projects/angular-inline-select/tsconfig.lib.json @@ -11,6 +11,7 @@ "include": [ "src/**/*.ts", "phone/src/**/*.ts", + "json/src/**/*.ts", "temporal/src/**/*.ts", "temporal-mat/src/**/*.ts" ], diff --git a/projects/angular-inline-select/tsconfig.spec.json b/projects/angular-inline-select/tsconfig.spec.json index e51d470..bc396fc 100644 --- a/projects/angular-inline-select/tsconfig.spec.json +++ b/projects/angular-inline-select/tsconfig.spec.json @@ -11,6 +11,8 @@ "src/**/*.spec.ts", "phone/src/**/*.d.ts", "phone/src/**/*.spec.ts", + "json/src/**/*.d.ts", + "json/src/**/*.spec.ts", "temporal/src/**/*.d.ts", "temporal/src/**/*.spec.ts", "temporal-mat/src/**/*.d.ts", diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts index 0863682..95e8844 100644 --- a/projects/app/src/app/app.routes.ts +++ b/projects/app/src/app/app.routes.ts @@ -65,5 +65,16 @@ export const routes: Routes = [ ...docChildren('temporal'), ], }, + { + path: 'json', + children: [ + { + path: '', + loadComponent: () => + import('./pages/json-playground/json-playground').then((m) => m.JsonPlayground), + }, + ...docChildren('json'), + ], + }, { path: '', pathMatch: 'full', redirectTo: 'text' }, ]; diff --git a/projects/app/src/app/docs/docs-data.ts b/projects/app/src/app/docs/docs-data.ts index 12d8f1a..f615302 100644 --- a/projects/app/src/app/docs/docs-data.ts +++ b/projects/app/src/app/docs/docs-data.ts @@ -51,6 +51,7 @@ export const PAGES = [ { path: 'number', label: 'Number' }, { path: 'phone', label: 'Phone' }, { path: 'temporal', label: 'Temporal' }, + { path: 'json', label: 'JSON' }, ] as const; // ----------------------------------------------------------------------------- @@ -302,6 +303,17 @@ const CHROME_TOKENS: TokenGroup = { fallback: 'cubic-bezier(0, 0, 0.2, 1)', description: 'Easing for panel lift, message and bubble enter animations.', }, + { + token: '--editable-scrollbar-thumb', + fallback: 'var(--mat-sys-outline, #9aa0a6)', + description: + 'Thumb color of the quiet scrollbar (50% translucent at rest, opaque on hover). Applied to the library’s scroll containers (slash-menu, JSON editor) and to any consumer element carrying the `editable-scrollbar` class.', + }, + { + token: '--editable-scrollbar-focus', + fallback: 'var(--mat-sys-primary, #6750a4)', + description: 'Thumb tint while keyboard focus is on — or inside — the scroll container.', + }, { token: '--editable-bubble-pad', fallback: 'calc(var(--mat-sys-inner-spacing, 16px) * 0.75)', @@ -389,6 +401,90 @@ const TEMPORAL_TOKENS: TokenGroup = { ], }; +const JSON_SURFACE_TOKENS: TokenGroup = { + title: 'JSON preview + editor surfaces', + description: + 'The idle preview looks and themes EXACTLY like the inline-text display — same per-line dashed underline, same token names, same focus/error re-assertions — so a theme written for the text field carries over unchanged. The elevated CodeMirror editor reuses the editor/caret/panel tokens.', + tokens: [ + { + token: '--editable-text-underline', + fallback: 'underline', + description: + 'The resting dashed underline’s text-decoration-line. Set to `none` to hide the affordance; keyboard focus and the idle error state re-assert their underlines.', + }, + { + token: '--editable-text-underline-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Color of the dashed affordance underline (and the solid focus underline).', + }, + { + token: '--editable-text-color', + fallback: 'inherit', + description: 'Text color of a filled (non-empty) preview.', + }, + { + token: '--editable-text-error-color', + fallback: 'var(--mat-sys-error, #dc3545)', + description: 'Underline color while the field is invalid and errors are visible.', + }, + { + token: '--editable-text-placeholder-opacity', + fallback: '0.3875', + description: 'Opacity of the empty-field placeholder.', + }, + { + token: '--editable-text-dim-opacity', + fallback: '0.35', + description: 'Opacity of the in-flow field while its elevated editor is open.', + }, + { + token: '--editable-text-editor-color', + fallback: 'var(--mat-sys-on-surface, inherit)', + description: 'Text color inside the elevated CodeMirror editor.', + }, + { + token: '--editable-text-caret-color', + fallback: 'var(--mat-sys-primary, #428bca)', + description: 'Caret color inside the elevated editor.', + }, + { + token: '--editable-dialog-width', + fallback: 'min(600px, 100%)', + description: 'Width of the editing dialog card (the readable default; full-screen on touch/narrow viewports).', + }, + { + token: '--editable-json-syntax-property', + fallback: 'light-dark(#0550ae, #79c0ff) — GitHub Primer', + description: 'Editor syntax color: object keys. Every syntax fallback follows the app color-scheme via light-dark().', + }, + { + token: '--editable-json-syntax-string', + fallback: 'light-dark(#0a3069, #a5d6ff) — GitHub Primer', + description: 'Editor syntax color: string values.', + }, + { + token: '--editable-json-syntax-number', + fallback: 'light-dark(#0550ae, #79c0ff) — GitHub Primer', + description: 'Editor syntax color: numbers.', + }, + { + token: '--editable-json-syntax-keyword', + fallback: 'light-dark(#0550ae, #79c0ff) — GitHub Primer', + description: 'Editor syntax color: true/false/null (GitHub renders JSON constants in the same accent as keys).', + }, + { + token: '--editable-json-syntax-invalid', + fallback: 'light-dark(#82071e, #ffa198) — GitHub Primer', + description: 'Editor syntax color: invalid tokens.', + }, + { + token: '--editable-json-gutter-color', + fallback: 'light-dark(#8c959f, #6e7681) — GitHub Primer', + description: 'Line-number gutter color.', + }, + ], +}; + // ----------------------------------------------------------------------------- // Sections // ----------------------------------------------------------------------------- @@ -935,4 +1031,67 @@ export const DOCS: Record = { ], tokenGroups: [TEMPORAL_TOKENS, CHROME_TOKENS], }, + + json: { + title: 'Inline JSON', + components: [ + { + name: 'AngularInlineJson', + selector: 'angular-inline-json', + summary: + 'The committed JSON flows in the page as ordinary paragraph text (styled identically to the inline-text display), middle-ellipsing at a measured visual-line budget, and elevates into a real CodeMirror editor: syntax highlighting, bracket matching, auto-indent, live lint. In the EDITOR, identifier keys may be typed without quotes (role: not "role":) — the one leniency, cutting the most common hand-typing errors; everything else stays strict (a trailing comma is still an error). Commit canonicalizes to strict, compact, double-quoted JSON.stringify — the MySQL/Postgres-friendly text the model carries, with primitives keeping their real types.', + models: [ + { + name: 'value', + type: 'string', + default: "''", + description: + 'The committed value channel: canonical strict JSON text (compact, double-quoted — JSON.stringify of the parsed draft). Opening a session reformats the editor into the editing form (pretty-printed, bare identifier keys); the semantic dirty check means a reformat alone never counts as a change.', + }, + EDITING_MODEL, + ], + inputs: [ + ...FORM_CONTRACT_INPUTS, + { + name: 'placeholder', + type: 'string', + default: "'null'", + description: 'Placeholder shown while empty.', + }, + ARIA_LABEL_INPUT, + { + name: 'maxPreviewLines', + type: 'number', + default: '5', + description: + 'Hard cap on the idle preview’s rendered VISUAL lines. The preview flows inline like paragraph text and, when the compact value would exceed the budget at the current width, middle-ellipses with real head and real tail content — measured against the actual layout (font, container width, mid-paragraph first-line start) via @chenglou/pretext, re-measured on resize. The skipped middle is never materialized, so cost is bounded regardless of value size.', + }, + { + name: 'errorTemplate', + type: 'TemplateRef | undefined', + default: 'undefined', + description: + 'Consumer error content (the mat-error analogue) as a TEMPLATE — the session UI renders in a portaled dialog component where element projection cannot reach. Content sugar: `ng-template[editableError]`. Takes over the error slot entirely; without it the control renders message-carrying errors itself.', + }, + ...AFFIX_INPUTS, + ], + outputs: [ + { + name: 'savedModelChange', + type: '{ value: string }', + description: + 'THE consumer commit event: fires once per changed settlement (accept-timed, change-gated) with the raw JSON text model.', + }, + { + name: 'saved', + type: 'InlineJsonSaved — { value: string; changed: boolean }', + description: + 'The machinery channel: exactly one emission per settled edit session — Save, Discard, and clear alike. For wrapping controls; app consumers bind savedModelChange.', + }, + TOUCH_OUTPUT, + ], + }, + ], + tokenGroups: [JSON_SURFACE_TOKENS, CHROME_TOKENS], + }, }; diff --git a/projects/app/src/app/pages/json-playground/json-playground.html b/projects/app/src/app/pages/json-playground/json-playground.html new file mode 100644 index 0000000..7fa966c --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.html @@ -0,0 +1,85 @@ +
+
+
+

Inline JSON

+

+ angular-inline-json elevates a bounded, glance-readable preview — styled exactly like the + inline-text control — into a real CodeMirror JSON editor. While editing, keys don't need quotes + (role: instead of "role":); everything else stays strict — a trailing comma is + rejected exactly as anywhere else. Commit serializes to strict, compact, double-quoted JSON — a plain string, + MySQL/Postgres-friendly, with primitives keeping their real types. +

+
+ +
+
+

Standalone [(value)]

+

+ Click the preview to open the editor: + +

+

Raw model text: {{ profile() }}

+
+ +
+

Flows like text — middle-ellipses at 5 rendered lines

+

+ This value has 5,000 keys, and it runs right here in the paragraph as ordinary text — + + — wrapping at whatever width the container has (resize the window: the cut recomputes against the + rendered layout, measured with pretext, not against pre-formatted lines). Real head, real tail, never + more than five visual lines, and the middle is never even materialized. +

+
+ +
+

Signal form + required validation

+

+ Record metadata: + + + @if (metadataMissing()) { + Metadata is required. + } + + +

+ +
+ + + + + +
+ +

+ Keys don't need quotes while editing — {{ '{tags: ["a"]}' }} commits as + {{ '{"tags":["a"]}' }} (strict, double-quoted, database-friendly). A trailing comma + ({{ '{"a":1,}' }}) still blocks Save and the lint gutter flags it live while you type. + Clearing while optional and toggling Required back on shows the idle error underline. +

+ + @if (emittedEvents().length > 0) { +
+ @for (entry of emittedEvents(); track $index) { + {{ entry }} + } +
+ } +
+
+
+
diff --git a/projects/app/src/app/pages/json-playground/json-playground.scss b/projects/app/src/app/pages/json-playground/json-playground.scss new file mode 100644 index 0000000..e1686d8 --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.scss @@ -0,0 +1 @@ +@use '../demo'; diff --git a/projects/app/src/app/pages/json-playground/json-playground.ts b/projects/app/src/app/pages/json-playground/json-playground.ts new file mode 100644 index 0000000..f2a1234 --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.ts @@ -0,0 +1,77 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, +} from '@angular/core'; +import { FormField, form, required, readonly, disabled } from '@angular/forms/signals'; + +// Material +import { MatButtonModule } from '@angular/material/button'; + +// Components +import { AngularInlineJson } from '../../../../../angular-inline-select/json/src/angular-inline-json'; +import { EditableErrorTemplate } from '../../../../../angular-inline-select/src/lib/angular-inline-text/editable-error'; + +function buildLargeConfig(): Record { + const config: Record = {}; + for (let i = 0; i < 5000; i++) config[`setting${i}`] = i % 2 === 0; + return config; +} + +@Component({ + selector: 'app-json-playground', + templateUrl: './json-playground.html', + styleUrl: './json-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + + // Forms + FormField, + + // Components + AngularInlineJson, + EditableErrorTemplate, + ], +}) +export class JsonPlayground { + // --------------------------------------------------------------------------- + // Standalone [(value)] example — small object, one-line preview + // --------------------------------------------------------------------------- + protected profile = signal('{"role":"admin","active":true}'); + + // --------------------------------------------------------------------------- + // Truncated preview: a 40-key object never renders more than 5 lines + // --------------------------------------------------------------------------- + protected largeConfig = signal(JSON.stringify(buildLargeConfig())); + + // --------------------------------------------------------------------------- + // Signal form example: required validation + field state toggles + // --------------------------------------------------------------------------- + protected fieldRequired = signal(true); + protected fieldReadonly = signal(false); + protected fieldDisabled = signal(false); + + protected metadataModel = signal<{ metadata: string }>({ metadata: '{"tags":["a","b"]}' }); + + protected metadataForm = form(this.metadataModel, (path) => { + required(path.metadata, { when: () => this.fieldRequired() }); + readonly(path.metadata, { when: () => this.fieldReadonly() }); + disabled(path.metadata, { when: () => this.fieldDisabled() }); + }); + + protected metadataMissing = computed(() => + this.metadataForm.metadata().errors().some((error) => error.kind === 'required'), + ); + + // Event console: raw JSON-text payloads, newest first. + protected emittedEvents = signal([]); + + protected logEmit(name: string, payload: unknown) { + this.emittedEvents.update((events) => [`${name} → ${JSON.stringify(payload)}`, ...events].slice(0, 8)); + } +} diff --git a/tsconfig.json b/tsconfig.json index c939a04..3f42f8f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "paths": { "angular-inline-select": ["./projects/angular-inline-select/src/public-api.ts"], "angular-inline-select/phone": ["./projects/angular-inline-select/phone/src/public-api.ts"], + "angular-inline-select/json": ["./projects/angular-inline-select/json/src/public-api.ts"], "angular-inline-select/temporal": ["./projects/angular-inline-select/temporal/src/public-api.ts"], "angular-inline-select/temporal-mat": [ "./projects/angular-inline-select/temporal-mat/src/public-api.ts" From 084320d29ea802413c534000357ba6f30fcaae7e Mon Sep 17 00:00:00 2001 From: Hong Date: Fri, 17 Jul 2026 22:49:09 +0200 Subject: [PATCH 41/48] fix(pretext): measurement more precise --- .../json/src/json-preview.ts | 45 ++++++++++++++++--- .../src/lib/styles/_editable-json.scss | 9 +++- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/projects/angular-inline-select/json/src/json-preview.ts b/projects/angular-inline-select/json/src/json-preview.ts index 986f034..eb6c7c6 100644 --- a/projects/angular-inline-select/json/src/json-preview.ts +++ b/projects/angular-inline-select/json/src/json-preview.ts @@ -37,6 +37,25 @@ export interface InlinePreviewGeometry { const ORIGIN: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }; +/** + * The measuring twin of CSS `line-break: anywhere` on the preview: interleave + * ZERO-WIDTH SPACES so every position is a break opportunity (pretext's + * segmenter treats ZWSP as `zero-width-break`). ZWSP has no advance width, so + * line WIDTHS are exactly the original text's — only the break points + * multiply. Without this, pretext breaks at UAX-14 points (after hyphens, + * etc.) while the browser fills lines completely, and the cut under-fills + * the budget. + */ +const ZERO_WIDTH_BREAK = '​'; + +function makeBreakableEverywhere(text: string): string { + return Array.from(text).join(ZERO_WIDTH_BREAK); +} + +function stripBreaks(text: string): string { + return text.replaceAll(ZERO_WIDTH_BREAK, ''); +} + /** * How many rendered lines `prepared` occupies with a distinct first-line * width, stopping early once the count exceeds `limit` (never walks a huge @@ -85,6 +104,21 @@ export function truncateToVisualLines( const options = { whiteSpace: 'pre-wrap' as const, letterSpacing: geometry.letterSpacing }; const budget = Math.max(maxLines, 2); + // The preview renders with `line-break: anywhere` — measure the same way + // (ZWSP-interleaved copies, see makeBreakableEverywhere). If the SOURCE + // already contains ZWSP (it would survive stripBreaks corrupted), measure + // it as-is: slightly conservative wrapping, never wrong content. + const breakable = !text.includes(ZERO_WIDTH_BREAK); + const prepare = (slice: string) => + prepareWithSegments(breakable ? makeBreakableEverywhere(slice) : slice, geometry.font, options); + const materialize = ( + prepared: ReturnType, + range: NonNullable>, + ) => { + const line = materializeLineRange(prepared, range).text; + return breakable ? stripBreaks(line) : line; + }; + // Probe the font: average character width over a JSON-typical sample. const probeText = '{"abcdefgh": 12345, "x": true},'; const probe = prepareWithSegments(probeText, geometry.font, options); @@ -95,8 +129,7 @@ export function truncateToVisualLines( // is ever fully measured. const fitBudgetChars = charsPerLine * (budget + 1) * 2; if (text.length <= fitBudgetChars) { - const whole = prepareWithSegments(text, geometry.font, options); - if (countLines(whole, geometry, budget) <= budget) return text; + if (countLines(prepare(text), geometry, budget) <= budget) return text; } const headLines = Math.ceil(budget / 2); @@ -107,7 +140,7 @@ export function truncateToVisualLines( // HEAD — walk exactly headLines ranges with per-line widths. const headSlice = text.slice(0, charsPerLine * (headLines + 1) * 2); - const headPrepared = prepareWithSegments(headSlice, geometry.font, options); + const headPrepared = prepare(headSlice); let head = ''; let cursor = ORIGIN; @@ -122,13 +155,13 @@ export function truncateToVisualLines( const range = layoutNextLineRange(headPrepared, cursor, width); if (range === null) break; - head += materializeLineRange(headPrepared, range).text; + head += materialize(headPrepared, range); cursor = range.end; } // TAIL — the last tailLines rendered lines of the value at full width. const tailSlice = text.slice(-(charsPerLine * (tailLines + 1) * 2)); - const tailPrepared = prepareWithSegments(tailSlice, geometry.font, options); + const tailPrepared = prepare(tailSlice); const tailLineTexts: string[] = []; let tailCursor = ORIGIN; @@ -136,7 +169,7 @@ export function truncateToVisualLines( const range = layoutNextLineRange(tailPrepared, tailCursor, geometry.lineWidth); if (range === null) break; - tailLineTexts.push(materializeLineRange(tailPrepared, range).text); + tailLineTexts.push(materialize(tailPrepared, range)); tailCursor = range.end; } diff --git a/projects/angular-inline-select/src/lib/styles/_editable-json.scss b/projects/angular-inline-select/src/lib/styles/_editable-json.scss index 63ba819..3b147b4 100644 --- a/projects/angular-inline-select/src/lib/styles/_editable-json.scss +++ b/projects/angular-inline-select/src/lib/styles/_editable-json.scss @@ -38,7 +38,14 @@ .editable-json__display { display: inline; white-space: pre-wrap; - overflow-wrap: anywhere; + overflow-wrap: anywhere; // fallback where line-break: anywhere is unsupported + + // Every character is an EQUAL break opportunity, so lines fill the full + // width. Without this the browser prefers standard break points (after + // hyphens — think UUIDs in values) and leaves ragged right edges. The + // pretext measurement mirrors this via ZWSP interleaving (json-preview.ts). + line-break: anywhere; + cursor: text; outline: none; From a865ce87ca8a1c92346639611c6fe507f613e8ae Mon Sep 17 00:00:00 2001 From: Hong Date: Fri, 17 Jul 2026 22:54:20 +0200 Subject: [PATCH 42/48] refactor(pretext): move logic up for reuse --- .../json/src/json-preview.ts | 197 ++-------------- .../middle-ellipsis/middle-ellipsis.spec.ts | 29 +++ .../utils/middle-ellipsis/middle-ellipsis.ts | 210 ++++++++++++++++++ .../angular-inline-select/src/public-api.ts | 1 + 4 files changed, 259 insertions(+), 178 deletions(-) create mode 100644 projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.spec.ts create mode 100644 projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.ts diff --git a/projects/angular-inline-select/json/src/json-preview.ts b/projects/angular-inline-select/json/src/json-preview.ts index eb6c7c6..7755e86 100644 --- a/projects/angular-inline-select/json/src/json-preview.ts +++ b/projects/angular-inline-select/json/src/json-preview.ts @@ -1,193 +1,34 @@ import { - prepareWithSegments, - layoutNextLineRange, - materializeLineRange, - measureNaturalWidth, - type LayoutCursor, -} from '@chenglou/pretext'; + MIDDLE_ELLIPSIS, + fallbackTruncate, + truncateToVisualLines as truncateInlineFlow, + type InlineFlowGeometry, +} from 'angular-inline-select'; /** - * The idle preview is PARAGRAPH TEXT: the compact JSON string flows inline - * exactly like the surrounding copy (that is the whole point of "inline"), - * wraps at whatever width the container currently has, and — when it would - * exceed the visual-line budget — ellipses IN THE MIDDLE with real head and - * real tail content. - * - * "Visual line" is the operative word: the budget is measured against the - * rendered layout (font, container width, the mid-paragraph start of the - * first line), not against any pre-formatted line structure. The measurement - * runs on @chenglou/pretext — canvas-metric text layout, zero DOM reflow — - * so a multi-megabyte value costs a bounded head/tail slice of measurement, - * never a full render. + * The JSON preview is the shared middle-ellipsis core (see + * utils/middle-ellipsis in the main entry point) flavored for JSON: + * `line-break: anywhere` rendering (code-like content packs every line + * full — mirrored in the measurement) and a JSON-typical font probe. */ -export const PREVIEW_ELLIPSIS = ' ⋯'; +export const PREVIEW_ELLIPSIS = MIDDLE_ELLIPSIS; -/** The geometry of the paragraph slot the preview renders into. */ -export interface InlinePreviewGeometry { - /** Width remaining on the line the preview STARTS on (it begins mid-paragraph). */ - firstLineWidth: number; - /** Full content width of the containing block — every following line. */ - lineWidth: number; - /** Canvas font shorthand of the rendered text, e.g. `400 16px Roboto`. */ - font: string; - /** Letter spacing in px, when the computed style carries one. */ - letterSpacing?: number; -} - -const ORIGIN: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }; - -/** - * The measuring twin of CSS `line-break: anywhere` on the preview: interleave - * ZERO-WIDTH SPACES so every position is a break opportunity (pretext's - * segmenter treats ZWSP as `zero-width-break`). ZWSP has no advance width, so - * line WIDTHS are exactly the original text's — only the break points - * multiply. Without this, pretext breaks at UAX-14 points (after hyphens, - * etc.) while the browser fills lines completely, and the cut under-fills - * the budget. - */ -const ZERO_WIDTH_BREAK = '​'; - -function makeBreakableEverywhere(text: string): string { - return Array.from(text).join(ZERO_WIDTH_BREAK); -} - -function stripBreaks(text: string): string { - return text.replaceAll(ZERO_WIDTH_BREAK, ''); -} +export type InlinePreviewGeometry = InlineFlowGeometry; -/** - * How many rendered lines `prepared` occupies with a distinct first-line - * width, stopping early once the count exceeds `limit` (never walks a huge - * text past the budget). - */ -function countLines( - prepared: ReturnType, - geometry: InlinePreviewGeometry, - limit: number, -): number { - let cursor = ORIGIN; - let count = 0; - - while (count <= limit) { - const width = count === 0 ? Math.max(geometry.firstLineWidth, 24) : geometry.lineWidth; - const range = layoutNextLineRange(prepared, cursor, width); - if (range === null) return count; - - cursor = range.end; - count++; - } +/** Sizes the bounded measuring slices from a JSON-typical character mix. */ +const JSON_PROBE_TEXT = '{"abcdefgh": 12345, "x": true},'; - return count; // limit + 1 — enough to know it does not fit -} - -/** - * Middle-ellipsis truncation measured in VISUAL lines. - * - * Layout plan for a budget of N lines (N ≥ 2): - * - head: lines 1..⌈N/2⌉ — line 1 at the partial first-line width, the last - * head line reserving the ellipsis width so " ⋯" lands on it; - * - a hard break after the ellipsis; - * - tail: the LAST ⌊N/2⌋ rendered lines of the value at full width. Greedy - * wrapping is memoryless from a line start, so the final line-range starts - * of the tail slice are exactly the final rendered lines of the full text. - * - * Head and tail measure bounded SLICES sized from a probe of the actual - * font's average character width — the middle of a huge value is never - * prepared, measured, or materialized. - */ +/** Middle-ellipsis truncation measured in VISUAL lines — JSON flavor. */ export function truncateToVisualLines( text: string, maxLines: number, geometry: InlinePreviewGeometry, ): string { - const options = { whiteSpace: 'pre-wrap' as const, letterSpacing: geometry.letterSpacing }; - const budget = Math.max(maxLines, 2); - - // The preview renders with `line-break: anywhere` — measure the same way - // (ZWSP-interleaved copies, see makeBreakableEverywhere). If the SOURCE - // already contains ZWSP (it would survive stripBreaks corrupted), measure - // it as-is: slightly conservative wrapping, never wrong content. - const breakable = !text.includes(ZERO_WIDTH_BREAK); - const prepare = (slice: string) => - prepareWithSegments(breakable ? makeBreakableEverywhere(slice) : slice, geometry.font, options); - const materialize = ( - prepared: ReturnType, - range: NonNullable>, - ) => { - const line = materializeLineRange(prepared, range).text; - return breakable ? stripBreaks(line) : line; - }; - - // Probe the font: average character width over a JSON-typical sample. - const probeText = '{"abcdefgh": 12345, "x": true},'; - const probe = prepareWithSegments(probeText, geometry.font, options); - const averageCharWidth = Math.max(measureNaturalWidth(probe) / probeText.length, 1); - const charsPerLine = Math.max(Math.ceil(geometry.lineWidth / averageCharWidth), 8); - - // Fits check on a bounded prefix: only a text short enough to possibly fit - // is ever fully measured. - const fitBudgetChars = charsPerLine * (budget + 1) * 2; - if (text.length <= fitBudgetChars) { - if (countLines(prepare(text), geometry, budget) <= budget) return text; - } - - const headLines = Math.ceil(budget / 2); - const tailLines = budget - headLines; - - const ellipsis = prepareWithSegments(PREVIEW_ELLIPSIS, geometry.font, options); - const ellipsisWidth = measureNaturalWidth(ellipsis); - - // HEAD — walk exactly headLines ranges with per-line widths. - const headSlice = text.slice(0, charsPerLine * (headLines + 1) * 2); - const headPrepared = prepare(headSlice); - - let head = ''; - let cursor = ORIGIN; - for (let i = 0; i < headLines; i++) { - const width = - i === 0 - ? Math.max(geometry.firstLineWidth, 24) - : i === headLines - 1 - ? Math.max(geometry.lineWidth - ellipsisWidth, 24) - : geometry.lineWidth; - - const range = layoutNextLineRange(headPrepared, cursor, width); - if (range === null) break; - - head += materialize(headPrepared, range); - cursor = range.end; - } - - // TAIL — the last tailLines rendered lines of the value at full width. - const tailSlice = text.slice(-(charsPerLine * (tailLines + 1) * 2)); - const tailPrepared = prepare(tailSlice); - - const tailLineTexts: string[] = []; - let tailCursor = ORIGIN; - for (;;) { - const range = layoutNextLineRange(tailPrepared, tailCursor, geometry.lineWidth); - if (range === null) break; - - tailLineTexts.push(materialize(tailPrepared, range)); - tailCursor = range.end; - } - - const tail = tailLineTexts.slice(-Math.max(tailLines, 1)).join(''); - - return `${head}${PREVIEW_ELLIPSIS}\n${tail}`; + return truncateInlineFlow(text, maxLines, geometry, { + breakAnywhere: true, // pairs with `line-break: anywhere` in _editable-json.scss + probeText: JSON_PROBE_TEXT, + }); } -/** - * Measurement-free fallback (SSR, jsdom, a not-yet-laid-out container): a - * character-budget middle ellipsis. Same shape, coarser cut — the rendered - * result is refined by `truncateToVisualLines` as soon as geometry exists. - */ -export function fallbackTruncate(text: string, maxLines: number, charsPerLine = 80): string { - const budget = Math.max(maxLines, 2) * charsPerLine; - if (text.length <= budget) return text; - - const headChars = Math.ceil(budget * 0.6); - const tailChars = budget - headChars; - return `${text.slice(0, headChars)}${PREVIEW_ELLIPSIS}\n${text.slice(-tailChars)}`; -} +export { fallbackTruncate }; diff --git a/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.spec.ts b/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.spec.ts new file mode 100644 index 0000000..f0f23ec --- /dev/null +++ b/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.spec.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { MIDDLE_ELLIPSIS, fallbackTruncate } from './middle-ellipsis'; + +// `truncateToVisualLines` needs real canvas text metrics (pretext measures +// with Canvas 2D), which jsdom does not provide — it is exercised through +// the JSON control's facade and in the browser. The measurement-free +// fallback is fully testable here. + +describe('fallbackTruncate (shared middle-ellipsis core)', () => { + it('returns short text unchanged', () => { + expect(fallbackTruncate('short text', 5)).toBe('short text'); + }); + + it('middle-ellipses long text — real head, real tail, bounded output', () => { + const text = `START-${'x'.repeat(5000)}-END`; + + const result = fallbackTruncate(text, 5); + + expect(result.length).toBeLessThan(text.length); + expect(result).toContain(MIDDLE_ELLIPSIS); + expect(result.startsWith('START-')).toBe(true); + expect(result.endsWith('-END')).toBe(true); + }); + + it('scales its budget with maxLines', () => { + const text = 'y'.repeat(1000); + expect(fallbackTruncate(text, 2).length).toBeLessThan(fallbackTruncate(text, 5).length); + }); +}); diff --git a/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.ts b/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.ts new file mode 100644 index 0000000..fa7ab25 --- /dev/null +++ b/projects/angular-inline-select/src/lib/utils/middle-ellipsis/middle-ellipsis.ts @@ -0,0 +1,210 @@ +import { + prepareWithSegments, + layoutNextLineRange, + materializeLineRange, + measureNaturalWidth, + type LayoutCursor, +} from '@chenglou/pretext'; + +/** + * Middle-ellipsis truncation for text that FLOWS INLINE with surrounding + * copy — the shared core behind the JSON preview, reusable by any control + * whose committed value can outgrow its in-flow presentation (inline-text + * next). + * + * The budget is measured in RENDERED lines at the caller's geometry (font, + * container width, the mid-paragraph start of the first line), not in any + * pre-formatted structure. Measurement runs on @chenglou/pretext — canvas- + * metric text layout, zero DOM reflow — and only bounded head/tail slices + * are ever prepared or materialized, so cost is O(budget) regardless of how + * large the text is. + * + * `@chenglou/pretext` is an optional peer dependency: apps that never call + * `truncateToVisualLines` tree-shake it away (`fallbackTruncate` alone has + * no dependency). + */ + +export const MIDDLE_ELLIPSIS = ' ⋯'; + +/** The geometry of the paragraph slot the text flows in. */ +export interface InlineFlowGeometry { + /** Width remaining on the line the text STARTS on (it begins mid-paragraph). */ + firstLineWidth: number; + /** Full content width of the containing block — every following line. */ + lineWidth: number; + /** Canvas font shorthand of the rendered text, e.g. `400 16px Roboto`. */ + font: string; + /** Letter spacing in px, when the computed style carries one. */ + letterSpacing?: number; +} + +export interface MiddleEllipsisOptions { + /** + * MUST mirror the rendering CSS. `true` when the element renders with + * `line-break: anywhere` (every character an equal break opportunity — + * code-like content such as JSON); `false` (default) for standard CSS + * wrapping (prose). Measuring with the wrong mode under- or over-fills + * the budget. + */ + breakAnywhere?: boolean; + /** + * Sample used to probe the font's average character width (sizes the + * bounded head/tail slices). Pass something typical of the content. + */ + probeText?: string; +} + +const ORIGIN: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }; + +/** + * The measuring twin of CSS `line-break: anywhere`: interleave ZERO-WIDTH + * SPACES so every position is a break opportunity (pretext's segmenter + * treats ZWSP as `zero-width-break`). ZWSP has no advance width, so line + * WIDTHS are exactly the original text's — only the break points multiply. + */ +const ZERO_WIDTH_BREAK = '​'; + +function makeBreakableEverywhere(text: string): string { + return Array.from(text).join(ZERO_WIDTH_BREAK); +} + +function stripBreaks(text: string): string { + return text.replaceAll(ZERO_WIDTH_BREAK, ''); +} + +/** + * How many rendered lines `prepared` occupies with a distinct first-line + * width, stopping early once the count exceeds `limit` (never walks a huge + * text past the budget). + */ +function countLines( + prepared: ReturnType, + geometry: InlineFlowGeometry, + limit: number, +): number { + let cursor = ORIGIN; + let count = 0; + + while (count <= limit) { + const width = count === 0 ? Math.max(geometry.firstLineWidth, 24) : geometry.lineWidth; + const range = layoutNextLineRange(prepared, cursor, width); + if (range === null) return count; + + cursor = range.end; + count++; + } + + return count; // limit + 1 — enough to know it does not fit +} + +/** + * Middle-ellipsis truncation measured in VISUAL lines. + * + * Layout plan for a budget of N lines (N ≥ 2): + * - head: lines 1..⌈N/2⌉ — line 1 at the partial first-line width, the last + * head line reserving the ellipsis width so " ⋯" lands on it; + * - a hard break after the ellipsis; + * - tail: the LAST ⌊N/2⌋ rendered lines of the value at full width. Greedy + * wrapping is memoryless from a line start, so the final line-range starts + * of the tail slice are exactly the final rendered lines of the full text. + * + * Head and tail measure bounded SLICES sized from a probe of the actual + * font's average character width — the middle of a huge value is never + * prepared, measured, or materialized. + */ +export function truncateToVisualLines( + text: string, + maxLines: number, + geometry: InlineFlowGeometry, + ellipsisOptions: MiddleEllipsisOptions = {}, +): string { + const options = { whiteSpace: 'pre-wrap' as const, letterSpacing: geometry.letterSpacing }; + const budget = Math.max(maxLines, 2); + + // When the element renders with `line-break: anywhere`, measure the same + // way (ZWSP-interleaved copies). If the SOURCE already contains ZWSP (it + // would survive stripBreaks corrupted), measure it as-is: slightly + // conservative wrapping, never wrong content. + const breakable = (ellipsisOptions.breakAnywhere ?? false) && !text.includes(ZERO_WIDTH_BREAK); + const prepare = (slice: string) => + prepareWithSegments(breakable ? makeBreakableEverywhere(slice) : slice, geometry.font, options); + const materialize = ( + prepared: ReturnType, + range: NonNullable>, + ) => { + const line = materializeLineRange(prepared, range).text; + return breakable ? stripBreaks(line) : line; + }; + + // Probe the font: average character width over a content-typical sample. + const probeText = ellipsisOptions.probeText ?? 'The quick brown fox, 12345.'; + const probe = prepareWithSegments(probeText, geometry.font, options); + const averageCharWidth = Math.max(measureNaturalWidth(probe) / probeText.length, 1); + const charsPerLine = Math.max(Math.ceil(geometry.lineWidth / averageCharWidth), 8); + + // Fits check on a bounded prefix: only a text short enough to possibly fit + // is ever fully measured. + const fitBudgetChars = charsPerLine * (budget + 1) * 2; + if (text.length <= fitBudgetChars) { + if (countLines(prepare(text), geometry, budget) <= budget) return text; + } + + const headLines = Math.ceil(budget / 2); + const tailLines = budget - headLines; + + const ellipsis = prepareWithSegments(MIDDLE_ELLIPSIS, geometry.font, options); + const ellipsisWidth = measureNaturalWidth(ellipsis); + + // HEAD — walk exactly headLines ranges with per-line widths. + const headSlice = text.slice(0, charsPerLine * (headLines + 1) * 2); + const headPrepared = prepare(headSlice); + + let head = ''; + let cursor = ORIGIN; + for (let i = 0; i < headLines; i++) { + const width = + i === 0 + ? Math.max(geometry.firstLineWidth, 24) + : i === headLines - 1 + ? Math.max(geometry.lineWidth - ellipsisWidth, 24) + : geometry.lineWidth; + + const range = layoutNextLineRange(headPrepared, cursor, width); + if (range === null) break; + + head += materialize(headPrepared, range); + cursor = range.end; + } + + // TAIL — the last tailLines rendered lines of the value at full width. + const tailSlice = text.slice(-(charsPerLine * (tailLines + 1) * 2)); + const tailPrepared = prepare(tailSlice); + + const tailLineTexts: string[] = []; + let tailCursor = ORIGIN; + for (;;) { + const range = layoutNextLineRange(tailPrepared, tailCursor, geometry.lineWidth); + if (range === null) break; + + tailLineTexts.push(materialize(tailPrepared, range)); + tailCursor = range.end; + } + + const tail = tailLineTexts.slice(-Math.max(tailLines, 1)).join(''); + + return `${head}${MIDDLE_ELLIPSIS}\n${tail}`; +} + +/** + * Measurement-free fallback (SSR, jsdom, a not-yet-laid-out container): a + * character-budget middle ellipsis. Same shape, coarser cut — the rendered + * result is refined by `truncateToVisualLines` as soon as geometry exists. + */ +export function fallbackTruncate(text: string, maxLines: number, charsPerLine = 80): string { + const budget = Math.max(maxLines, 2) * charsPerLine; + if (text.length <= budget) return text; + + const headChars = Math.ceil(budget * 0.6); + const tailChars = budget - headChars; + return `${text.slice(0, headChars)}${MIDDLE_ELLIPSIS}\n${text.slice(-tailChars)}`; +} diff --git a/projects/angular-inline-select/src/public-api.ts b/projects/angular-inline-select/src/public-api.ts index 92813c1..d5312f9 100644 --- a/projects/angular-inline-select/src/public-api.ts +++ b/projects/angular-inline-select/src/public-api.ts @@ -11,5 +11,6 @@ export * from './lib/bubble-menu/bubble-menu'; export * from './lib/bubble-menu/editable-clear'; // Shared utilities — control-agnostic building blocks any control may use. export * from './lib/utils/editable-dialog/editable-dialog'; +export * from './lib/utils/middle-ellipsis/middle-ellipsis'; export * from './lib/angular-inline-text/caret'; export * from './lib/angular-inline-number/angular-inline-number'; From 99780f5052e5e8b6e8bf31c83e4883853031e6e7 Mon Sep 17 00:00:00 2001 From: Hong Date: Fri, 17 Jul 2026 23:07:29 +0200 Subject: [PATCH 43/48] example(Json): added more --- .../json-playground/json-playground.html | 71 +++++++++++++++++++ .../json-playground/json-playground.scss | 31 ++++++++ .../pages/json-playground/json-playground.ts | 38 +++++++++- 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/projects/app/src/app/pages/json-playground/json-playground.html b/projects/app/src/app/pages/json-playground/json-playground.html index 7fa966c..3535e0b 100644 --- a/projects/app/src/app/pages/json-playground/json-playground.html +++ b/projects/app/src/app/pages/json-playground/json-playground.html @@ -80,6 +80,77 @@

Signal form + required validation

}
+ +
+

In a Material table — one JSON config per row

+

+ Each service's config is an editable JSON string in a fixed-width column. The preview + middle-ellipses to fit the cell; click any one to edit it in the centered dialog. Editing never reflows + the table — commit is the only reflow. +

+ +
+ + + + + + + + + + + + + + + + + + +
#{{ row.position }}Service{{ row.name }}Config + +
+
+
+ +
+

In a plain HTML table — value vs. stored string

+

+ A bare <table>: each feature flag's rules are editable on the left, with the exact + committed string — what lands in the database — shown on the right. Save one and watch the stored value + update to strict, compact JSON. +

+ + + + + + + + + + + @for (row of featureFlagRows; track row.flag) { + + + + + + } + +
FlagRules (editable)Stored string
{{ row.flag }} + + {{ row.rules }}
+
diff --git a/projects/app/src/app/pages/json-playground/json-playground.scss b/projects/app/src/app/pages/json-playground/json-playground.scss index e1686d8..a96237a 100644 --- a/projects/app/src/app/pages/json-playground/json-playground.scss +++ b/projects/app/src/app/pages/json-playground/json-playground.scss @@ -1 +1,32 @@ @use '../demo'; +@use '../temporal-playground/model-table'; // shared plain-table styling (.model-table) + +// --- Material table example: fixed columns so editing never reshapes them --- +.table-scroll { + overflow: auto; + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 0.75rem; + background: var(--mat-sys-surface-bright); +} + +.demo-table { + width: 100%; + + // Fixed layout: column widths never re-derive from content, so a JSON + // preview growing/shrinking in a cell can't push the columns around. + table-layout: fixed; + + .mat-column-position { + width: 3rem; + } + + .mat-column-name { + width: 8rem; + } + + // Room for the field's focus ring inside cells + td { + padding-top: 4px; + padding-bottom: 4px; + } +} diff --git a/projects/app/src/app/pages/json-playground/json-playground.ts b/projects/app/src/app/pages/json-playground/json-playground.ts index f2a1234..3d8a8a6 100644 --- a/projects/app/src/app/pages/json-playground/json-playground.ts +++ b/projects/app/src/app/pages/json-playground/json-playground.ts @@ -10,11 +10,23 @@ import { FormField, form, required, readonly, disabled } from '@angular/forms/si // Material import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; // Components import { AngularInlineJson } from '../../../../../angular-inline-select/json/src/angular-inline-json'; import { EditableErrorTemplate } from '../../../../../angular-inline-select/src/lib/angular-inline-text/editable-error'; +interface ServiceRow { + position: number; + name: string; + config: string; +} + +interface FeatureFlagRow { + flag: string; + rules: string; +} + function buildLargeConfig(): Record { const config: Record = {}; for (let i = 0; i < 5000; i++) config[`setting${i}`] = i % 2 === 0; @@ -29,6 +41,7 @@ function buildLargeConfig(): Record { imports: [ // Material MatButtonModule, + MatTableModule, // Forms FormField, @@ -45,7 +58,7 @@ export class JsonPlayground { protected profile = signal('{"role":"admin","active":true}'); // --------------------------------------------------------------------------- - // Truncated preview: a 40-key object never renders more than 5 lines + // Truncated preview: a 5,000-key object never renders more than 5 lines // --------------------------------------------------------------------------- protected largeConfig = signal(JSON.stringify(buildLargeConfig())); @@ -68,6 +81,29 @@ export class JsonPlayground { this.metadataForm.metadata().errors().some((error) => error.kind === 'required'), ); + // --------------------------------------------------------------------------- + // Material table example: one editable JSON config per service row + // --------------------------------------------------------------------------- + protected serviceColumns = ['position', 'name', 'config']; + + protected serviceRows: ServiceRow[] = [ + { position: 1, name: 'auth', config: '{"provider":"oauth2","scopes":["read","write"],"ttl":3600}' }, + { position: 2, name: 'cache', config: '{"driver":"redis","host":"10.0.0.5","port":6379,"ttl":300}' }, + { position: 3, name: 'mailer', config: '{"transport":"smtp","host":"smtp.example.com","secure":true}' }, + { position: 4, name: 'search', config: '{"engine":"elastic","shards":5,"replicas":1,"analyzer":"standard"}' }, + { position: 5, name: 'billing', config: '{"currency":"EUR","proration":true,"retries":[60,300,3600]}' }, + { position: 6, name: 'flags', config: '{}' }, + ]; + + // --------------------------------------------------------------------------- + // Plain HTML table example: display value | the raw string behind it + // --------------------------------------------------------------------------- + protected featureFlagRows: FeatureFlagRow[] = [ + { flag: 'new-editor', rules: '{"enabled":true,"rollout":0.25,"cohorts":["beta"]}' }, + { flag: 'dark-mode', rules: '{"enabled":true}' }, + { flag: 'export-csv', rules: '{"enabled":false,"reason":"pending-review"}' }, + ]; + // Event console: raw JSON-text payloads, newest first. protected emittedEvents = signal([]); From b9f3c2145b77694104a0f2481f9d16682c0fda20 Mon Sep 17 00:00:00 2001 From: Hong Date: Fri, 17 Jul 2026 23:50:10 +0200 Subject: [PATCH 44/48] feat(BenchMark): added guess the editable --- projects/app/src/app/app.html | 102 +++++++++--------- projects/app/src/app/app.routes.ts | 8 ++ projects/app/src/app/app.ts | 25 ++++- .../guess-the-editable.html | 49 +++++++++ .../guess-the-editable.scss | 81 ++++++++++++++ .../guess-the-editable/guess-the-editable.ts | 88 +++++++++++++++ 6 files changed, 303 insertions(+), 50 deletions(-) create mode 100644 projects/app/src/app/pages/guess-the-editable/guess-the-editable.html create mode 100644 projects/app/src/app/pages/guess-the-editable/guess-the-editable.scss create mode 100644 projects/app/src/app/pages/guess-the-editable/guess-the-editable.ts diff --git a/projects/app/src/app/app.html b/projects/app/src/app/app.html index 7dcf003..c41d4ec 100644 --- a/projects/app/src/app/app.html +++ b/projects/app/src/app/app.html @@ -33,18 +33,21 @@ [opened]="sidenavOpened()" (openedChange)="sidenavOpened.set($event)" > - - @for (page of pages; track page.path) { - {{ page.label }} + + @for (sect of navSections; track sect.heading) { +

{{ sect.heading }}

+ @for (item of sect.items; track item.link) { + {{ item.label }} + } }
@@ -53,44 +56,47 @@ - + Playground + API + Theming + + } diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts index 95e8844..397a555 100644 --- a/projects/app/src/app/app.routes.ts +++ b/projects/app/src/app/app.routes.ts @@ -76,5 +76,13 @@ export const routes: Routes = [ ...docChildren('json'), ], }, + + // Benchmark section — not a documented component, so no api/theming children. + { + path: 'benchmark/guess', + loadComponent: () => + import('./pages/guess-the-editable/guess-the-editable').then((m) => m.GuessTheEditable), + }, + { path: '', pathMatch: 'full', redirectTo: 'text' }, ]; diff --git a/projects/app/src/app/app.ts b/projects/app/src/app/app.ts index 114d22a..842d32e 100644 --- a/projects/app/src/app/app.ts +++ b/projects/app/src/app/app.ts @@ -72,11 +72,23 @@ export class App { protected readonly title = signal('Inline Text Playground'); // --------------------------------------------------------------------------- - // Sidenav: playground pages + // Sidenav: grouped sections // --------------------------------------------------------------------------- - /** The sidenav items — sourced from the docs registry, one entry per playground. */ + /** The documented component pages — the source of truth for the contextual tabs. */ protected readonly pages = PAGES; + /** Grouped sidenav: the component playgrounds, then the benchmark tools. */ + protected readonly navSections = [ + { + heading: 'Components', + items: PAGES.map((page) => ({ link: `/${page.path}`, label: page.label })), + }, + { + heading: 'Benchmark', + items: [{ link: '/benchmark/guess', label: 'Guess The Editable' }], + }, + ]; + /** Narrow viewport (<1024px): the sidenav overlays instead of pushing. */ #isNarrow = toSignal( inject(BreakpointObserver) @@ -116,6 +128,15 @@ export class App { return segment ?? 'text'; }); + /** + * The Playground / API / Theming tabs only apply to a DOCUMENTED component + * section — benchmark pages (and any future non-component route) have no + * such views, so the strip hides entirely there. + */ + protected showSectionTabs = computed(() => + this.pages.some((page) => page.path === this.section()), + ); + // --------------------------------------------------------------------------- // Login // --------------------------------------------------------------------------- diff --git a/projects/app/src/app/pages/guess-the-editable/guess-the-editable.html b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.html new file mode 100644 index 0000000..80dfbcc --- /dev/null +++ b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.html @@ -0,0 +1,49 @@ +
+ Every editable below is hiding in the prose. Hold Ctrl + @if (revealed()) { + — revealed. + } @else { + to reveal them all. + } +
+ +
+

+ +

+ + + +

+ We started this project with a team of + people and one stubborn belief: an + editable value should look exactly like the text around it until the moment you decide to change it. No boxes, + no chrome, no little pencil icons — just words you can rewrite. +

+ +

+ The first prototype shipped that spring and handled plain text, nothing else. Within + of focused + work we had numbers, then dates, then a field you could reach the author at: + . + Every new type had to pass the same test — drop it into a paragraph and dare a stranger to find it. +

+ +

+ That test is this very page. Scattered through these paragraphs are + live fields of six different kinds; + the settings behind the post are stored as + . We almost charged + € a month for the + privilege; we never did. Can you tell which words are alive before you press the key? +

+ +

+ +

+
diff --git a/projects/app/src/app/pages/guess-the-editable/guess-the-editable.scss b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.scss new file mode 100644 index 0000000..f17099a --- /dev/null +++ b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.scss @@ -0,0 +1,81 @@ +:host { + display: block; + min-height: 100%; + padding: 24px; + box-sizing: border-box; + + // THE benchmark premise: every editable hides its resting affordance and + // must pass as ordinary prose. Two tokens cover every family — the line + // token (text / number / phone / json) and the color token (the temporal + // controls' dashed border, which has no line-toggle token of its own). + --editable-text-underline: none; + --editable-text-underline-color: transparent; +} + +// Ctrl held: flip both tokens back on so every hidden field lights up and +// you can check your guesses. +:host(.guess--reveal) { + --editable-text-underline: underline; + --editable-text-underline-color: var(--mat-sys-primary); +} + +.guess__hint { + position: sticky; + top: 0; + z-index: 1; + + margin: -24px -24px 24px; + padding: 12px 24px; + + background: var(--mat-sys-surface-container); + border-bottom: 1px solid var(--mat-sys-outline-variant); + color: var(--mat-sys-on-surface-variant); + font: var(--mat-sys-body-medium); + + kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.85em; + padding: 0.1em 0.45em; + border-radius: 0.3em; + border: 1px solid var(--mat-sys-outline); + background: var(--mat-sys-surface-bright); + color: var(--mat-sys-on-surface); + } + + strong { + color: var(--mat-sys-primary); + } +} + +.post { + max-width: 68ch; + margin: 0 auto; + + font: var(--mat-sys-body-large); + line-height: 1.7; + color: var(--mat-sys-on-surface); + + p { + margin: 0 0 1.25em; + } +} + +.post__title { + font: var(--mat-sys-headline-large); + margin: 0 0 0.25em; +} + +.post__byline { + color: var(--mat-sys-on-surface-variant); + font: var(--mat-sys-body-medium); + margin-bottom: 2em; +} + +// The multi-line reader note reads as a closing line of the post. +.post__note { + margin-top: 2em; + padding-top: 1.25em; + border-top: 1px solid var(--mat-sys-outline-variant); + font-style: italic; + color: var(--mat-sys-on-surface-variant); +} diff --git a/projects/app/src/app/pages/guess-the-editable/guess-the-editable.ts b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.ts new file mode 100644 index 0000000..ead7a8a --- /dev/null +++ b/projects/app/src/app/pages/guess-the-editable/guess-the-editable.ts @@ -0,0 +1,88 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, +} from '@angular/core'; + +// Every editable family, so we can scatter one of each through the prose. +import { AngularInlineText, AngularInlineNumber } from 'angular-inline-select'; +import { AngularInlinePhone, createLibphonenumberCodec } from 'angular-inline-select/phone'; +import { + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + composeDbEntry, + type InlineDateValue, + type InlineTimeValue, +} from 'angular-inline-select/temporal'; +import { AngularInlineJson } from 'angular-inline-select/json'; +import metadata from 'libphonenumber-js/metadata.min.json'; +import examples from 'libphonenumber-js/examples.mobile.json'; + +const phoneCodec = createLibphonenumberCodec(metadata, examples); + +/** + * "Guess the Editable" — a benchmark, not a demo. + * + * The host sets `--editable-text-underline: none` (and the temporal border + * variant), so every scattered control hides its resting affordance and must + * pass as plain prose. Holding Ctrl flips the tokens back on — the reveal — + * so any control that ALREADY stood out (a stray baseline, a prefix chrome, + * a boxed preview) is the one that "fails to look inline enough". + */ +@Component({ + selector: 'app-guess-the-editable', + templateUrl: './guess-the-editable.html', + styleUrl: './guess-the-editable.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + AngularInlineText, + AngularInlineNumber, + AngularInlinePhone, + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + AngularInlineJson, + ], + host: { + class: 'guess', + '[class.guess--reveal]': 'revealed()', + // Ctrl is a HOLD, not a toggle: track its live state on every key event + // (`ctrlKey` is true on Ctrl-down and any key held with it, false on its + // release), and reset on blur so a Ctrl+Tab away never leaves it stuck on. + '(document:keydown)': 'trackCtrl($event)', + '(document:keyup)': 'trackCtrl($event)', + '(window:blur)': 'revealed.set(false)', + }, +}) +export class GuessTheEditable { + protected revealed = signal(false); + + protected trackCtrl(event: KeyboardEvent) { + // metaKey too, so ⌘ works for Mac muscle memory. + this.revealed.set(event.ctrlKey || event.metaKey); + } + + protected codec = phoneCodec; + + // The scattered values — one of every family, seeded so each shows real + // content (an empty field's italic placeholder would be a giveaway). + protected title = signal('The Quiet Craft of Inline Editing'); + protected author = signal('Hong Knop'); + protected publishDate = signal('2019-04-15'); + protected publishTime = signal(composeDbEntry('2019-04-15', '09:30')); + protected teamSize = signal(4); + protected workDuration = signal(8 * 3600); // "8:00" at h:mm + protected authorPhone = signal('+493012345678'); + protected readCount = signal(4200); + protected fieldCount = signal(12); + protected settings = signal('{"theme":"dark","density":0}'); + protected note = signal('Leave a note here if you spot them all.'); + + // Price is a number with a currency FORMAT — two decimals — so it reads as + // money. The € stays inline prose so nothing but the digits is the field. + protected price = signal(9); + protected priceFormat = (value: number | null): string => (value === null ? '' : value.toFixed(2)); +} From 1ad60dcc6a489501a812747d52d691c318b5e36b Mon Sep 17 00:00:00 2001 From: Hong Date: Mon, 20 Jul 2026 22:28:29 +0200 Subject: [PATCH 45/48] fix(InlineJson): make inline indent more consistent --- .../json/src/angular-inline-json.scss | 3 - .../json/src/angular-inline-json.ts | 8 +- .../json/src/json-editor.ts | 75 ++++++++++++++++++- .../json/src/json-session.ts | 12 +++ .../src/lib/styles/_editable-json.scss | 36 +++++++-- .../angular-inline-date.html | 14 +++- .../angular-inline-date.ts | 10 --- .../angular-inline-date/calendar/calendar.ts | 2 + 8 files changed, 129 insertions(+), 31 deletions(-) diff --git a/projects/angular-inline-select/json/src/angular-inline-json.scss b/projects/angular-inline-select/json/src/angular-inline-json.scss index b59db76..e69de29 100644 --- a/projects/angular-inline-select/json/src/angular-inline-json.scss +++ b/projects/angular-inline-select/json/src/angular-inline-json.scss @@ -1,3 +0,0 @@ -// The preview block, the elevated editor, and the panel action buttons share -// the global `.editable-json*` / `.editable-action*` chrome in -// styles/_editable-json.scss and styles/_editable.scss. diff --git a/projects/angular-inline-select/json/src/angular-inline-json.ts b/projects/angular-inline-select/json/src/angular-inline-json.ts index 6c7ef98..c1478a5 100644 --- a/projects/angular-inline-select/json/src/angular-inline-json.ts +++ b/projects/angular-inline-select/json/src/angular-inline-json.ts @@ -69,12 +69,7 @@ export interface InlineJsonSaved { */ @Component({ selector: 'angular-inline-json', - imports: [ - NgTemplateOutlet, - - BubbleMenu, - EditableClearButton, - ], + imports: [NgTemplateOutlet, BubbleMenu, EditableClearButton], templateUrl: './angular-inline-json.html', styleUrl: './angular-inline-json.scss', host: { @@ -481,6 +476,7 @@ export class AngularInlineJson implements FormValueControl { ariaLabel: this.ariaLabel() ?? 'Edit JSON', data, }); + this.#dialogRef = ref; // The settlement safety net: runs exactly once per session for EVERY diff --git a/projects/angular-inline-select/json/src/json-editor.ts b/projects/angular-inline-select/json/src/json-editor.ts index 71c4a75..854a4d6 100644 --- a/projects/angular-inline-select/json/src/json-editor.ts +++ b/projects/angular-inline-select/json/src/json-editor.ts @@ -1,5 +1,13 @@ -import { EditorState, type Extension } from '@codemirror/state'; -import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { EditorState, RangeSetBuilder, type Extension } from '@codemirror/state'; +import { + Decoration, + EditorView, + ViewPlugin, + keymap, + lineNumbers, + type DecorationSet, + type ViewUpdate, +} from '@codemirror/view'; import { HighlightStyle, StreamLanguage, @@ -121,6 +129,68 @@ const jsonLinter = linter((view) => { return [diagnostic]; }); +// ----------------------------------------------------------------------------- +// Indent guides — faint vertical rules under each indentation level, so nesting +// reads at a glance. Our editing form is space-indented two per level +// (`printEditableJson`), and the dialect is space-only, so counting leading +// spaces is exact. Each indented line carries a `--cm-indent-levels` custom +// property; the CSS (_editable-json.scss) draws that many evenly-spaced rules +// with a clipped repeating gradient. One decoration object per level is cached. +// ----------------------------------------------------------------------------- + +const INDENT_COLUMNS = 2; + +const indentGuideDecorations = new Map(); + +function indentGuideDecoration(levels: number): Decoration { + let deco = indentGuideDecorations.get(levels); + if (deco === undefined) { + deco = Decoration.line({ + attributes: { class: 'cm-indentGuides', style: `--cm-indent-levels:${levels}` }, + }); + indentGuideDecorations.set(levels, deco); + } + return deco; +} + +function buildIndentGuides(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + + for (const { from, to } of view.visibleRanges) { + for (let pos = from; pos <= to; ) { + const line = view.state.doc.lineAt(pos); + const text = line.text; + + let spaces = 0; + while (spaces < text.length && text.charCodeAt(spaces) === 32) spaces++; + + const levels = Math.floor(spaces / INDENT_COLUMNS); + if (levels > 0) builder.add(line.from, line.from, indentGuideDecoration(levels)); + + pos = line.to + 1; + } + } + + return builder.finish(); +} + +const indentGuides = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildIndentGuides(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildIndentGuides(update.view); + } + } + }, + { decorations: (plugin) => plugin.decorations }, +); + export interface JsonEditorCallbacks { onChange: (text: string) => void; } @@ -149,6 +219,7 @@ export function createJsonEditorState( const extensions: Extension[] = [ jsonLanguage, lineNumbers(), + indentGuides, // An (empty) theme whose only job is declaring the scheme — flips every // `&dark` rule in CM's base theme. EditorView.theme({}, { dark: options.dark ?? false }), diff --git a/projects/angular-inline-select/json/src/json-session.ts b/projects/angular-inline-select/json/src/json-session.ts index cc162f8..08f6eac 100644 --- a/projects/angular-inline-select/json/src/json-session.ts +++ b/projects/angular-inline-select/json/src/json-session.ts @@ -88,6 +88,18 @@ export class JsonSession { this.#view = new EditorView({ state, parent: container }); this.#view.focus(); + + // COLD-OPEN REMEASURE. The editor mounts the very first time inside a + // dialog whose layout has not settled yet; CM takes its one line-height + // measurement there and lands on a stale default (14px), caching it in the + // height map. Every gutter line number is then sized to 14px while the + // wrapped lines render at ~18px, so the numbers drift a little lower each + // row until "1" sits a full line below the `{` and a phantom last number + // hangs past the end. Nothing resizes the scroller afterwards (the dialog + // enters via transform, invisible to CM's ResizeObserver), so CM never + // re-measures on its own. Force one re-measure now that the row is laid + // out — it re-reads the real line height and re-aligns the gutter. + this.#view.requestMeasure(); }); #destroyView = inject(DestroyRef).onDestroy(() => this.#view?.destroy()); diff --git a/projects/angular-inline-select/src/lib/styles/_editable-json.scss b/projects/angular-inline-select/src/lib/styles/_editable-json.scss index 3b147b4..67c9530 100644 --- a/projects/angular-inline-select/src/lib/styles/_editable-json.scss +++ b/projects/angular-inline-select/src/lib/styles/_editable-json.scss @@ -103,7 +103,8 @@ // COMPONENT-owned spacing (never the generic dialog's): the action row keeps // clear of every edge — the shadcn/MatDialog lesson learned the hard way. .editable-json__session .editable-panel__footer { - padding: var(--mat-sys-inner-spacing, 16px); + padding-inline: var(--mat-sys-inner-spacing, 16px); + padding-block-end: var(--mat-sys-inner-spacing, 16px); } .editable-json__editor { @@ -151,16 +152,37 @@ color: var(--editable-json-gutter-color, light-dark(#8c959f, #6e7681)); } - // Numbers ride the same block padding as the code body they label. - .cm-gutter { - padding-block: var(--mat-sys-inner-spacing, 16px); - } - + // NB: the gutter gets NO manual block padding. CM mirrors `.cm-content`'s + // top padding onto the first gutter element (a `margin-top`) once it has + // measured correctly, so every line number aligns with its line on its own. + // Adding padding here double-counts that offset and drops each number a full + // line below its code — the very misalignment this used to cause. .cm-lineNumbers .cm-gutterElement { - padding-inline: var(--mat-sys-inner-spacing, 16px) calc(var(--mat-sys-inner-spacing, 16px) * 0.25); + padding-inline: var(--mat-sys-inner-spacing, 16px) + calc(var(--mat-sys-inner-spacing, 16px) * 0.25); min-width: 3ch; } + // Indent guides: one faint vertical rule per nesting level, drawn as a + // repeating gradient clipped to exactly `--cm-indent-levels` columns (set per + // line by the indentGuides plugin). `content-box` origin skips CM's 6px line + // padding so the first rule sits under text column 0; `1ch * 2` is one + // two-space indent in the monospace font, so every rule lands on an indent + // stop. Purely decorative — it rides behind the caret and selection. + .cm-line.cm-indentGuides { + background-image: repeating-linear-gradient( + to right, + var(--editable-json-indent-guide, light-dark(#d8dee4, #30363d)) 0, + var(--editable-json-indent-guide, light-dark(#d8dee4, #30363d)) 1px, + transparent 1px, + transparent calc(1ch * 2) + ); + background-origin: content-box; + background-repeat: no-repeat; + background-position: 0 0; + background-size: calc(var(--cm-indent-levels) * 1ch * 2) 100%; + } + // CodeMirror uses the NATIVE caret (no drawSelection extension). The dark // base theme (json-editor.ts passes the scheme at mount) already keeps it // visible; this pins it to the family caret token on top. diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index cdde2f6..1cc6af5 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -70,7 +70,11 @@ - + @@ -100,7 +104,11 @@ @if (!twoFields()) { - + } @@ -115,7 +123,7 @@ [cdkConnectedOverlayOrigin]="overlayOrigin() ?? origin" [cdkConnectedOverlayOpen]="overlayOpen()" [cdkConnectedOverlayPositions]="overlayPositions" - (overlayOutsideClick)="handleOutsideClick()" + (overlayOutsideClick)="this.overlayOpen.set(false)" >
{ if (target.closest('input, button') === null) event.preventDefault(); } - /** - * An outside click DISMISSES the panel — and only that. Settling belongs - * to the focusout path: when the click also moves focus away, the blur - * settle runs anyway; when it does NOT (a hosting container's prevented - * chrome click), the session must survive the dismissal. - */ - protected handleOutsideClick() { - this.overlayOpen.set(false); - } - #inputOf(key: SideKey): HTMLInputElement | undefined { return (key === 'start' ? this.startInput() : this.endInput())?.nativeElement; } diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts index c8370e8..6449397 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts @@ -6,6 +6,8 @@ import { afterNextRender, computed, inject, + + // Signals input, linkedSignal, output, From 9e0d00b8e503b0a12fbd681c8f697212139a4026 Mon Sep 17 00:00:00 2001 From: Hong Date: Mon, 20 Jul 2026 22:50:24 +0200 Subject: [PATCH 46/48] feat(Calendar): added accesability provider to be conform with WAI ARIA --- .../angular-inline-date.html | 18 +++-- .../angular-inline-date.ts | 7 +- .../calendar/calendar.html | 12 ++-- .../angular-inline-date/calendar/calendar.ts | 25 +++++-- .../angular-inline-duration.ts | 7 +- .../angular-inline-time.ts | 7 +- .../temporal/src/public-api.ts | 1 + .../temporal/src/side-session.ts | 10 ++- .../temporal/src/temporal-intl.ts | 68 +++++++++++++++++++ 9 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 projects/angular-inline-select/temporal/src/temporal-intl.ts diff --git a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html index 1cc6af5..01c5023 100644 --- a/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -72,12 +72,16 @@ - + } @@ -89,7 +93,7 @@ + } @@ -153,7 +161,7 @@ } @if (quickPickList().length > 0) { -
+
@for (command of quickPickList(); track command.id) { -
{{ monthLabel() }}
+
{{ monthLabel() }}
+
+
@if (data.suffixTemplate(); as suffix) {
-
+ +
-

Inline text in a table

+

Single Line in Table

- 100 rows of mixed lengths, every name and note editable in place — long names ellipsize in - their fixed column, long notes wrap. The table scrolls inside the viewport. + isSingleLine cells: one logical line — Enter saves, pasted line breaks + collapse to spaces — regardless of the paint. noWrap ellipsizes at the column + edge; wrap grows the row instead, breaking at whitespace, or inside the second + column's long words (hyphenated where the browser can).

+ + noWrap + wrap +
- +
- - + + - - + + + + + + +
# {{ row.position }} NameLong title NotesWith an unbreakable word - + +
+
+
+ +
+
+

Text Area in Table

+

+ Multi-line cells always wrap — wrapBehavior is single-line-only and has no + effect here. Authored breaks (including blank lines) are user content and always survive: + each break the user typed stays, and the long lines fold at the column edge. Enter adds a + line; the cell grows with its content. +

+
+ +
+ + + + + + + + + + + + + + - - + +
#{{ row.position }}Authored lines + + With an unbreakable word +
diff --git a/projects/app/src/app/pages/text-playground/text-playground.scss b/projects/app/src/app/pages/text-playground/text-playground.scss index d0c5f8d..378822c 100644 --- a/projects/app/src/app/pages/text-playground/text-playground.scss +++ b/projects/app/src/app/pages/text-playground/text-playground.scss @@ -42,55 +42,19 @@ main { display: flex; flex-direction: column; - - // Animate the manual push so the shift is gradual — ResizeObserver fires - // on every frame of the transition, not just once. - transition: margin-left 0.3s ease; - - // Continuous stress test: keeps the layout shifting while an editable is - // open, forcing constant re-measure + overlay reposition. - &.oscillate { - animation: push-left 2s ease-in-out infinite alternate; - } } -@keyframes push-left { - from { - margin-left: 0; - } - to { - margin-left: 320px; - } -} +/* --- Table examples: isSingleLine (the value) vs wrapBehavior (the paint) --- */ -/* --- Layout shift tester (bottom left) --- */ - -.shift-controls { - position: fixed; - left: 16px; - bottom: 16px; - z-index: 20; - - display: flex; - align-items: center; - gap: 8px; - - padding: 8px 12px; - border-radius: 999px; - background: var(--mat-sys-surface-container-high); - border: 1px solid var(--mat-sys-outline-variant); - - &__label { - font: var(--mat-sys-label-large); - color: var(--mat-sys-on-surface-variant); - padding-right: 4px; - } +// Content-sized sections: 4 rows never justify the fill-the-viewport default +// of `.example`, so the table sections opt out and end where the table ends. +.example--fit { + min-height: 0; } -/* --- Table example: 100 rows, scrolls inside the viewport --- */ - .table-scroll { overflow: auto; + max-height: 70vh; border: 1px solid var(--mat-sys-outline-variant); border-radius: 0.75rem; background: var(--mat-sys-surface-bright); @@ -100,21 +64,22 @@ main { width: 100%; // Fixed layout: column widths never re-derive from content, so typing in an - // editable can't push columns around. Name ellipsizes, notes wrap in place. + // editable can't push columns around — and every column stays narrower than + // the text in it, which is the whole point of these tables. table-layout: fixed; .mat-column-position { width: 4rem; } - .mat-column-name { - width: 28%; - } - // Room for the editable's focus ring inside cells td { padding-top: 4px; padding-bottom: 4px; + + // Wrapped cells are as tall as their content: keep the text at the top so + // a growing cell doesn't drag its neighbours' baselines around with it. + vertical-align: top; } } diff --git a/projects/app/src/app/pages/text-playground/text-playground.ts b/projects/app/src/app/pages/text-playground/text-playground.ts index b369515..0c631f2 100644 --- a/projects/app/src/app/pages/text-playground/text-playground.ts +++ b/projects/app/src/app/pages/text-playground/text-playground.ts @@ -10,15 +10,25 @@ import { FormField, form, required, pattern, disabled, readonly } from '@angular // Material import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatTableModule } from '@angular/material/table'; // Components -import { AngularInlineText } from '../../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; +import { + AngularInlineText, + type InlineTextWrapBehavior, +} from '../../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; export interface DemoRow { position: number; - name: string; - notes: string; + /** One logical line, always far too long for its column. */ + title: string; + /** One logical line whose overflow is a single unbreakable word. */ + compound: string; + /** Several authored lines, each too long for its column. */ + note: string; + /** Several authored lines with an unbreakable word in them. */ + report: string; } const INITIAL_PROJECT_NAME = 'Aurora'; @@ -27,31 +37,68 @@ const INITIAL_SUMMARY = 'Save with Ctrl+Enter or the Save button, discard with Escape — ' + 'the overlay only appears once you actually change something.'; -// Mixed lengths on purpose: short names sit naturally, long ones must -// ellipsize inside the fixed-width name column without pushing it. -const SAMPLE_NAMES = [ - 'Iris', - 'Aurora Borealis', - 'Halo', - 'Gossamer Drift Relay', - 'Ember', - 'Junction Point Observatory of the Western Rim', - 'Cascade', - 'Flux Capacitor Calibration and Maintenance Facility Northwest', - 'Drift', +// Every cell below is deliberately too long for its column: what these tables +// demonstrate is what happens AT the width constraint, so nothing may fit. + +/** One logical line — no line break anywhere, plenty of whitespace to break at. */ +const SAMPLE_TITLES = [ + 'Junction Point Observatory of the Western Rim, survey sector nine', + 'Flux Capacitor Calibration and Maintenance Facility, Northwest Approach', 'The Extraordinarily Long Research Vessel Designation That Never Fits Anywhere', + 'Gossamer Drift Relay, secondary handshake array and telemetry mast', + 'Aurora Borealis Deep Field Observation Platform, upper orbital ring', +]; + +/** One logical line whose overflow is a single word — nowhere to break politely. */ +const SAMPLE_COMPOUNDS = [ + 'Rekalibrierungsmaßnahmenverordnung filed against the starboard array', + 'Pending: interplanetaryhyperspectralimagingandtelemetrysubsystemoverhaul', + 'Betriebssicherheitsüberprüfungsbescheinigung issued for the whole ring', + 'Escalated as antidisestablishmentarianism_of_the_docking_clamp_committee', + 'Filed under Höchstgeschwindigkeitsbegrenzungsüberschreitung, third cycle', ]; -// Mixed lengths on purpose: empty shows the placeholder, short ones stay on -// one line, long ones must wrap to several lines inside the notes column. +/** + * Several authored lines — the line breaks are user content and must survive. + * Some samples include a BLANK line (paragraph break): an empty line is user + * content too, and both paints must keep it. + */ const SAMPLE_NOTES = [ - '', - 'Stable.', - 'Needs a follow-up during the next maintenance window.', - 'Recalibrated twice this cycle. The drift is within tolerance, but keep an eye on the secondary readings until the next full diagnostic.', - 'Long-form note to exercise wrapping: the array was realigned after the last storm season, power draw is nominal, and the relay handshake completes in under forty milliseconds. Crew rotation is scheduled for the third week, pending transport availability and weather on the pass.', + 'Recalibrated twice this cycle and the drift is still within tolerance.\n' + + 'Keep an eye on the secondary readings until the next full diagnostic.', + 'The array was realigned after the last storm season and power draw is nominal.\n' + + 'Relay handshake completes in under forty milliseconds, every attempt.\n' + + '\n' + + 'Crew rotation is scheduled for the third week, pending transport.', + 'Follow-up needed during the next maintenance window, whenever that lands.\n' + + '\n' + + 'Nothing here is urgent, but none of it should be forgotten either.', ]; +/** Several authored lines, at least one of which is an unbreakable word. */ +const SAMPLE_REPORTS = [ + 'Status: Verkehrsinfrastrukturfinanzierungsgesellschaft review outstanding.\n' + + 'Everything else on the checklist came back clean on the first pass.', + 'Flagged: supercalifragilisticexpialidocious_diagnostic_output_channel_seven\n' + + 'Downgraded to advisory after the second read, no action required today.\n' + + '\n' + + 'Next audit lands with the quarterly rotation.', + 'Awaiting Grundstücksverkehrsgenehmigungszuständigkeitsübertragungsverordnung.\n' + + 'The paperwork trails the work by about a week, as it always does.', +]; + +// Striding across pools of different sizes so the four columns of a row never +// line up into the same combination twice down the table. +function makeDemoRows(count: number): DemoRow[] { + return Array.from({ length: count }, (_, i) => ({ + position: i + 1, + title: SAMPLE_TITLES[i % SAMPLE_TITLES.length], + compound: SAMPLE_COMPOUNDS[(i + 2) % SAMPLE_COMPOUNDS.length], + note: SAMPLE_NOTES[i % SAMPLE_NOTES.length], + report: SAMPLE_REPORTS[(i + 1) % SAMPLE_REPORTS.length], + })); +} + @Component({ selector: 'app-text-playground', templateUrl: './text-playground.html', @@ -60,6 +107,7 @@ const SAMPLE_NOTES = [ imports: [ // Material MatButtonModule, + MatButtonToggleModule, MatTableModule, // Forms @@ -128,24 +176,36 @@ export class TextPlayground { } // --------------------------------------------------------------------------- - // Table example (100 rows) + // Table examples: isSingleLine (the VALUE) vs wrapBehavior (the PAINT) + // + // Two tables — one per isSingleLine — each with an exclusive wrapBehavior + // toggle, so every combination is two clicks away. Each column is narrower + // than its content, so every cell has to make the decision under test. // --------------------------------------------------------------------------- - protected displayedColumns = ['position', 'name', 'notes']; - // 10 × 5 pools with coprime-ish striding so name and note lengths combine - // in every variation across the 100 rows. - protected rows: DemoRow[] = Array.from({ length: 100 }, (_, i) => ({ - position: i + 1, - name: `${SAMPLE_NAMES[i % SAMPLE_NAMES.length]} ${i + 1}`, - notes: SAMPLE_NOTES[(i + Math.floor(i / 5)) % SAMPLE_NOTES.length], - })); + /** "Single Line in Table": one whitespace-heavy column, one unbreakable-word column. */ + protected singleLineColumns = ['position', 'title', 'compound']; + protected singleLineRows: DemoRow[] = makeDemoRows(4); + protected singleLineWrap = signal('noWrap'); + + /** + * "Text Area in Table": authored line breaks, with and without a long word. + * No paint controls here — multi-line always wraps (`wrapBehavior` is + * single-line-only). + */ + protected textAreaColumns = ['position', 'note', 'report']; + protected textAreaRows: DemoRow[] = makeDemoRows(4); // --------------------------------------------------------------------------- - // Layout shift tester + // Floating nav // --------------------------------------------------------------------------- - // Pushes the whole content area aside with a left margin to stress-test - // layout stability: the in-flow display text must move with the page while - // idle, and typing in the elevated editor must never shift the page. - protected pushMargin = signal(0); - protected oscillate = signal(false); + // Plain `href="#…"` resolves against `` and would navigate to + // "/#…", losing the /text route — so the nav scrolls programmatically. + // scrollIntoView also handles the real scroll container (the sidenav + // content, not the document); `scroll-margin-top` clears the sticky tabs. + protected scrollToExample(event: Event, id: string) { + event.preventDefault(); + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }