diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..93bd124 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "app", + "runtimeExecutable": "npm", + "runtimeArgs": ["start", "--", "--port", "4202"], + "port": 4202, + "autoPort": true + } + ] +} 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/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 new file mode 100644 index 0000000..a298b5b --- /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": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + } + ] +} 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..1a9db96 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,642 @@ +# Roadmap — angular-inline-text + +## North star + +**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`. + +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. + +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)`). + +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. + +**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 + +- **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. +- **Idle-gesture completeness.** Every common edit gesture on the idle + display elevates in ONE action: type, delete, paste, and now **cut** (a + `(cut)` handler writes the clipboard and elevates with the selection + removed — previously `deleteByCut` fell through `replayEdit` and elevated + unchanged, so cut took two gestures). Similarly, a `/` typed on the idle + display opens the slash menu on elevation (detection runs in + `handlePanelAttach`, not just on `input`). Known remaining fall-through: + word-delete (`deleteWordBackward`) still elevates unchanged — rare, no + clipboard stake; revisit if it bites. + +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~~ — 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 + +**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. + +## 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: 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 ` + 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..7755e86 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-preview.ts @@ -0,0 +1,34 @@ +import { + MIDDLE_ELLIPSIS, + fallbackTruncate, + truncateToVisualLines as truncateInlineFlow, + type InlineFlowGeometry, +} from 'angular-inline-select'; + +/** + * 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 = MIDDLE_ELLIPSIS; + +export type InlinePreviewGeometry = InlineFlowGeometry; + +/** Sizes the bounded measuring slices from a JSON-typical character mix. */ +const JSON_PROBE_TEXT = '{"abcdefgh": 12345, "x": true},'; + +/** Middle-ellipsis truncation measured in VISUAL lines — JSON flavor. */ +export function truncateToVisualLines( + text: string, + maxLines: number, + geometry: InlinePreviewGeometry, +): string { + return truncateInlineFlow(text, maxLines, geometry, { + breakAnywhere: true, // pairs with `line-break: anywhere` in _editable-json.scss + probeText: JSON_PROBE_TEXT, + }); +} + +export { fallbackTruncate }; 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/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..da5e86d --- /dev/null +++ b/projects/angular-inline-select/package.json @@ -0,0 +1,56 @@ +{ + "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", + "libphonenumber-js": "^1.13.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": { + "optional": true + }, + "luxon": { + "optional": true + }, + "@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": { + "tslib": "^2.3.0" + }, + "sideEffects": false +} 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..9009e09 --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.html @@ -0,0 +1,129 @@ + + + + + + +{{ 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 new file mode 100644 index 0000000..5da030c --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.spec.ts @@ -0,0 +1,269 @@ +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: { value: 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; + + // 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(); +} + +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([{ value: '+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([{ value: '+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('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([{ value: '+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(); + + // 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..0b2c00f --- /dev/null +++ b/projects/angular-inline-select/phone/src/angular-inline-phone.ts @@ -0,0 +1,528 @@ +import { + Component, + TemplateRef, + input, + model, + output, + computed, + signal, + 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 { + 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, OverlayModule, NgTemplateOutlet], + templateUrl: './angular-inline-phone.html', + 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', + }, +}) +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); + + /** 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); + 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(); + + /** + * THE consumer commit event — fires once per changed settlement with the + * MODEL: `{ value }`, always E.164 or `null` inside, never raw input. + */ + savedModelChange = output<{ value: string | null }>(); + + /** + * 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. */ + 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 }); + } + + // --------------------------------------------------------------------------- + // 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(); + const last = Math.max(0, options.length - 1); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.pickerActiveIndex.update((i) => Math.min(i + 1, last)); + 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({ value: 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); + } + + /** 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..957c2ab --- /dev/null +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.spec.ts @@ -0,0 +1,78 @@ +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', + nationalNumber: '1712345678', + 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..f9c212c --- /dev/null +++ b/projects/angular-inline-select/phone/src/libphonenumber-codec.ts @@ -0,0 +1,125 @@ +import { + parsePhoneNumberFromString, + validatePhoneNumberLength, + formatIncompletePhoneNumber, + getExampleNumber, + getCountries, + getCountryCallingCode, + 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), + nationalNumber: String(phone.nationalNumber), + 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(); + }, + + 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 new file mode 100644 index 0000000..66f5ec9 --- /dev/null +++ b/projects/angular-inline-select/phone/src/phone-codec.ts @@ -0,0 +1,82 @@ +/** + * 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; + /** + * 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; +} + +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; + + /** 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; +} + +/** + * 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 new file mode 100644 index 0000000..6c529ea --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.html @@ -0,0 +1,26 @@ + + + + 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..9200384 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.spec.ts @@ -0,0 +1,254 @@ +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: { value: 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('.5')).toBe(0.5); + expect(defaultParseNumber('-3')).toBe(-3); + expect(defaultParseNumber('')).toBeNull(); + expect(defaultParseNumber(' ')).toBeNull(); + expect(defaultParseNumber('12abc')).toBeUndefined(); + }); + + it('rejects non-decimal shapes Number() would otherwise accept', () => { + for (const bad of ['Infinity', '-Infinity', '1e3', '0x10', '0b101', '0o17', 'NaN', '1,000']) { + expect(defaultParseNumber(bad)).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([{ value: 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([{ value: 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..564ce7b --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-number/angular-inline-number.ts @@ -0,0 +1,212 @@ +import { + Component, + TemplateRef, + input, + model, + output, + computed, + 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; + + // Dot-decimal only. `Number()` alone would accept hex (`0x10`), binary, + // octal, scientific (`1e3`) and `Infinity` — all surprising in a plain + // number field — so gate on a strict decimal shape first. + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(trimmed)) return undefined; + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : undefined; +} + +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(); + + /** + * THE consumer commit event — fires once per changed settlement with the + * MODEL: `{ value }`, always `number | null` inside, never a string. + */ + savedModelChange = output<{ value: number | null }>(); + + /** + * 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. */ + 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; + }); + + /** + * Whether an edit session is open. Two-way bindable — also the bridge that + * freezes the string channel while a session runs. + */ + editing = model(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.editing() ? (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 new file mode 100644 index 0000000..7b9a90b --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.html @@ -0,0 +1,168 @@ + + + @if (prefixTpl(); as prefix) { + + } + + @if (suffixTpl(); as suffix) { + + } + + + + +
+
+ @if (prefixTpl(); as prefix) { + + } + + @if (suffixTpl(); as suffix) { + + } +
+ + + @if (menuOpen()) { +
+ +
+ } + + +
+
+ + + + + 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..aaabb51 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.scss @@ -0,0 +1,2 @@ +// 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.spec.ts b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts new file mode 100644 index 0000000..4631543 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts @@ -0,0 +1,534 @@ +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, type InlineTextSaved } from './angular-inline-text'; +import { EditableSuffix } from './editable-affix'; +import { detectSlashToken } from './editable-menu'; +import { replayEdit } from './caret'; + +// ============================================================================= +// Hosts — one per binding mode +// ============================================================================= + +@Component({ + imports: [AngularInlineText], + template: ` + + `, +}) +class ValueBindingHost { + value = signal('initial'); + errors = signal([]); + touched = signal(false); + disabled = signal(false); + + saved: { value: 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('detectSlashToken', () => { + it('detects a slash token at the start of the draft', () => { + expect(detectSlashToken('/ger', 4)).toEqual({ start: 0, end: 4, query: 'ger' }); + }); + + it('detects a slash token after whitespace', () => { + expect(detectSlashToken('call /de', 8)).toEqual({ start: 5, end: 8, query: 'de' }); + }); + + it('ignores a mid-word slash (either/or, URLs)', () => { + expect(detectSlashToken('either/or', 9)).toBeNull(); + expect(detectSlashToken('http://x', 8)).toBeNull(); + }); + + it('closes once whitespace follows the slash', () => { + expect(detectSlashToken('/de now', 7)).toBeNull(); + }); + + it('reads the query only up to the caret', () => { + expect(detectSlashToken('/german', 4)).toEqual({ start: 0, end: 4, query: 'ger' }); + }); + + it('a bare slash is an open token with an empty query', () => { + expect(detectSlashToken('/', 1)).toEqual({ start: 0, end: 1, query: '' }); + }); +}); + +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', () => { + 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('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([{ value: '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([{ value: '' }]); + 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('cut on the idle display elevates with the selection removed and writes the clipboard', () => { + const display = h.display(); + + // Select the whole committed value on the pristine display + const selection = document.getSelection(); + const range = document.createRange(); + range.selectNodeContents(display); + selection?.removeAllRanges(); + selection?.addRange(range); + + let clipped: string | null = null; + const event = new Event('cut', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { setData: (_type: string, value: string) => (clipped = value) }, + }); + display.dispatchEvent(event); + h.fixture.detectChanges(); + + // One gesture: clipboard has the text, the field is elevated and emptied + expect(clipped).toBe('initial'); + expect(h.editable().editing()).toBe(true); + expect(h.host.value()).toBe(''); + }); + + 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 new file mode 100644 index 0000000..b4af0dd --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -0,0 +1,1043 @@ +import { + Component, + DestroyRef, + ElementRef, + TemplateRef, + inject, + + // Signals + computed, + output, + model, + viewChild, + contentChild, + input, + effect, + afterNextRender, + afterRenderEffect, + signal, + untracked, + linkedSignal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; + +// CDK +import { CdkConnectedOverlayConfig, ConnectedPosition, OverlayModule } from '@angular/cdk/overlay'; +import { A11yModule, _IdGenerator } from '@angular/cdk/a11y'; + +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; + 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 { + 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, + }, + ]; +} + +/** + * 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, + A11yModule, + + BubbleMenu, + EditableClearButton, + ], + 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', + '(focus)': 'focus()', + }, +}) +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) — the bubble's anchor + measure box. */ + protected fieldArea = viewChild.required>('fieldArea'); + + /** The contenteditable inside the elevated panel. Exists only while editing. */ + protected editor = viewChild>('editor'); + + /** The slash-menu container in the panel. Exists only while the menu is open. */ + protected menuContainer = viewChild>('menuContainer'); + + // 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(''); + + /** Form Value Contract: disabled */ + disabled = input(false); + + /** Form Value Contract: readonly */ + readonly = input(false); + + /** 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); + + /** + * 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). + */ + touched = input(false); + + /** Form Value Contract: hidden */ + hidden = input(false); + + /** + * 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(); + + /** + * 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(); + + /** + * 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<{ value: string }>(); + + /** + * 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(); + + /** Whether the field is elevated (an edit session is open). Two-way bindable. */ + editing = model(false); + + isSingleLine = input(false); + placeholder = input('N/A'); + + /** 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); + + /** + * 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); + + /** + * Slash-command menu template — dormant unless provided. The consumer owns + * the options and the search (an `@for` filtered by the live query); the + * control owns the trigger, keyboard navigation, and the combobox ARIA. + * Same dual channel as the other slots: input for composition, + * `ng-template[editableMenu]` content for direct use. + */ + menuTemplate = input | undefined>(undefined); + + private contentMenu = contentChild(EditableMenu); + + protected menuTpl = computed(() => this.menuTemplate() ?? this.contentMenu()?.templateRef); + + /** The active `/query` token, or `null` when the menu is closed. */ + #menuToken = signal(null); + + /** Id of the active option (mirrored to the editor's `aria-activedescendant`). */ + protected menuActiveId = signal(undefined); + + protected menuOpen = computed(() => this.menuTpl() != null && this.#menuToken() != null); + protected menuQuery = computed(() => this.#menuToken()?.query ?? ''); + + /** + * The `editableMenu` template context: the live query, the active-option id + * signal (for declarative `data-active` binding), and the `apply` callback. + */ + protected menuContext = computed(() => ({ + $implicit: this.menuQuery(), + activeId: this.menuActiveId, + apply: this.applyMenu, + })); + + /** The projected option elements, in DOM order. */ + #menuOptionEls(): HTMLElement[] { + const container = this.menuContainer()?.nativeElement; + return container ? Array.from(container.querySelectorAll('[role="option"]')) : []; + } + + /** + * Keeps the active option valid as the consumer re-filters: when the menu + * opens or the query changes, land on the first option (or clear if empty). + */ + #menuActiveReset = afterRenderEffect(() => { + if (!this.#menuToken()) return; + + const options = this.#menuOptionEls(); + const activeId = untracked(() => this.menuActiveId()); + + if (options.length === 0) { + if (activeId !== undefined) this.menuActiveId.set(undefined); + } else if (activeId === undefined || !options.some((option) => option.id === activeId)) { + this.menuActiveId.set(options[0].id); + } + }); + + #menuMove(delta: number) { + const options = this.#menuOptionEls(); + if (options.length === 0) return; + + const index = options.findIndex((option) => option.id === this.menuActiveId()); + const next = + index < 0 + ? delta > 0 + ? 0 + : options.length - 1 + : (index + delta + options.length) % options.length; + + this.menuActiveId.set(options[next].id); + options[next].scrollIntoView({ block: 'nearest' }); + } + + #closeMenu() { + this.#menuToken.set(null); + this.menuActiveId.set(undefined); + } + + /** Re-detects the `/query` token from the editor DOM after every edit. */ + #detectMenu(el: HTMLElement) { + if (!this.menuTpl()) return; + + const selection = getSelectionOffsets(el); + const text = el.innerText ?? el.textContent ?? ''; + this.#menuToken.set(detectSlashToken(text, selection?.end ?? text.length)); + } + + /** + * Replaces the draft with a command's text and closes the menu. Replaces + * the whole draft by default (a command is usually the new beginning — + * a country becoming `'+49 '`), or just the `/query` token with + * `{ replaceToken: true }`. An arrow so the template context can hold it. + */ + protected applyMenu = (replacement: string, options?: { replaceToken?: boolean }) => { + const el = this.editor()?.nativeElement; + if (!el) return; + + const token = this.#menuToken(); + const text = el.innerText ?? el.textContent ?? ''; + + let next: string; + let caret: number; + if (options?.replaceToken && token) { + next = text.slice(0, token.start) + replacement + text.slice(token.end); + caret = token.start + replacement.length; + } else { + next = replacement; + caret = replacement.length; + } + + el.textContent = next; + setCaretOffset(el, caret); + this.#closeMenu(); + this.handleEditorInput(); + }; + + /** + * 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); + + /** + * 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) { + // A session opened by ANY path — `elevate()`, or an external + // `editing.set(true)` (e.g. the phone flag picker seeding a draft). + // `#saveAttempted` is per-session, so clear it here too, not only in + // `elevate()`, or a stale attempt flashes errors on the fresh draft. + untracked(() => this.#saveAttempted.set(false)); + } else 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.value() ?? ''; + const previous = this.previous(); + + if (!this.normalizeValue()) { + return { + value, + changed: value !== previous, + }; + } + + const normalized = normalizeString(value); + return { + value: normalized, + changed: normalized !== previous, + }; + }); + + /** + * 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; + + // Cut is owned by the `(cut)` handler — it needs `clipboardData`, which a + // `beforeinput` can't provide. Ignore the `deleteByCut` intent here so we + // don't elevate without removing the selection (that made cut take two + // gestures: elevate-unchanged, then cut again in the editor). + if ((event as InputEvent).inputType === 'deleteByCut') 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); + } + + /** + * Cut on the pristine display: write the selection to the clipboard and + * elevate with it removed — one gesture, like delete and paste. Without + * this the field would elevate unchanged and the cut would need repeating. + */ + protected interceptCut(event: ClipboardEvent) { + if (this.disabled() || this.readonly() || this.editing()) return; + event.preventDefault(); + + const committed = this.value() ?? ''; + const selection = getSelectionOffsets(this.display().nativeElement) ?? { + start: committed.length, + end: committed.length, + }; + + if (selection.start !== selection.end) { + event.clipboardData?.setData('text/plain', committed.slice(selection.start, selection.end)); + } + + const remaining = committed.slice(0, selection.start) + committed.slice(selection.end); + this.elevate(selection.start, remaining); + } + + /** 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; + + // Elevating on a `/` (typed from the idle display) should open the menu + // immediately — no `input` event fires for the seeded draft, so detect + // it here once the editor and caret are in place. + this.#detectMenu(editorEl); + }); + } + + // --------------------------------------------------------------------------- + // Commit / revert + // --------------------------------------------------------------------------- + + accepted = false; + /** The per-field submit (our one honest deviation from a normal form). */ + protected accept() { + const { value, changed } = this.normalization(); + + if (!changed) { + // Mark accepted so the detach safety net doesn't also run `revert()` — + // the outcome is the same either way, but this keeps the accept/detach + // ordering from mattering. + this.accepted = true; + this.close(); + this.saved.emit({ value, changed: false }); + 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; + + // 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.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; + + // 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); + + this.#detectMenu(el); + } + + /** Arrow-key navigation while the slash menu is open. */ + protected handleMenuNav(event: Event, delta: number) { + if (!this.menuOpen()) return; + + event.preventDefault(); + this.#menuMove(delta); + } + + /** + * Two-stage Escape: first press closes an open menu, the next cancels the + * session. (Bound at the panel level.) + */ + protected handleEscape(event: Event) { + if (this.menuOpen()) { + event.stopPropagation(); + this.#closeMenu(); + return; + } + + this.cancel(); + } + + /** Selects the active option if the menu is open. Returns whether it handled the key. */ + #menuSelectActive(event: Event): boolean { + if (!this.menuOpen()) return false; + + event.preventDefault(); + const activeId = this.menuActiveId(); + this.#menuOptionEls() + .find((option) => option.id === activeId) + ?.click(); + + return true; + } + + /** + * 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 { + 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.handleEditorInput(); + } + + /** Single-line fields accept on Enter — unless the menu claims it for selection. */ + protected handleEnterKey(event: Event) { + if (this.#menuSelectActive(event)) return; + if (!this.isSingleLine()) return; + + 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; + }); + }); + + // --------------------------------------------------------------------------- + // Clear affordance (the floating bubble lives in BubbleMenu) + // --------------------------------------------------------------------------- + + /** + * 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 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()); + + /** Bumped by the ResizeObserver to re-run the measure after the next render. */ + #measureTick = signal(0); + + #measureContentOffset() { + const el = this.fieldArea().nativeElement; + if (this.isEmpty()) { + this.#contentOffset.set(null); + return; + } + + // 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; + } + + // 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, + }); + } + + // 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)); + + #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 + * '' 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(); + + // Clear is an idle-only affordance (the bubble is hidden while editing). + // Guard anyway: committing '' mid-session would strand `previous` at the + // frozen baseline and desync the draft. + 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 the in-flow display element. */ + focus(options?: FocusOptions) { + this.display().nativeElement.focus(options); + } + + /** + * 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); + + if (!this.editing()) return; + + 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/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..39a47b2 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-error.ts @@ -0,0 +1,44 @@ +import { Directive, TemplateRef, inject } 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 {} + +/** + * 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/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/lib/angular-inline-text/editable-menu.ts b/projects/angular-inline-select/src/lib/angular-inline-text/editable-menu.ts new file mode 100644 index 0000000..97da41e --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/editable-menu.ts @@ -0,0 +1,84 @@ +import { Directive, TemplateRef, Signal, inject } from '@angular/core'; + +/** Template context for {@link EditableMenu}. */ +export interface EditableMenuContext { + /** The query: the text between the trigger `/` and the end of the draft. */ + $implicit: string; + /** + * Id of the active option, mirrored to the editor's `aria-activedescendant`. + * Bind `[attr.data-active]="option.id === activeId()"` for the keyboard + * highlight — the control drives navigation, the template reflects it. + */ + activeId: Signal; + /** + * Apply a command: replaces the whole draft by default (a command usually + * IS the new beginning — e.g. a country becoming `'+49 '`), or just the + * `/query` token with `{ replaceToken: true }`. Restores the caret and + * closes the menu. + */ + apply: (replacement: string, options?: { replaceToken?: boolean }) => void; +} + +/** + * Slash-command menu template — typed, keyboard-first, rendered INSIDE the + * elevated panel between the editor line and the footer. The control decides + * WHERE/WHEN (trigger detection, keyboard routing, two-stage Escape); the + * consumer decides WHAT: this template receives the live query and renders + * the options — typically an `@angular/aria` listbox it filters itself. + * + * Focus never leaves the editor: arrow keys are forwarded to the projected + * `[role="listbox"]`, whose `aria-activedescendant` is mirrored back onto + * the editor (combobox pattern). + * + * ```html + * + * + *
+ * @for (option of filter(query); track option.id) { + *
{{ option.label }}
+ * } + *
+ *
+ *
+ * ``` + * + * Dormant unless provided: fields without a menu template render nothing, + * listen to nothing, and keep plain-textbox ARIA. + */ +@Directive({ + selector: 'ng-template[editableMenu]', +}) +export class EditableMenu { + readonly templateRef = inject>(TemplateRef); +} + +/** A detected slash-command token: the `/` and the query up to the caret. */ +export interface SlashToken { + /** Index of the `/` in the draft. */ + start: number; + /** Caret index (exclusive end of the query). */ + end: number; + /** The query text between `/` and the caret. */ + query: string; +} + +/** + * Finds the active `/query` token ending at `caret`: a `/` at the start of the + * draft or after whitespace, with no whitespace between it and the caret. + * Returns `null` when the caret is not inside such a token — keeping the + * trigger from firing on mid-word slashes like `either/or` or URLs. + */ +export function detectSlashToken(text: string, caret: number): SlashToken | null { + for (let i = caret - 1; i >= 0; i--) { + const char = text[i]; + if (char === '/') { + const before = i === 0 ? '' : text[i - 1]; + if (before === '' || /\s/.test(before)) { + return { start: i, end: caret, query: text.slice(i + 1, caret) }; + } + return null; + } + if (/\s/.test(char)) return null; + } + return null; +} 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..634fa01 --- /dev/null +++ b/projects/angular-inline-select/src/lib/bubble-menu/bubble-menu.ts @@ -0,0 +1,172 @@ +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, 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 + * field-facing padding on `.editable-bubble` (the hover bridge) — see + * styles/_editable.scss. + */ +const END_POSITIONS: ConnectedPosition[] = [ + { 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, + * 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: 'center', overlayX: 'end', overlayY: 'center', 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-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..3b147b4 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-json.scss @@ -0,0 +1,221 @@ +// ============================================================================= +// 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; // 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; + + 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/_editable-text.scss b/projects/angular-inline-select/src/lib/styles/_editable-text.scss new file mode 100644 index 0000000..24cffdc --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-text.scss @@ -0,0 +1,192 @@ +// ============================================================================= +// 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). + // `--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; + 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. 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; + } + + &[contenteditable='false'] { + cursor: default; + } + + // Empty field: an empty `display: inline` element has no line box, so the + // browser paints NO caret when focused (the ::before placeholder is + // generated content — it renders, but it isn't caret-able content). An + // empty field has nothing to wrap, so promoting it to inline-block costs + // nothing and gives the caret a box to render in — same reason the + // single-line variant (always inline-block) never had this problem. + &:empty { + display: inline-block; + min-width: 1px; // caret-able even with an empty placeholder + min-height: 1em; + } + + // 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. + // 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)); + } + + // 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..e952e7d --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable.scss @@ -0,0 +1,290 @@ +// ----------------------------------------------------------------------------- +// 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; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: slash-command menu (consumer options, control navigation) +// ----------------------------------------------------------------------------- +.editable-menu { + max-height: var(--editable-menu-max-height, 40vh); + overflow-y: auto; + + // The consumer renders the options; these are the shared affordances the + // control's DOM navigation drives, themeable via tokens. + [role='option'] { + display: flex; + align-items: center; + gap: calc(var(--mat-sys-inner-spacing, 16px) / 2); + + padding: calc(var(--mat-sys-inner-spacing, 16px) / 4) + calc(var(--mat-sys-inner-spacing, 16px) / 2); + border-radius: var(--mat-sys-corner-small, 0.4rem); + cursor: pointer; + + // The control mirrors the active option to `aria-activedescendant` and + // marks it for styling — keyboard highlight without moving focus. + &[data-active='true'] { + background: var( + --editable-menu-active-background, + var(--mat-sys-secondary-container, #d7e3ff) + ); + color: var(--editable-menu-active-color, var(--mat-sys-on-secondary-container, #001b3f)); + } + } +} + +// ----------------------------------------------------------------------------- +// 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 { + // 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: 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; + 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..f1da9ab --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_index.scss @@ -0,0 +1,22 @@ +// ============================================================================= +// 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 +// - 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/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 new file mode 100644 index 0000000..d5312f9 --- /dev/null +++ b/projects/angular-inline-select/src/public-api.ts @@ -0,0 +1,16 @@ +/* + * Public API Surface of angular-inline-select + */ + +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/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/utils/middle-ellipsis/middle-ellipsis'; +export * from './lib/angular-inline-text/caret'; +export * from './lib/angular-inline-number/angular-inline-number'; 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..7523c6c --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.spec.ts @@ -0,0 +1,211 @@ +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 { AngularInlineDate, 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 () => { + // 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 }); + Object.defineProperty(event, 'target', { value: container }); + h.adapter.onContainerClick(event); + h.fixture.detectChanges(); + }; + + // Idle: the click focuses (the session opens on focusin, the error panel follows). + chromeClick(); + expect(document.activeElement).toBe(h.input()); + 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'); + }); +}); + +@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 new file mode 100644 index 0000000..22e733e --- /dev/null +++ b/projects/angular-inline-select/temporal-mat/src/mat-form-field-adapter.ts @@ -0,0 +1,238 @@ +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 { MAT_FORM_FIELD, 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; + + // 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 + // 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( + () => { + // 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()); + } + + 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(); + } + + /** Every control resolves its own default — read the uniform verdict, never the input. */ + #placeholder(): string { + return this.#control.placeholderText(); + } + + 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/ng-package.json b/projects/angular-inline-select/temporal/ng-package.json new file mode 100644 index 0000000..fbafcc4 --- /dev/null +++ b/projects/angular-inline-select/temporal/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.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 new file mode 100644 index 0000000..cdde2f6 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -0,0 +1,166 @@ + + + @if (prefixTpl(); as tpl) { + + } + + + + @if (twoFields()) { + + + + + + + + + + + } + + @if (consumerSuffixTpl(); as tpl) { + + } @else if (showCalendar()) { + + } + + + {{ revertNotice() }} + + + +@if (!twoFields()) { + + + +} + + + +
+ @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.scss b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss new file mode 100644 index 0000000..c76b82c --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.scss @@ -0,0 +1,176 @@ +: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; + border: 0; + background: transparent; + cursor: pointer; + border-radius: var(--mat-sys-corner-extra-small, 0.25rem); +} +.inline-date__trigger:focus-visible { + outline: 2px solid var(--mat-sys-primary, #4285f4); + outline-offset: 2px; +} + +.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; + 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; + } +} 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 new file mode 100644 index 0000000..92fc7c8 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.spec.ts @@ -0,0 +1,683 @@ +import { Component, signal, type Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form } from '@angular/forms/signals'; + +import { AngularInlineDate, type InlineDateSaved } from './angular-inline-date'; +import { + parseDateInput, + formatIsoDate, + formatInternalRange, + buildDateCommands, + toIsoDate, + inferDateShape, + toInternalRange, + echoDateShape, + dateValuesEqual, + localeDatePlaceholder, + type DateSavedDetails, + type InlineDateValue, +} from './date-codec'; +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 +// 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. +const NOW = new Date(2026, 4, 12); + +// ============================================================================= +// Codec +// ============================================================================= + +describe('date codec', () => { + it('parses dotted, slashed and ISO shapes', () => { + expect(parseDateInput('12.5.2026', NOW)).toBe('2026-05-12'); + expect(parseDateInput('12.5.26', NOW)).toBe('2026-05-12'); + expect(parseDateInput('12/5/2026', NOW)).toBe('2026-05-12'); + expect(parseDateInput('2026-05-12', NOW)).toBe('2026-05-12'); + }); + + it('year-less shapes take the year from now', () => { + expect(parseDateInput('12.5.', NOW)).toBe('2026-05-12'); + 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(); + expect(parseDateInput('12.13.2026', NOW)).toBeUndefined(); + 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(''); + }); + + 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'); + + const today = commands.find((command) => command.id === 'ai-date-today'); + expect(today?.iso).toBe('2026-05-12'); + expect(today?.match).toContain('today'); // English basis survives any locale + expect(today?.label.toLowerCase()).toBe('heute'); + + // 3 relatives + 7 weekdays, all resolving to real dates + expect(commands).toHaveLength(10); + expect(commands.every((command) => /^\d{4}-\d{2}-\d{2}$/.test(command.iso))).toBe(true); + }); + + it('toIsoDate is timezone-free calendar math', () => { + expect(toIsoDate(new Date(2026, 0, 1))).toBe('2026-01-01'); + }); +}); + +describe('date shape-echo codec', () => { + it('infers the shape from the bound value; null declares nothing', () => { + expect(inferDateShape('2026-05-12')).toBe('single'); + expect(inferDateShape({ start: '2026-05-12' })).toBe('start-only'); + expect(inferDateShape({ start: '2026-05-12', end: '2026-05-15' })).toBe('range'); + expect(inferDateShape({ start: null, end: null })).toBe('range'); + expect(inferDateShape(null)).toBeNull(); + }); + + it('normalizes every shape to the canonical internal range', () => { + expect(toInternalRange('2026-05-12')).toEqual({ start: '2026-05-12', end: '2026-05-12' }); + // { start } is the single-day range [start, start] + expect(toInternalRange({ start: '2026-05-12' })).toEqual({ + start: '2026-05-12', + end: '2026-05-12', + }); + expect(toInternalRange({ start: '2026-05-12', end: '2026-05-15' })).toEqual({ + start: '2026-05-12', + end: '2026-05-15', + }); + expect(toInternalRange(null)).toEqual({ start: null, end: null }); + }); + + it('echoes the received shape, never inventing another one', () => { + const single = { start: '2026-05-12', end: '2026-05-12' }; + expect(echoDateShape(single, 'single')).toBe('2026-05-12'); + expect(echoDateShape(single, 'start-only')).toEqual({ start: '2026-05-12' }); + expect(echoDateShape(single, 'range')).toEqual({ start: '2026-05-12', end: '2026-05-12' }); + }); + + it('start-only keeps its one-key form until the data has a distinct end', () => { + expect(echoDateShape({ start: '2026-05-12', end: '2026-05-12' }, 'start-only')).toEqual({ + start: '2026-05-12', + }); + expect(echoDateShape({ start: '2026-05-12', end: '2026-05-15' }, 'start-only')).toEqual({ + start: '2026-05-12', + end: '2026-05-15', + }); + }); + + it('dateValuesEqual compares structurally across shapes', () => { + expect(dateValuesEqual('2026-05-12', '2026-05-12')).toBe(true); + expect(dateValuesEqual({ start: '2026-05-12' }, { start: '2026-05-12' })).toBe(true); + expect(dateValuesEqual({ start: '2026-05-12' }, '2026-05-12')).toBe(false); + expect( + dateValuesEqual({ start: '2026-05-12' }, { start: '2026-05-12', end: '2026-05-15' }), + ).toBe(false); + expect(dateValuesEqual(null, null)).toBe(true); + }); + + it('formats single days plainly and distinct ranges through formatRange', () => { + expect(formatInternalRange({ start: '2026-05-12', end: '2026-05-12' }, 'en')).toBe( + 'May 12, 2026', + ); + expect(formatInternalRange({ start: null, end: null }, 'en')).toBe(''); + + const ranged = formatInternalRange({ start: '2026-05-12', end: '2026-05-15' }, 'en'); + expect(ranged).toContain('12'); + expect(ranged).toContain('15'); + }); +}); + +// ============================================================================= +// Component — the input rehost: real inputs, gesture-tiered sessions +// ============================================================================= + +@Component({ + imports: [AngularInlineDate, FormField], + template: ` + + `, +}) +class DateFormHost { + model = signal(db('2026-05-12')); + field = form(this.model); + now = () => NOW; + + saved: DateSavedDetails[] = []; + sessions: InlineDateSaved[] = []; +} + +@Component({ + imports: [AngularInlineDate], + template: ` + + `, +}) +class DateShapeHost { + value = signal(null); + ranged = signal(false); + placeholder = signal(undefined); + now = () => NOW; + + saved: DateSavedDetails[] = []; + sessions: InlineDateSaved[] = []; +} + +interface Harness { + fixture: ComponentFixture; + host: T; + 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, + inputs, + start: () => inputs()[0], + end: () => inputs()[1], + panel: () => document.querySelector('.inline-date__panel') as HTMLElement | null, + }; +} + +/** Focus settlement runs a macrotask behind (`setTimeout(0)`) — flush it. */ +async function settle(h: Harness) { + h.fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + h.fixture.detectChanges(); +} + +function focusInput(h: Harness, input: HTMLInputElement) { + input.focus(); + h.fixture.detectChanges(); +} + +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 press(h: Harness, input: HTMLInputElement, key: string) { + input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + h.fixture.detectChanges(); +} + +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 = setupHost(DateFormHost); + }); + + 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('Enter commits the typed draft with a full-reading preview, and closes the panel', async () => { + type(h, h.start(), '24.12.2026'); + + expect(document.querySelector('.inline-date__preview')?.textContent?.trim()).toBe( + 'Thursday, December 24, 2026', + ); + + press(h, h.start(), 'Enter'); + + 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(); + // Focus stays — Enter never traps NOR moves it. + expect(document.activeElement).toBe(h.start()); + }); + + 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('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); + + 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(); + }); + + 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(savedStartDays(h.host.saved)).toEqual(['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.host.field().value()).toBeNull(); + expect(savedStartDays(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(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()); + }); + + 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(savedStartDays(h.host.saved)).toEqual(['2026-05-13']); + }); +}); + +// ============================================================================= +// The two-field range — shape-echo, Tab-advance, per-side clear +// ============================================================================= + +@Component({ + imports: [AngularInlineDate], + template: ` + + `, +}) +class ZonedDateHost { + value = signal(dayToDbEntry('2026-07-21', 'Asia/Tokyo')); + now = () => NOW; +} + +describe('AngularInlineDate with a display zone (T6)', () => { + it('speaks the ZONE calendar day at the value boundary', () => { + const h = setupHost(ZonedDateHost); + + // Tokyo's Jul 21 — whatever day the machine zone thinks this instant is. + expect(h.start().value).toBe('Jul 21, 2026'); + + type(h, h.start(), '24.12.2026'); + press(h, h.start(), 'Enter'); + + expect(h.host.value()).toBe(dayToDbEntry('2026-12-24', 'Asia/Tokyo')); + h.start().blur(); + }); +}); + +describe('AngularInlineDate two-field range', () => { + let h: Harness; + + beforeEach(() => { + h = setupHost(DateShapeHost); + }); + + afterEach(async () => { + await blurAway(h); + }); + + 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); + + 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('null + ranged=true cold-starts as the pair, both hinting the locale pattern', () => { + h.host.ranged.set(true); + h.fixture.detectChanges(); + + expect(h.inputs().length).toBe(2); + expect(h.start().placeholder).toBe('mm/dd/yyyy'); + expect(h.end()!.placeholder).toBe('mm/dd/yyyy'); + }); + + 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.end()!.placeholder).toBe('…'); + }); + + 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.start().placeholder).toBe('when?'); + expect(h.end()!.placeholder).toBe('when?'); + }); + + it('Tab-advance: focus moving start → end settles the start (commit-valid)', async () => { + h.host.ranged.set(true); + h.fixture.detectChanges(); + + 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 }, + ]); + + 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('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(); + + type(h, h.end()!, ''); + press(h, h.end()!, 'Enter'); + expect(h.host.value()).toEqual({ start: db('2026-05-12'), end: null }); + + type(h, h.start(), ''); + press(h, h.start(), 'Enter'); + 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('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(); + + type(h, h.start(), '20.5.2026'); + press(h, h.start(), 'Enter'); + + expect(h.host.value()).toEqual({ start: db('2026-05-20') }); + + // 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(h.host.value()).toEqual({ start: db('2026-05-20'), end: dbEnd('2026-05-25') }); + }); + + 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(); + + type(h, h.start(), ''); + press(h, h.start(), 'Enter'); + + expect(h.host.value()).toEqual({ start: null }); + expect(h.inputs().length).toBe(2); + }); + + it('press-hold-drag paints the range live and commits it whole — ONE saved', async () => { + h.host.ranged.set(true); + h.fixture.detectChanges(); + + focusInput(h, h.start()); + const cellA = gridCell('2026-05-06')!; + cellA.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + h.fixture.detectChanges(); + gridCell('2026-05-09')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + h.fixture.detectChanges(); + + // 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); + + document.dispatchEvent(new MouseEvent('mouseup')); + await settle(h); + + 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('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(); + + 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 }), + ); + await settle(h); + + 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 + + // A reversed drag (25 → 22) commits sorted. + gridCell('2026-05-25')!.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + gridCell('2026-05-22')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + document.dispatchEvent(new MouseEvent('mouseup')); + await settle(h); + + expect(h.host.value()).toEqual({ start: db('2026-05-22'), end: dbEnd('2026-05-25') }); + }); + + 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(); + + focusInput(h, h.start()); + gridCell('2026-05-20')!.click(); + await settle(h); + + 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 + + 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(); + }); +}); + +// ============================================================================= +// 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-date/angular-inline-date.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts new file mode 100644 index 0000000..496b6b5 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -0,0 +1,927 @@ +import { + Component, + ElementRef, + Injector, + + // Signals + afterNextRender, + computed, + contentChild, + inject, + input, + model, + output, + signal, + type Signal, + type TemplateRef, + viewChild, +} from '@angular/core'; +import { DOCUMENT, NgTemplateOutlet } from '@angular/common'; + +// CDK +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + type ConnectedPosition, +} from '@angular/cdk/overlay'; + +// Form +import { FormValueControl, type ValidationError } from '@angular/forms/signals'; + +// Core +import { + EditablePrefix, + EditableSuffix, + BubbleMenu, + EditableClearButton, + type BubbleMenuSide, +} from 'angular-inline-select'; +import { + parseDateInput, + formatIsoDate, + describeIsoDate, + buildDateCommands, + inferDateShape, + toInternalRange, + echoDateShape, + 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, toDateTime } 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. */ +export interface InlineDateSaved { + /** The value the session settled on, in the consumer's bound shape. */ + value: InlineDateValue; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * 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 extends SideCore { + /** The committed day at session start — what Escape and snap-back restore. */ + baselineDay: IsoDate | null; + /** + * 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. + */ + readonly parsed: Signal; +} + +/** + * 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. + * + * 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: [ + NgTemplateOutlet, + + // CDK + CdkConnectedOverlay, + CdkOverlayOrigin, + + // Components + Calendar, + BubbleMenu, + EditableClearButton, + ], + templateUrl: './angular-inline-date.html', + styleUrl: './angular-inline-date.scss', + host: { + '[style.display]': 'hidden() ? "none" : null', + }, +}) +export class AngularInlineDate implements FormValueControl { + #document = inject(DOCUMENT); + #injector = inject(Injector); + + /** + * The committed value channel — polymorphic UTC ISO DB entries (iusta's + * `toDBEntry`): a single string binds a single date, `{ start, end? }` + * binds a range, and the control ECHOES whichever shape it received. + * Behind the back a day is its local `startOf('day')` in UTC (range ends + * `endOf('day')`); the DISPLAY is the localized local calendar day. + */ + 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); + + /** Form Value Contract. */ + errors = input([]); + disabled = input(false); + readonly = input(false); + required = input(false); + touched = input(false); + invalid = input(false); + hidden = input(false); + + /** + * 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()), + ); + + /** + * 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; + return this.internalRange().start === null ? this.effectivePlaceholder() : '…'; + }); + + /** Accessible base name; ranged fields append " start" / " end". */ + ariaLabel = input(undefined); + + /** + * 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(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); + + /** + * 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 }); + + readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); + + /** 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 + * 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()); + + /** Affix template passthrough (composition channel + content sugar). */ + 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 consumerSuffixTpl = 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 }); + + /** Public: the composed disabled verdict (own input + group-fed state). */ + readonly effectiveDisabled = computed( + () => this.disabled() || (this.#leafState?.disabled() ?? false), + ); + protected effectiveReadonly = computed( + () => this.readonly() || (this.#leafState?.readonly() ?? false), + ); + protected effectiveTouched = computed( + () => this.touched() || (this.#leafState?.touched() ?? false), + ); + protected effectiveInvalid = computed( + () => this.invalid() || (this.#leafState?.invalid() ?? false), + ); + + /** Form Value Contract: touch — emitted whenever a session settles. */ + touch = 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(); + + /** + * 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. */ + editing = model(false); + + #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 = this.#shapeMemory.shape; + + /** Object shapes render the start–end input pair; a string renders one field. */ + protected twoFields = this.#shapeMemory.twoFields; + + /** + * 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, 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, zone); + + 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, zone) }; + } + + // -- 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 committed = computed(() => this.internalRange()[key]); + const display = computed(() => formatIsoDate(committed(), this.locale())); + const core = makeSideCore(key, committed, display); + + return { + ...core, + baselineDay: null, + parsed: computed(() => + parseDateInput(core.draft(), this.now()(), this.locale(), this.effectiveZone()), + ), + }; + } + + 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; + + 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(Calendar); + protected panelRef = viewChild>('panel'); + + /** + * 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); + + #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; + }); + + /** 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 side = this.#side(this.focusTarget() ?? 'start'); + const raw = side.draft().trim(); + if (!raw) return ''; + + const iso = side.parsed(); + if (iso === null || iso === undefined) return `… ${raw}`; + + return `${describeIsoDate(iso, this.locale())}`; + }); + + /** The grid's pending day: the focused side's parsed draft, else its committed day. */ + protected pendingDay = computed(() => { + const side = this.#side(this.focusTarget() ?? 'start'); + const draft = side.parsed(); + if (typeof draft === 'string') return draft; + + return side.committed() ?? 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 }, + ]; + + protected revertFlash = this.#chrome.revertFlash; + protected revertNotice = this.#chrome.revertNotice; + + constructor() { + 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 placeholder = + key === 'end' ? this.effectiveEndPlaceholder() : this.effectivePlaceholder(); + return sideSize(this.#side(key).draft(), placeholder); + } + + protected ariaLabelOf(key: SideKey): string { + return sideAriaLabel(this.ariaLabel() ?? 'Date', key, this.twoFields()); + } + + 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.committed(); + side.open.set(true); + } + + side.draft.set(raw); + side.dirty = true; + side.saveAttempted.set(false); + this.overlayOpen.set(true); + + const day = side.parsed(); + if (day !== undefined) this.#writeSideDay(key, day); + } + + /** + * 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). + */ + #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); + } + + // -- 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(); + 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); + } + + /** + * 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() { + this.#chrome.scheduleFocusSettle(() => this.#onFocusSettled()); + } + + #onFocusSettled() { + 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'); + } + + 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'); + } + } + + // -- Settlement (ONE per session — commit, snap-back, Escape, clear) ----------- + + /** + * 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). + */ + #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.committed(); + } else if (options.revert) { + day = side.baselineDay; + } else if (options.resolve !== undefined) { + day = options.resolve; + } else { + const parsed = side.parsed(); + snappedBack = parsed === undefined; + day = parsed === undefined ? side.baselineDay : parsed; + } + + 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; + + 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); + } + + if (snappedBack) this.#chrome.announceRevert(key, formatIsoDate(day, this.locale())); + + this.#selfTouched.set(true); + this.touch.emit(); + + const value = this.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) { + switch (event.key) { + case 'Enter': { + event.preventDefault(); + const side = this.#side(key); + if (side.parsed() === 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 --------------------------------------------------------------------- + + /** + * 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 pickDate(day: IsoDate) { + const key = this.focusTarget() ?? 'start'; + const side = this.#side(key); + if (!side.open()) { + side.baselineDay = side.committed(); + side.open.set(true); + } + + // 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.committed(), keepOpen: true }); + + const other: SideKey = key === 'start' ? 'end' : 'start'; + if (this.twoFields() && this.#side(other).committed() === null) { + this.#chrome.focusSide(other); + } else { + this.overlayOpen.set(false); + this.#chrome.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())); + } + } + + /** + * Commits BOTH sides in one settlement (drag, Ctrl+click): one value + * write, both sides re-baselined, ONE `saved`. + */ + #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.committed(); + 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.#emitSavedModel(); + this.saved.emit({ value, changed }); + } + + /** 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.#chrome.focusSide(this.focusTarget() ?? 'start'); + } + + /** + * 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; + } + + this.#commitBothSides(day, null); + this.#chrome.focusSide('end'); + } + + /** Escape in the grid hands control back to the focused input (session continues). */ + protected escapeCalendar() { + this.#chrome.focusSide(this.focusTarget() ?? 'start'); + } + + /** + * 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 (this.overlayOpen()) { + this.overlayOpen.set(false); + return; + } + + if (this.focusTarget() === null) this.#chrome.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(); + } + + /** + * 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; + } + + // -- Clear affordance (idle hover bubble; per-side for a range) ---------------- + + #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 one side from the idle hover bubble — a commit AND an interaction + * (mat-faithful): it writes `null` into that side (the OTHER side is never + * nuked, shape-echoed), 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(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(); + this.#writeSideDay(key, null); + + for (const side of [this.#startSide, this.#endSide]) { + side.baselineDay = side.committed(); + side.draft.set(side.display()); + side.dirty = false; + side.saveAttempted.set(false); + } + + this.#selfTouched.set(true); + this.touch.emit(); + + const value = this.value(); + const changed = !dateValuesEqual(value, before); + if (changed) this.#emitSavedModel(); + this.saved.emit({ value, changed }); + } + + // -- Form Value Contract ------------------------------------------------------------ + + focus(options?: FocusOptions) { + this.#inputOf('start')?.focus(options); + } + + /** + * Presentation-only rollback (the MatInput precedent): an open draft is + * discarded back to the baseline with no `touch`, no `saved`, no focus + * stealing. + */ + 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.committed(); + 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/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..f4316cc --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.scss @@ -0,0 +1,103 @@ +:host { + --_spacing: var(--mat-sys-spacing, 0.25rem); + --_radius: var(--mat-sys-corner-small, 0.625rem); + --_button-size: 2rem; + + display: block; + padding: var(--_spacing); + user-select: none; +} +.cal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-block-end: calc(var(--_spacing) * 2); +} +.cal__label { + font: var(--mat-sys-title-small, 500 0.875rem/1.25 system-ui); + text-transform: capitalize; +} +.cal__nav { + height: var(--_button-size); + width: var(--_button-size); + + border: 0; + background: transparent; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 4px 8px; + border-radius: var(--mat-sys-corner-small, calc(var(--_radius) * 2)); + 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, var(--_button-size)); + margin-block-start: var(--_spacing); +} +.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: var(--_button-size); + width: var(--_button-size); + + border: 0; + background: transparent; + border-radius: calc(var(--_radius) * 0.875); + 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; + + &:first-child { + border-start-start-radius: calc(var(--_radius) * 0.875); + border-end-start-radius: calc(var(--_radius) * 0.875); + } + + &:last-child { + border-start-end-radius: calc(var(--_radius) * 0.875); + border-end-end-radius: calc(var(--_radius) * 0.875); + } +} + +.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/calendar/calendar.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts new file mode 100644 index 0000000..c8370e8 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts @@ -0,0 +1,372 @@ +import { + Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + input, + linkedSignal, + 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; + day: number; + outside: boolean; + 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 { + 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 { + return DateTime.fromISO(iso).plus({ months }).toFormat(ISO_DAY); +} + +/** 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: 'temporal-calendar', + templateUrl: './calendar.html', + styleUrl: './calendar.scss', +}) +export class Calendar { + #injector = inject(Injector); + #document = inject(DOCUMENT); + + /** The pending day — the field's parsed draft, mirrored per keystroke. */ + activeDay = input(null); + + /** 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 + * valid day standing. + */ + protected active = linkedSignal({ + source: this.activeDay, + computation: (day, previous) => day ?? previous?.value ?? this.today(), + }); + + protected weeks = computed(() => { + const [, month] = parts(this.active()); + const first = firstDayOfWeek(this.locale()); + 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. + const lead = ((firstOfMonth.weekday % 7) - first + 7) % 7; + + const weeks: CalendarDay[][] = []; + let cursor = firstOfMonth.minus({ days: lead }); + for (let week = 0; week < 6; week++) { + const days: CalendarDay[] = []; + for (let day = 0; day < 7; day++) { + days.push({ + iso: cursor.toFormat(ISO_DAY), + day: cursor.day, + outside: cursor.month !== month, + today: cursor.toFormat(ISO_DAY) === today, + }); + cursor = cursor.plus({ days: 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(); + 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; + } + } + + /** + * 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/angular-inline-date/date-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts new file mode 100644 index 0000000..30588f6 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/date-codec.ts @@ -0,0 +1,411 @@ +/** + * Date codec — the canonical value is an ISO calendar date string + * (`'2026-05-12' | null`): serializable, locale-free, timezone-free — the + * date analogue of the phone control's E.164. Display and command names + * localize through `Intl` at zero bundle bytes. + */ + +import { DateTime } from 'luxon'; + +/** `'yyyy-MM-dd'`. */ +export type IsoDate = string; + +/** The object shapes of `InlineDateValue`: `{ start }` is the single-day range `[start, start]`. */ +export interface IsoDateRange { + start: IsoDate | null; + end?: IsoDate | null; +} + +/** + * The polymorphic bound value. The consumer's binding shape IS the mode + * declaration: a string binds a single date field, an object binds a range. + * The control echoes the shape it received and never invents another one. + */ +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'; + +export function inferDateShape(value: InlineDateValue): DateValueShape | 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 InternalDateRange { + start: IsoDate | null; + end: IsoDate | null; +} + +export function toInternalRange(value: InlineDateValue): InternalDateRange { + if (value === null) return { start: null, end: null }; + if (typeof value === 'string') return { start: value, end: value }; + + const start = value.start ?? null; + return { start, end: value.end === undefined ? start : value.end }; +} + +/** + * The echo: renders the internal range back in the consumer's shape. + * `start-only` keeps its one-key form until the data actually has a + * distinct end — only then does it grow the `end` key. + */ +export function echoDateShape( + internal: InternalDateRange, + shape: DateValueShape, +): InlineDateValue { + switch (shape) { + case 'single': + return internal.start; + case 'start-only': + return internal.end === internal.start || 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 dateValuesEqual(a: InlineDateValue, b: InlineDateValue): boolean { + if (a === null || b === null || typeof a === 'string' || typeof b === 'string') return a === b; + + return a.start === b.start && a.end === b.end; +} + +export function toIsoDate(date: Date): IsoDate { + return DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'); +} + +function isoIfValid(year: number, month: number, day: number): IsoDate | undefined { + const date = DateTime.fromObject({ year, month, day }); + return date.isValid ? date.toFormat('yyyy-MM-dd') : 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, + nowYear: number, + 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 ?? nowYear, 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'` — 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(), + 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])); + + // Dotted / slashed day-first: d.M.yyyy | d.M.yy | d.M. | d.M | d/M/yyyy + match = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2}|\d{4})?)?$/.exec(trimmed); + if (match) { + const day = Number(match[1]); + const month = Number(match[2]); + const year = + match[3] === undefined + ? nowYear + : match[3].length === 2 + ? 2000 + Number(match[3]) + : Number(match[3]); + + return isoIfValid(year, month, day); + } + + // Named months (the display's own format, localized + English). + 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, + locale?: string | string[], + options: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }, +): string { + if (iso === null) return ''; + + const [year, month, day] = iso.split('-').map(Number); + try { + return new Intl.DateTimeFormat(locale, options).format(new Date(year, month - 1, day)); + } catch { + return iso; + } +} + +/** Long reading for the interpretation preview: `'Monday, 12 May 2026'`. */ +export function describeIsoDate(iso: IsoDate, locale?: string | string[]): string { + return formatIsoDate(iso, locale, { dateStyle: 'full' }); +} + +/** + * Localized display of the internal range: single days render like + * `formatIsoDate`; a distinct end renders through `formatRange` + * (`'12 – 15 May 2026'`). Interim single-field display until T5's + * two-field ranged UI. + */ +export function formatInternalRange( + range: InternalDateRange, + locale?: string | string[], +): string { + const { start, end } = range; + if (start === null && end === null) return ''; + if (start === null) return `– ${formatIsoDate(end, locale)}`; + if (end === null || end === start) return formatIsoDate(start, locale); + + const toDate = (iso: IsoDate) => { + const [year, month, day] = iso.split('-').map(Number); + return new Date(year, month - 1, day); + }; + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).formatRange( + toDate(start), + toDate(end), + ); + } catch { + return `${start} – ${end}`; + } +} + +/** A slash-menu command resolving to a concrete date. */ +export interface DateCommand { + id: string; + /** Localized label (`'morgen'`), from `Intl` — zero bundled translations. */ + label: string; + /** Extra lower-cased matching basis (English + ISO), so `/tomorrow` works everywhere. */ + match: string; + iso: IsoDate; +} + +function relativeLabel(days: -1 | 0 | 1, locale?: string | string[]): string { + try { + return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(days, 'day'); + } catch { + return days === 0 ? 'today' : days === 1 ? 'tomorrow' : 'yesterday'; + } +} + +function weekdayLabel(date: Date, locale?: string | string[]): string { + try { + return new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(date); + } catch { + return date.toDateString().slice(0, 3); + } +} + +/** + * 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[], + 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 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 { 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} ${iso}`.toLowerCase(), + iso, + }; + }); + + const weekdays: DateCommand[] = Array.from({ length: 7 }, (_, index) => { + const { iso, date } = at(index + 1); + const label = weekdayLabel(date, locale); + + return { + id: `ai-date-weekday-${index}`, + label, + match: `${label} ${english(date)} ${iso}`.toLowerCase(), + iso, + }; + }); + + return [...relatives, ...weekdays]; +} 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 new file mode 100644 index 0000000..613bdb5 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.html @@ -0,0 +1,68 @@ + + + @if (prefixTpl(); as tpl) { + + } + + + + @if (suffixTpl(); as tpl) { + + } + + + {{ revertNotice() }} + + + + + + + + +
+
+ +
+
+
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..7ddee8a --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.scss @@ -0,0 +1,116 @@ +: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; + // 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; + 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.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts new file mode 100644 index 0000000..56d9f10 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.spec.ts @@ -0,0 +1,200 @@ +import { Component, signal } from '@angular/core'; +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, + type DurationSavedDetails, +} from './duration-codec'; + +// ============================================================================= +// Codec +// ============================================================================= + +describe('duration codec', () => { + it('parses colon notation positionally by format', () => { + expect(parseDuration('1:30', 'h:mm')).toBe(5400); + expect(parseDuration('1:30', 'mm:ss')).toBe(90); + expect(parseDuration('1:02:03', 'h:mm:ss')).toBe(3723); + }); + + it('parses unit tokens format-independently', () => { + expect(parseDuration('1h 30m')).toBe(5400); + expect(parseDuration('45m')).toBe(2700); + expect(parseDuration('1.5h')).toBe(5400); + expect(parseDuration('90s')).toBe(90); + }); + + it('parses bare numbers as minutes (hour formats) or seconds (mm:ss)', () => { + expect(parseDuration('90', 'h:mm')).toBe(5400); + expect(parseDuration('90', 'mm:ss')).toBe(90); + }); + + it('empty is null, sexagesimal overflow and garbage are undefined', () => { + expect(parseDuration('')).toBeNull(); + expect(parseDuration('1:75')).toBeUndefined(); + expect(parseDuration('abc')).toBeUndefined(); + }); + + it('formats seconds per format and describes them for the preview', () => { + 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'); + }); +}); + +// ============================================================================= +// Component — the input rehost: one real input, gesture-tiered sessions +// ============================================================================= + +@Component({ + imports: [AngularInlineDuration, FormField], + template: ` + + `, +}) +class DurationFormHost { + model = signal(5400); + field = form(this.model); + + saved: DurationSavedDetails[] = []; + sessions: InlineDurationSaved[] = []; +} + +interface Harness { + fixture: ComponentFixture; + host: DurationFormHost; + input: () => HTMLInputElement; +} + +function setup(): Harness { + const fixture = TestBed.createComponent(DurationFormHost); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + input: () => + fixture.nativeElement.querySelector('.inline-duration__input') as HTMLInputElement, + }; +} + +/** Focus settlement runs a macrotask behind (`setTimeout(0)`) — flush it. */ +async function settle(h: Harness) { + h.fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + h.fixture.detectChanges(); +} + +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 press(h: Harness, key: string) { + h.input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + h.fixture.detectChanges(); +} + +async function blurAway(h: Harness) { + (document.activeElement as HTMLElement | null)?.blur(); + await settle(h); +} + +describe('AngularInlineDuration (input rehost)', () => { + let h: Harness; + + beforeEach(() => { + h = setup(); + }); + + afterEach(async () => { + await blurAway(h); + }); + + it('renders the committed seconds in clock format in a real input', () => { + expect(h.input().value).toBe('01:30'); + }); + + it('Enter commits unit tokens as seconds, snapped to step', () => { + type(h, '2h 15m'); + press(h, 'Enter'); + + 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 + }); + + 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('01: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('01:30'); + expect(h.host.saved).toEqual([]); + }); + + 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 }]); + }); +}); + + +// ============================================================================= +// 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-duration/angular-inline-duration.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts new file mode 100644 index 0000000..7ba534d --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -0,0 +1,490 @@ +import { + Component, + DestroyRef, + ElementRef, + computed, + contentChild, + effect, + inject, + input, + linkedSignal, + model, + output, + signal, + untracked, + viewChild, + 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 { + EditablePrefix, + EditableSuffix, + type BubbleMenuSide, + BubbleMenu, + EditableClearButton, +} from 'angular-inline-select'; +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. */ +export interface InlineDurationSaved { + /** The value the session settled on — SECONDS, or `null` for empty. */ + value: number | null; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; +} + +/** + * 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 + * hour formats, seconds under `mm:ss`). + * - 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({ + selector: 'angular-inline-duration', + imports: [CdkConnectedOverlay, CdkOverlayOrigin, NgTemplateOutlet, BubbleMenu, EditableClearButton], + templateUrl: './angular-inline-duration.html', + styleUrl: './angular-inline-duration.scss', + host: { + '[style.display]': 'hidden() ? "none" : null', + }, +}) +export class AngularInlineDuration implements FormValueControl { + #document = inject(DOCUMENT); + + /** The committed value channel: duration in SECONDS, or `null`. */ + value = model(null); + + /** Form Value Contract. */ + errors = input([]); + disabled = input(false); + readonly = input(false); + required = input(false); + touched = input(false); + invalid = input(false); + hidden = input(false); + + 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); + + /** + * 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(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'); + + /** Snap committed values to a multiple of this many seconds (1 = off). */ + step = input(1); + + /** Affix template passthrough (composition channel + content sugar). */ + 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); + + /** + * 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 }); + + /** Public: the composed disabled verdict (own input + group-fed state). */ + readonly effectiveDisabled = computed( + () => this.disabled() || (this.#leafState?.disabled() ?? false), + ); + protected effectiveReadonly = computed( + () => this.readonly() || (this.#leafState?.readonly() ?? false), + ); + protected effectiveTouched = computed( + () => this.touched() || (this.#leafState?.touched() ?? false), + ); + protected effectiveInvalid = computed( + () => this.invalid() || (this.#leafState?.invalid() ?? false), + ); + + /** Form Value Contract: touch — emitted whenever a session settles. */ + touch = 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(); + + /** + * 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. */ + 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 input's text: user-owned while the session is open (frozen + * linkedSignal), the committed display otherwise. + */ + 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.draft(), this.durationFormat()) === undefined, + ); + + #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 — 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()); + + /** The panel appears only to carry an error — there is no live preview. */ + protected panelOpen = computed( + () => this.#open() && !this.#panelDismissed() && 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; + } + + 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); + } + + // -- Focus flow ------------------------------------------------------------------- + + protected handleFocusIn() { + this.#openSession(); + this.editing.set(true); + } + + 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.#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}`); + 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; + } + } + } + + /** + * 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; + 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.#emitSavedModel(); + this.saved.emit({ value: null, changed: true }); + } + + // -- Form Value Contract ------------------------------------------------------------------ + + focus(options?: FocusOptions) { + this.durationInput()?.nativeElement.focus(options); + } + + /** Presentation-only rollback — see the date control. */ + 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-duration/duration-codec.ts b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts new file mode 100644 index 0000000..6d44fc0 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/duration-codec.ts @@ -0,0 +1,127 @@ +/** + * Duration codec — the value is SECONDS (`number | null`), display is a + * clock-style string. Same contract shape as the number codec: `''` → `null` + * (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, + s: 1, +}; + +/** + * Parses a duration draft into seconds. + * + * Accepted shapes: + * - Colon notation, positional by `format`: `'1:30'` is 1 h 30 min under + * `h:mm`, but 1 min 30 s under `mm:ss`. Positions after the first must be + * valid sexagesimal (0–59). + * - Unit tokens, format-independent: `'1h 30m'`, `'45m'`, `'90s'`, `'1.5h'`. + * - A bare number: minutes under hour-based formats, seconds under `mm:ss`. + */ +export function parseDuration(raw: string, format: DurationFormat = 'h:mm'): number | null | undefined { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === '') return null; + + // Unit tokens: "1h 30m", "45m", "1.5h", "90s" + if (/^(\d+(?:\.\d+)?\s*[hms]\s*)+$/.test(trimmed)) { + let seconds = 0; + for (const [, amount, unit] of trimmed.matchAll(/(\d+(?:\.\d+)?)\s*([hms])/g)) { + seconds += Number(amount) * UNIT_SECONDS[unit]; + } + return Math.round(seconds); + } + + // Colon notation: positional by format + if (/^\d+(?::\d{1,2})+$/.test(trimmed)) { + const parts = trimmed.split(':').map(Number); + if (parts.slice(1).some((part) => part > 59)) return undefined; + + if (format === 'mm:ss') { + if (parts.length !== 2) return undefined; + return parts[0] * 60 + parts[1]; + } + + if (parts.length === 2) return parts[0] * 3600 + parts[1] * 60; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + return undefined; + } + + // Bare number: minutes for hour-based formats, seconds for mm:ss + if (/^\d+(?:\.\d+)?$/.test(trimmed)) { + const amount = Number(trimmed); + return Math.round(format === 'mm:ss' ? amount : amount * 60); + } + + return undefined; +} + +/** Renders seconds in the given clock format (`null` → `''`). */ +export function formatDuration(seconds: number | null, format: DurationFormat = 'h:mm'): string { + if (seconds === null) return ''; + + const pad = (value: number) => String(value).padStart(2, '0'); + + if (format === 'mm:ss') { + 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 `${pad(hours)}:${pad(minutes)}:${pad(seconds % 60)}`; + return `${pad(hours)}:${pad(minutes)}`; +} + +/** Human reading for the live interpretation preview: `'1 h 30 min'`. */ +export function describeDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = seconds % 60; + + const parts: string[] = []; + if (hours) parts.push(`${hours} h`); + if (minutes) parts.push(`${minutes} min`); + if (rest || parts.length === 0) parts.push(`${rest} s`); + + return parts.join(' '); +} 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 new file mode 100644 index 0000000..17f54e8 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.html @@ -0,0 +1,141 @@ + + + @if (prefixTpl(); as tpl) { + + } + + + + + + @if (!twoFields() && dayOffset() > 0) { + +{{ dayOffset() }} + } + + + @if (twoFields()) { + + + + + @if (dayOffset() > 0) { + +{{ dayOffset() }} + } + + + + + + + + + + } + + @if (consumerSuffixTpl(); as tpl) { + + } + + + + + {{ revertNotice() }} + + + +@if (!twoFields()) { + + + +} + + + +
+
+ +
+
+
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..270f3fe --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.scss @@ -0,0 +1,163 @@ +:host { + display: inline; + position: relative; + --_spacing: var(--mat-sys-spacing, 0.25rem); +} + +.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)); +} + +.inline-time__separator { + user-select: none; + color: var(--mat-sys-on-surface-variant, #5f6368); +} + +/* 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: calc(var(--_spacing) * -2.5); + right: calc(var(--_spacing) * -2.5); + z-index: 10; + + border-radius: var(--mat-sys-corner-full, 100vw); + font-size: 0.75rem; + font-weight: 500; + line-height: calc(1 / 0.75); + 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; + // 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; + 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.spec.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts new file mode 100644 index 0000000..52e9570 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.spec.ts @@ -0,0 +1,856 @@ +import { Component, signal } from '@angular/core'; +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, + 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, + localDayDiff, + localTimeOf, + localDayOf, + parseDbEntryDraft, + todayIn, +} from '../datetime/db-entry'; + +// The value contract: UTC ISO DB entries behind, local display in front. +// Expectations compose through the same helpers, so specs are TZ-independent. +const DAY = '2026-07-21'; +const at = (time: string) => composeDbEntry(DAY, time); + +// ============================================================================= +// Codec +// ============================================================================= + +describe('time codec', () => { + it('parses separated and compact shapes', () => { + expect(parseTime('9:30')).toBe('09:30'); + expect(parseTime('09.30')).toBe('09:30'); + expect(parseTime('9')).toBe('09:00'); + expect(parseTime('21')).toBe('21:00'); + expect(parseTime('930')).toBe('09:30'); + expect(parseTime('2105')).toBe('21:05'); + }); + + it('empty is null; impossible times and garbage are undefined', () => { + expect(parseTime('')).toBeNull(); + expect(parseTime('25:00')).toBeUndefined(); // overflow — only parseTimeDraft carries it + expect(parseTime('9:75')).toBeUndefined(); + expect(parseTimeDraft('9:75')).toBeUndefined(); + expect(parseTime('soon')).toBeUndefined(); + }); + + it('overflow hours declare the day over-count by hand', () => { + expect(parseTimeDraft('24:30')).toEqual({ time: '00:30', days: 1 }); + expect(parseTimeDraft('2430')).toEqual({ time: '00:30', days: 1 }); + expect(parseTimeDraft('240:30')).toEqual({ time: '00:30', days: 10 }); + expect(parseTimeDraft('30:00')).toEqual({ time: '06:00', days: 1 }); + expect(parseTimeDraft('9:30')).toEqual({ time: '09:30', days: 0 }); + + // Bare 1-2 digit hours stay strict; bad minutes still gate. + expect(parseTimeDraft('99')).toBeUndefined(); + expect(parseTimeDraft('24:75')).toBeUndefined(); + + // The overflow-free convenience rejects what it cannot carry. + 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'); + 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 + }); +}); + +// ============================================================================= +// T6 — the display zone is configuration, the value is not +// ============================================================================= + +describe('db-entry zones (T6)', () => { + // 2026-07-21 is summer: New York = UTC-4 (EDT), Tokyo = UTC+9. + const NY = 'America/New_York'; + const TOKYO = 'Asia/Tokyo'; + + it('the same instant reads as DIFFERENT calendar days per zone', () => { + const instant = '2026-07-21T23:30:00.000Z'; + expect(localDayOf(instant, NY)).toBe('2026-07-21'); // 19:30 EDT + expect(localDayOf(instant, TOKYO)).toBe('2026-07-22'); // 08:30 JST + expect(localTimeOf(instant, NY)).toBe('19:30'); + }); + + it('day boundaries and compositions run in the display zone', () => { + expect(dayToDbEntry('2026-07-21', NY)).toBe('2026-07-21T04:00:00.000Z'); + expect(composeDbEntry('2026-07-21', '21:00', NY)).toBe('2026-07-22T01:00:00.000Z'); + // An offset-less pasted draft reads in the display zone too. + expect(parseDbEntryDraft('2026-07-21 21:00', NY)).toBe('2026-07-22T01:00:00.000Z'); + }); + + it('the +n over-count is a ZONE question — the same range differs per wall', () => { + const start = composeDbEntry('2026-07-21', '21:00', NY); + const end = composeDbEntry('2026-07-22', '06:00', NY); + expect(localDayDiff(start, end, NY)).toBe(1); // overnight in New York… + expect(localDayDiff(start, end, TOKYO)).toBe(0); // …same Tokyo afternoon/evening + }); + + it('todayIn reads the reference clock in the zone', () => { + // 23:30 UTC on Jul 21 is already Jul 22 in Tokyo. + const clock = new Date('2026-07-21T23:30:00.000Z'); + expect(todayIn(clock, TOKYO)).toBe('2026-07-22'); + expect(todayIn(clock, NY)).toBe('2026-07-21'); + }); +}); + +@Component({ + imports: [AngularInlineTime, FormField], + template: ` + + `, +}) +class ZonedTimeHost { + model = signal(composeDbEntry('2026-07-21', '21:00', 'America/New_York')); + field = form(this.model); + + sessions: InlineTimeSaved[] = []; +} + +describe('AngularInlineTime with a display zone (T6) + native bounds (T3)', () => { + it('displays the ZONE wall clock and re-composes commits in it', () => { + const fixture = TestBed.createComponent(ZonedTimeHost); + fixture.detectChanges(); + const input = fixture.nativeElement.querySelector('.inline-time__input') as HTMLInputElement; + + expect(input.value).toBe('21:00'); // New York's 21:00, whatever the machine zone + + input.focus(); + fixture.detectChanges(); + input.value = '9'; + input.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + fixture.detectChanges(); + + expect(fixture.componentInstance.model()).toBe( + composeDbEntry('2026-07-21', '09:00', 'America/New_York'), + ); + + input.blur(); + }); + + it('T3: min/max forward to the native picker input', () => { + const fixture = TestBed.createComponent(ZonedTimeHost); + fixture.detectChanges(); + const native = fixture.nativeElement.querySelector('.inline-time__native') as HTMLInputElement; + + expect(native.getAttribute('min')).toBe('08:00'); + expect(native.getAttribute('max')).toBe('18:00'); + expect(native.getAttribute('step')).toBe('60'); + }); +}); + +// ============================================================================= +// Component — the input rehost: one real input, gesture-tiered sessions +// ============================================================================= + +@Component({ + imports: [AngularInlineTime, FormField], + template: ` + + `, +}) +class TimeFormHost { + model = signal(at('09:30')); + field = form(this.model); + native = signal(false); + + saved: TimeSavedDetails[] = []; + sessions: InlineTimeSaved[] = []; +} + +interface Harness { + fixture: ComponentFixture; + host: TimeFormHost; + input: () => HTMLInputElement; + native: () => HTMLInputElement; + panel: () => HTMLElement | null; +} + +function setup(): Harness { + const fixture = TestBed.createComponent(TimeFormHost); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + input: () => fixture.nativeElement.querySelector('.inline-time__input') as HTMLInputElement, + native: () => fixture.nativeElement.querySelector('.inline-time__native') as HTMLInputElement, + panel: () => document.querySelector('.inline-time__panel') as HTMLElement | null, + }; +} + +/** Focus settlement runs a macrotask behind (`setTimeout(0)`) — flush it. */ +async function settle(h: Harness) { + h.fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + h.fixture.detectChanges(); +} + +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 press(h: Harness, key: string) { + h.input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + h.fixture.detectChanges(); +} + +async function blurAway(h: Harness) { + (document.activeElement as HTMLElement | null)?.blur(); + await settle(h); +} + +describe('AngularInlineTime (input rehost)', () => { + let h: Harness; + + beforeEach(() => { + h = setup(); + }); + + afterEach(async () => { + await blurAway(h); + }); + + it('renders the committed time localized in a real input', () => { + expect(h.input().value).toBe('09:30'); + }); + + it('Enter commits typed drafts as DB entries anchored on the value own day', async () => { + type(h, '2105'); + press(h, 'Enter'); + + 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' }, + ]); + 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(); // no panel for a clean commit — there is no preview + }); + + it('the parse gate blocks Enter on impossible times', () => { + type(h, '9:75'); + press(h, 'Enter'); + + expect(h.host.saved).toEqual([]); + expect(h.host.field().value()).toBe(at('09:30')); + expect(h.input().getAttribute('aria-invalid')).toBe('true'); + }); + + it('blur with an unreadable draft SNAPS BACK to the baseline', async () => { + type(h, '9:75'); + await blurAway(h); + + 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, side: 'start' }, + ]); + }); + + it('blur with a readable draft COMMITS (navigation never traps)', async () => { + type(h, '2105'); + await blurAway(h); + + 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' }, + ]); + }); + + it('Escape reverts to the session baseline', () => { + type(h, '2105'); + press(h, 'Escape'); + + expect(h.host.field().value()).toBe(at('09:30')); + expect(h.input().value).toBe('09:30'); + 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'; + native.dispatchEvent(new Event('change', { bubbles: true })); + h.fixture.detectChanges(); + + expect(h.host.model()).toBe(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' }, + ]); + expect(h.input().value).toBe('14:45'); + }); + + it('an OS-picker change during a session replaces the draft without committing', () => { + type(h, '9'); + + const native = h.native(); + native.value = '10:15'; + native.dispatchEvent(new Event('change', { bubbles: true })); + h.fixture.detectChanges(); + + // Draft replaced, session still open, nothing committed yet. + expect(h.host.saved).toEqual([]); + expect(h.input().value).toBe('10:15'); + expect(h.host.field().value()).toBe(at('10:15')); // live channel + + press(h, 'Enter'); + expect(savedStarts(h.host.saved)).toEqual([at('10:15')]); + }); + + it('an overflow draft commits onto the anchor day + n', () => { + type(h, '24:30'); + press(h, 'Enter'); + + 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, + side: 'start', + }, + ]); + }); + + it('a pasted FULL ISO datetime is an explicit instant — its own day, no anchor', () => { + type(h, '2026-07-25T08:00'); + + // Live channel already carries the full instant. + expect(h.host.field().value()).toBe(composeDbEntry('2026-07-25', '08:00')); + + press(h, 'Enter'); + + expect(h.host.sessions).toEqual([ + { + value: composeDbEntry('2026-07-25', '08:00'), + changed: true, + dayOverflow: 0, + explicitDay: true, + side: 'start', + }, + ]); + expect(localDayOf(h.host.model())).toBe('2026-07-25'); // the day CAME ALONG + }); + + it('a time typed into an EMPTY field anchors on the reference clock day', () => { + h.host.model.set(null); + h.fixture.detectChanges(); + + type(h, '8'); + press(h, 'Enter'); + + const value = h.host.model(); + 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: TimeSavedDetails[] = []; + 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); + }); +}); + +// ============================================================================= +// 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(); + }); +}); + + +// ============================================================================= +// 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/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 new file mode 100644 index 0000000..0b00c9f --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -0,0 +1,942 @@ +import { + Component, + ElementRef, + computed, + contentChild, + inject, + input, + model, + output, + signal, + viewChild, + type Signal, + 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 { + EditablePrefix, + EditableSuffix, + BubbleMenu, + EditableClearButton, + type BubbleMenuSide, +} from 'angular-inline-select'; +import { + parseTime, + parseTimeDraft, + formatWallClock, + inferTimeShape, + toInternalTimeRange, + echoTimeShape, + timeValuesEqual, + type InlineTimeValue, + type TimeDraft, + type TimeSavedDetails, + type TimeValueShape, + type InternalTimeRange, +} from './time-codec'; +import { INLINE_TIME_DAY_OFFSET } from './day-offset'; +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, + diffDbEntrySeconds, + localDayDiff, + localDayOf, + localTimeOf, + parseDbEntryDraft, + rollDbEntryForward, + toDateTime, + todayIn, + type DbDateTime, +} from '../datetime/db-entry'; +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, in the consumer's bound shape. */ + value: InlineTimeValue; + /** Whether the settled value differs from the session baseline. */ + changed: boolean; + /** + * The day over-count the user TYPED via overflow hours (`'24:30'` → 1, + * `'240:30'` → 10) — already applied to `value`, surfaced so a range + * group can anchor it on the start's day instead of this field's own. + */ + dayOverflow: number; + /** + * The commit CARRIED ITS OWN DAY (a pasted full ISO datetime — the + * decomposition gesture): a range group must take the instant as-is and + * 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 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 + * 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'`); overflow hours + * 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 + * is the primary road everywhere. While a session is open, a pick + * replaces the draft; idle, it commits immediately. + */ +@Component({ + selector: 'angular-inline-time', + 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 { + #document = inject(DOCUMENT); + + /** + * 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()); + + /** + * 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); + readonly = input(false); + required = input(false); + touched = input(false); + invalid = input(false); + 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); + + protected effectiveEndPlaceholder = computed(() => { + const explicit = this.endPlaceholder(); + if (explicit !== undefined) return explicit; + 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); + + /** + * 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(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); + + /** + * T6 — the DISPLAY ZONE (IANA id): which zone's wall clock the field + * 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 }); + + readonly effectiveZone = computed(() => this.zone() ?? this.#zoneDefault?.()); + + /** Granularity of the native picker, in seconds (forwarded to its `step`). */ + step = input(60); + + /** + * T3 — native picker bounds, forwarded to the OS input's `min`/`max` + * (`'HH:mm'`). Named picker* because signal forms reserves `min`/`max` + * beside `[formField]` — and they bound the PICKER, not the codec. + */ + pickerMin = input(undefined); + pickerMax = input(undefined); + + /** + * 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); + + /** Affix template passthrough (composition channel + content sugar). */ + 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 consumerSuffixTpl = computed( + () => this.suffixTemplate() ?? this.contentSuffix()?.templateRef, + ); + + /** + * Day-overflow badge feed — provided on this element by the range + * group's `rangeEnd` role directive; absent (0) everywhere else. + */ + #groupDayOffset = inject(INLINE_TIME_DAY_OFFSET, { optional: true, self: true }); + + /** + * 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 }); + + /** Public: the composed disabled verdict (own input + group-fed state). */ + readonly effectiveDisabled = computed( + () => this.disabled() || (this.#leafState?.disabled() ?? false), + ); + protected effectiveReadonly = computed( + () => this.readonly() || (this.#leafState?.readonly() ?? false), + ); + protected effectiveTouched = computed( + () => this.touched() || (this.#leafState?.touched() ?? false), + ); + protected effectiveInvalid = computed( + () => this.invalid() || (this.#leafState?.invalid() ?? false), + ); + + /** + * 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`, + ); + + /** Form Value Contract: touch — emitted whenever a session settles. */ + touch = 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(); + + /** + * 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. */ + editing = model(false); + + #shapeMemory = makeShapeMemory({ + value: this.value, + infer: inferTimeShape, + ranged: this.ranged, + singleShape: 'single', + rangeShape: 'range', + }); + + /** The effective shape: last seen, or the `ranged` cold-start default. */ + readonly shape = this.#shapeMemory.shape; + + /** Object shapes render the start–end input pair; a string renders one field. */ + protected twoFields = this.#shapeMemory.twoFields; + + /** One canonical internal model, always: per-side DB-entry instants. */ + readonly internalRange = computed(() => toInternalTimeRange(this.value())); + + /** 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); + } + + /** + * 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'); + readonly #endSide = this.#makeSide('end'); + + #side(key: SideKey): TimeSide { + return key === 'start' ? this.#startSide : this.#endSide; + } + + #makeSide(key: SideKey): TimeSide { + const committed = computed(() => this.internalRange()[key]); + const display = computed(() => this.#wallClockOf(committed())); + 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())), + }; + } + + 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; + + /** + * 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`. + */ + #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) — 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(() => this.#side(this.focusTarget() ?? 'start').explicit()); + + /** The parse gate: whether the focused draft fails the codec. Public for consumers. */ + readonly parseFailed = computed( + () => this.parsedDraft() === undefined && this.explicitDraft() === undefined, + ); + + #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 — 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; + }); + + /** 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.editing() && !this.#panelDismissed() && 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 = this.#chrome.revertFlash; + protected revertNotice = this.#chrome.revertNotice; + + protected startInput = viewChild>('startInput'); + protected endInput = viewChild>('endInput'); + protected nativeInput = viewChild.required>('nativeInput'); + protected panelRef = viewChild>('panel'); + + constructor() { + 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(); + }, + }); + } + + // -- 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 ariaLabelOf(key: SideKey): string { + return sideAriaLabel(this.ariaLabel() ?? 'Time', key, this.twoFields()); + } + + protected ariaInvalidOf(key: SideKey): boolean { + const side = this.#side(key); + return this.errorsVisible() || (side.open() && side.saveAttempted() && this.parseFailed()); + } + + // -- The live channel ----------------------------------------------------------- + + #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); + side.open.set(true); + } + + /** + * 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, + }; + } + + /** 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 resolved = this.#resolveDraft(key); + if (resolved === undefined) return; + + const current = this.internalRange(); + if (key === 'start') this.#writeInstants(resolved.instant, current.end); + else this.#writeInstants(current.start, resolved.instant); + } + + // -- 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/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: 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() { + this.#chrome.scheduleFocusSettle(() => this.#onFocusSettled()); + } + + #onFocusSettled() { + const active = this.#document.activeElement; + 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; + + // 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) -------------- + + /** + * 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 TimeSide.dirty). + const untouched = !options.revert && !side.dirty; + + let dayOverflow = 0; + let explicitDay = false; + let snappedBack = false; + + if (untouched) { + // Nothing to derive — the value stands. + } else if (options.revert) { + if (!timeValuesEqual(side.baselineValue, this.value())) this.value.set(side.baselineValue); + } else { + 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 { + dayOverflow = resolved.days; + explicitDay = resolved.explicit; + this.#reconcile(key, resolved.instant, resolved.explicit); + } + } + + const changed = !untouched && !timeValuesEqual(this.value(), side.baselineValue); + side.dirty = false; + + if (options.keepOpen) { + side.baselineValue = this.value(); + side.anchorDay = this.#anchorDay(); + side.draft.set(side.display()); + side.saveAttempted.set(false); + } else { + side.open.set(false); + side.saveAttempted.set(false); + } + + if (snappedBack) this.#chrome.announceRevert(key, this.#side(key).display()); + + this.#selfTouched.set(true); + this.touch.emit(); + + const value = this.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) { + switch (event.key) { + case 'Enter': { + event.preventDefault(); + if (this.parseFailed()) { + // The parse gate: the user ASKED for a commit — block and say why. + this.#side(key).saveAttempted.set(true); + return; + } + + this.#settle(key, { keepOpen: true }); + this.#panelDismissed.set(true); + return; + } + case 'Escape': { + event.preventDefault(); + event.stopPropagation(); + this.#settle(key, { revert: true, keepOpen: true }); + this.#panelDismissed.set(true); + return; + } + } + } + + /** + * 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; + this.#panelDismissed.update((dismissed) => !dismissed); + } + + // -- The OS picker --------------------------------------------------------------------- + + /** + * 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(key: SideKey) { + if (!this.native() || this.effectiveDisabled() || this.effectiveReadonly()) return; + this.#showOsPicker(key); + } + + /** + * 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 = localTimeOf(this.#side(key).committed(), this.effectiveZone()) ?? ''; + + if (typeof native.showPicker !== 'function') { + native.focus(); + return; + } + + try { + native.showPicker(); + } catch { + native.focus(); + } + } + + /** + * 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; + + // 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 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; + } + + // 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)) { + this.#emitSavedModel(); + this.saved.emit({ + value: this.value(), + changed: true, + dayOverflow: 0, + explicitDay: false, + side: key, + }); + } + } + + // -- Clear affordance (idle hover bubble; per-side for a range) -------------- + + #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 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(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(); + + const value = this.value(); + const changed = !timeValuesEqual(value, before); + if (changed) this.#emitSavedModel(); + 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.#inputOf('start')?.focus(options); + } + + /** + * Presentation-only rollback (the MatInput precedent): an open draft is + * discarded back to the baseline with no `touch`, no `saved`, no focus + * stealing. + */ + reset() { + 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); + } + + this.#panelDismissed.set(true); + } +} diff --git a/projects/angular-inline-select/temporal/src/angular-inline-time/day-offset.ts b/projects/angular-inline-select/temporal/src/angular-inline-time/day-offset.ts new file mode 100644 index 0000000..d8ecd95 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/day-offset.ts @@ -0,0 +1,15 @@ +import { InjectionToken, type Signal } from '@angular/core'; + +/** + * Day-overflow feed for the time control's `+n` badge (the airline + * arrival-time pattern): when provided on the control's element — the + * range group's `rangeEnd` role directive does this — the control renders + * a `+1`-style suffix badge whenever the signal is positive. + * + * Presentation-only by design: the offset is DERIVED state (from the + * group's composed datetimes), never part of the draft or the `'HH:mm'` + * value. + */ +export const INLINE_TIME_DAY_OFFSET = new InjectionToken>( + 'INLINE_TIME_DAY_OFFSET', +); 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 new file mode 100644 index 0000000..a929c16 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/time-codec.ts @@ -0,0 +1,261 @@ +/** + * 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 { 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; + 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 { + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return undefined; + return `${pad(hours)}:${pad(minutes)}`; +} + +/** + * A parsed time draft: the wall-clock time plus the DAY OVERFLOW typed as + * hours beyond 23 — `'24:30'` → `{ time: '00:30', days: 1 }`, `'240:30'` → + * `{ time: '00:30', days: 10 }`. Plain times carry `days: 0`. + */ +export interface TimeDraft { + time: WallClockTime; + days: number; +} + +function draftIfValid(hours: number, minutes: number): TimeDraft | undefined { + if (hours < 0 || minutes < 0 || minutes > 59) return undefined; + + const time = timeIfValid(hours % 24, minutes); + 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'` — 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, + 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] = 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) — + // 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)) { + if (trimmed.length <= 2) { + const time = timeIfValid(Number(trimmed), 0); + return applyMeridiem(time === undefined ? undefined : { time, days: 0 }); + } + + return applyMeridiem(draftIfValid(Number(trimmed.slice(0, -2)), Number(trimmed.slice(-2)))); + } + + return undefined; +} + +/** + * Overflow-free convenience over `parseTimeDraft`: plain `'HH:mm'` or the + * parse gate — overflow drafts are UNDEFINED here (callers that can't + * carry the day over-count must reject them). + */ +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; +} + +// 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 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 new file mode 100644 index 0000000..361bc5c --- /dev/null +++ b/projects/angular-inline-select/temporal/src/datetime/db-entry.ts @@ -0,0 +1,173 @@ +import { DateTime } from 'luxon'; + +/** + * The DB-entry core — the sandbox mirror of iusta's `core/datetime` + * (`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 wall-clock strings, in the + * DISPLAY ZONE + * duration = seconds + * + * T6 — THE ZONE IS CONFIGURATION, THE VALUE IS NOT: every "local" helper + * takes an optional trailing IANA `zone`. Omitted, the machine zone reads + * (the pre-T6 behavior, byte-identical); given, days and wall-clocks are + * that zone's (iusta's `ServerSideDatetimeConfiguration` analogue — see + * `INLINE_TEMPORAL_ZONE`). Instant math (shift/diff) is zone-free. + * + * Luxon itself is CONTAINED here (and consumed via 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`. + */ + +/** `'2026-07-20T19:00:00.000Z'` — `datetime.toUTC().toISO()`, SQL-friendly. */ +export type DbDateTime = string; + +/** An IANA zone id (`'Europe/Berlin'`); `undefined` = the machine zone. */ +export type ZoneId = string | undefined; + +/** The Luxon bridge, inbound: a DB entry (or any ISO 8601) in the display zone. */ +export function toDateTime(value: DbDateTime | null, zone?: ZoneId): DateTime | null { + if (value === null || value === '') return null; + + const parsed = zone ? DateTime.fromISO(value, { zone }) : DateTime.fromISO(value); + return parsed.isValid ? parsed : null; +} + +/** The Luxon bridge, outbound — THE house function (iusta naming wins for utils). */ +export function toDBEntry(dateTime: DateTime): DbDateTime { + return dateTime.toUTC().toISO()!; +} + +/** 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 dateToDbEntry(date: Date): DbDateTime { + return toDBEntry(DateTime.fromJSDate(date)); +} + +/** + * A FULL ISO datetime typed/pasted as a draft (`'2026-07-21T21:00'`, + * `'2026-07-21 21:00'`, with or without seconds/zone) — the decomposition + * trigger. A string WITHOUT an offset reads in the display zone. Anything + * else (including bare dates and bare times) is `undefined`: those belong + * to the field codecs. + */ +export function parseDbEntryDraft(raw: string, zone?: ZoneId): DbDateTime | undefined { + const trimmed = raw.trim(); + if (!/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) return undefined; + + const iso = trimmed.replace(' ', 'T'); + const parsed = zone ? DateTime.fromISO(iso, { zone }) : DateTime.fromISO(iso); + return parsed.isValid ? toDBEntry(parsed) : undefined; +} + +/** The display-zone calendar day of a DB entry: `'yyyy-MM-dd'`. */ +export function localDayOf(value: DbDateTime | null, zone?: ZoneId): string | null { + return toDateTime(value, zone)?.toFormat('yyyy-MM-dd') ?? null; +} + +/** The display-zone wall-clock time of a DB entry: `'HH:mm'`. */ +export function localTimeOf(value: DbDateTime | null, zone?: ZoneId): string | null { + return toDateTime(value, zone)?.toFormat('HH:mm') ?? null; +} + +function dayIn(day: string, zone?: ZoneId): DateTime { + return zone ? DateTime.fromISO(day, { zone }) : DateTime.fromISO(day); +} + +/** Display-zone midnight of a `'yyyy-MM-dd'` day, as a DB entry (`startOf('day')`). */ +export function dayToDbEntry(day: string, zone?: ZoneId): DbDateTime { + 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 toDBEntry(dayIn(day, zone).endOf('day')); +} + +/** + * Rebuilds a DB entry from a display-zone day + wall-clock time — the + * composition every commit runs (anchor day + typed time, or typed day + + * preserved time). + */ +export function composeDbEntry(day: string, time: string, zone?: ZoneId): DbDateTime { + // `'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 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 : toDBEntry(dateTime.plus({ seconds })); +} + +/** Whole seconds between two DB entries (`induceFromTimeRange`'s primitive) — zone-free. */ +export function diffDbEntrySeconds(start: DbDateTime, end: DbDateTime): number | null { + const from = toDateTime(start); + const to = toDateTime(end); + if (from === null || to === null) return null; + + 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 toDBEntry(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. + */ +export function localDayDiff(start: DbDateTime, end: DbDateTime, zone?: ZoneId): number | null { + const from = toDateTime(start, zone); + const to = toDateTime(end, zone); + if (from === null || to === null) return null; + + return Math.round(to.startOf('day').diff(from.startOf('day'), 'days').days); +} + +/** Moves `value` onto the display-zone day of `day`, preserving its wall-clock time. */ +export function moveDbEntryToDay(value: DbDateTime, day: string, zone?: ZoneId): DbDateTime { + const time = localTimeOf(value, zone); + return time === null ? value : composeDbEntry(day, time, zone); +} + +/** Shifts a `'yyyy-MM-dd'` day by whole calendar days — plain day-string math, zone-free. */ +export function addLocalDays(day: string, days: number): string { + return DateTime.fromISO(day).plus({ days }).toFormat('yyyy-MM-dd'); +} + +/** Today's calendar day in the display zone, from a reference clock. */ +export function todayIn(now: Date, zone?: ZoneId): string { + const dateTime = zone ? DateTime.fromJSDate(now, { zone }) : DateTime.fromJSDate(now); + return dateTime.toFormat('yyyy-MM-dd'); +} diff --git a/projects/angular-inline-select/temporal/src/datetime/zone.ts b/projects/angular-inline-select/temporal/src/datetime/zone.ts new file mode 100644 index 0000000..a18a69b --- /dev/null +++ b/projects/angular-inline-select/temporal/src/datetime/zone.ts @@ -0,0 +1,24 @@ +import { InjectionToken, signal, type Provider, type Signal } from '@angular/core'; + +import type { ZoneId } from './db-entry'; + +/** + * T6 — the app-wide DISPLAY ZONE default (iusta's + * `ServerSideDatetimeConfiguration` analogue): every temporal control and + * the range group read it as the fallback behind their own `zone` input. + * Absent, wall-clocks and calendar days read in the MACHINE zone — the + * pre-T6 behavior. + * + * A `Signal` on purpose: a server-pushed configuration change re-renders + * every display without touching a single value — values are UTC DB + * entries and never contain the zone. + */ +export const INLINE_TEMPORAL_ZONE = new InjectionToken>('INLINE_TEMPORAL_ZONE'); + +/** `provideInlineTemporalZone('Europe/Berlin')` — or hand in a live signal. */ +export function provideInlineTemporalZone(zone: string | Signal): Provider { + return { + provide: INLINE_TEMPORAL_ZONE, + useValue: typeof zone === 'string' ? signal(zone).asReadonly() : zone, + }; +} diff --git a/projects/angular-inline-select/temporal/src/leaf-state.ts b/projects/angular-inline-select/temporal/src/leaf-state.ts new file mode 100644 index 0000000..7fe2f7a --- /dev/null +++ b/projects/angular-inline-select/temporal/src/leaf-state.ts @@ -0,0 +1,33 @@ +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 — + * provided per-leaf by the role directives (the day-offset pattern), so a + * standalone control never sees it and stays fully decoupled. Leaves MERGE + * these with their own inputs via computeds: no effects, no writes, pure + * pull. + */ +export interface TemporalLeafState { + disabled: Signal; + readonly: Signal; + touched: Signal; + invalid: Signal; + /** Group-level errors routed to THIS leaf (ordering errors → the end field). */ + errors: Signal; +} + +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/public-api.ts b/projects/angular-inline-select/temporal/src/public-api.ts new file mode 100644 index 0000000..6ddd4d6 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/public-api.ts @@ -0,0 +1,19 @@ +/* + * Public API Surface of angular-inline-select/temporal + * + * Secondary entry point: apps that never import it carry zero + * date/time/duration bytes — the core barrel stays temporal-free. + */ + +export * from './datetime/db-entry'; +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/calendar/calendar'; +export * from './angular-inline-time/angular-inline-time'; +export * from './angular-inline-time/time-codec'; +export * from './angular-inline-time/day-offset'; +export * from './angular-inline-duration/angular-inline-duration'; +export * from './angular-inline-duration/duration-codec'; +export * from './range-group/range-group'; 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 new file mode 100644 index 0000000..6ed1421 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.spec.ts @@ -0,0 +1,775 @@ +import { Component, signal, viewChild, type Type } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormField, form } from '@angular/forms/signals'; + +import { AngularInlineDate } from '../angular-inline-date/angular-inline-date'; +import { AngularInlineTime } from '../angular-inline-time/angular-inline-time'; +import { AngularInlineDuration } from '../angular-inline-duration/angular-inline-duration'; +import { composeDbEntry, dayToDbEntry, dayEndToDbEntry } from '../datetime/db-entry'; +import { + DateTimeRangeGroup, + RangeDay, + RangeEndDay, + RangeStart, + RangeEnd, + RangeTimes, + RangeLength, + createTemporalRangeGroup, + type ComposedDateRange, + type ComposedTimeRange, + type TemporalRangeGroup, + type TemporalRangeValue, +} from './range-group'; + +const NOW = new Date(2026, 6, 21); + +// Every value is a UTC ISO DB entry; expectations compose through the same +// helpers, so the specs are TZ-independent. The seed is an OVERNIGHT stay: +// 21 Jul 21:00 → 22 Jul 06:00, 9 h — the +1 lives IN the end value. +const at = (day: string, time: string) => composeDbEntry(day, time); + +// The quintet fixture (the maximal form): stay · start · end · length · end day. +// The end-day leaf appends LAST so the older tests' input indices survive. +@Component({ + imports: [ + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + DateTimeRangeGroup, + RangeDay, + RangeEndDay, + RangeStart, + RangeEnd, + RangeLength, + ], + template: ` +
+ + + + + +
+ `, +}) +class QuartetHost { + group = viewChild.required(DateTimeRangeGroup); + + day = signal(dayToDbEntry('2026-07-21')); + 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)[] = []; + durations: (number | null)[] = []; + + 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; + inputs: () => HTMLInputElement[]; +} + +function setup(): Harness { + const fixture = TestBed.createComponent(QuartetHost); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + group: () => fixture.componentInstance.group(), + inputs: () => [...fixture.nativeElement.querySelectorAll(LEAF_INPUTS)] as HTMLInputElement[], + }; +} + +/** + * 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 input = h.inputs()[index]; + input.focus(); + h.fixture.detectChanges(); + + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + h.fixture.detectChanges(); + + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + h.fixture.detectChanges(); + + input.blur(); + await new Promise((resolve) => setTimeout(resolve)); + h.fixture.detectChanges(); +} + +// 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; + + beforeEach(() => { + h = setup(); + }); + + it('registers the quartet and composes its state', () => { + expect(h.group().day()).toBe('2026-07-21'); + expect(h.group().start()).toBe(at('2026-07-21', '21:00')); + expect(h.group().end()).toBe(at('2026-07-22', '06:00')); + expect(h.group().length()).toBe(32_400); + }); + + it('the overnight seed wears the +1 badge — intrinsic to the values', () => { + expect(h.group().endDayOffset()).toBe(1); + + const badges = [...h.fixture.nativeElement.querySelectorAll('.time-day-badge')]; + expect(badges.map((badge) => badge.textContent?.trim())).toEqual(['+1']); + }); + + it('a typed end is wall-clock intent: 23:30 lands the same evening, badge drops', async () => { + await commitInto(h, END, '23:30'); + + expect(h.host.end()).toBe(at('2026-07-21', '23:30')); + expect(h.host.length()).toBe(2.5 * 3600); + expect(h.group().endDayOffset()).toBe(0); + expect(h.fixture.nativeElement.querySelector('.time-day-badge')).toBeNull(); + }); + + it('an end at or before the start rolls to the next day (+24 h)', async () => { + await commitInto(h, END, '21:00'); + + expect(h.host.end()).toBe(at('2026-07-22', '21:00')); + expect(h.host.length()).toBe(24 * 3600); + expect(h.group().endDayOffset()).toBe(1); + }); + + it('typed overflow hours ARE the over-count, anchored on the start day', async () => { + // 24:30 = next day 00:30 — over computed from the typed hours. + await commitInto(h, END, '24:30'); + + expect(h.host.end()).toBe(at('2026-07-22', '00:30')); + expect(h.host.length()).toBe(3.5 * 3600); // 21:00 → +1 00:30 + expect(h.group().endDayOffset()).toBe(1); + + // 240:30 = ten days out at 00:30. + await commitInto(h, END, '240:30'); + + expect(h.host.end()).toBe(at('2026-07-31', '00:30')); + expect(h.group().endDayOffset()).toBe(10); + expect( + h.fixture.nativeElement.querySelector('.time-day-badge')?.textContent?.trim(), + ).toBe('+10'); + }); + + it('committing a duration MOVES the end instant; multi-day lengths grow the badge', async () => { + await commitInto(h, LENGTH, '2:00'); + expect(h.host.end()).toBe(at('2026-07-21', '23:00')); + expect(h.group().endDayOffset()).toBe(0); + + await commitInto(h, LENGTH, '30h'); + expect(h.host.end()).toBe(at('2026-07-23', '03:00')); // 21:00 + 30 h + expect(h.group().endDayOffset()).toBe(2); + }); + + it('committing a start keeps the end instant and follows with the duration', async () => { + await commitInto(h, START, '22:00'); + + expect(h.host.start()).toBe(at('2026-07-21', '22:00')); + expect(h.host.end()).toBe(at('2026-07-22', '06:00')); + expect(h.host.length()).toBe(8 * 3600); + expect(h.group().endDayOffset()).toBe(1); + }); + + it('day edits shift BOTH instants, preserving wall-clock times and the over-count', async () => { + await commitInto(h, 0, '24.7.2026'); + + expect(h.host.day()).toBe(dayToDbEntry('2026-07-24')); + expect(h.host.start()).toBe(at('2026-07-24', '21:00')); + expect(h.host.end()).toBe(at('2026-07-25', '06:00')); + expect(h.host.length()).toBe(32_400); + }); + + it('composes the date range with the over-count applied', () => { + expect(h.group().dateRange()).toEqual({ + start: dayToDbEntry('2026-07-21'), + end: dayEndToDbEntry('2026-07-22'), + }); + expect(h.group().timeRange()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-22', '06:00'), + }); + }); + + it('emits the composed streams per commit — only the ones that changed', async () => { + // End 23:30: same-day now — every stream moves. + await commitInto(h, END, '23:30'); + + expect(h.host.dateRanges).toEqual([ + { start: dayToDbEntry('2026-07-21'), end: dayEndToDbEntry('2026-07-21') }, + ]); + expect(h.host.timeRanges).toEqual([ + { start: at('2026-07-21', '21:00'), end: at('2026-07-21', '23:30') }, + ]); + expect(h.host.durations).toEqual([2.5 * 3600]); + + // Length 30h: end moves two days out. + await commitInto(h, LENGTH, '30h'); + + expect(h.host.dateRanges[1]).toEqual({ + start: dayToDbEntry('2026-07-21'), + end: dayEndToDbEntry('2026-07-23'), + }); + expect(h.host.timeRanges[1]).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-23', '03:00'), + }); + expect(h.host.durations[1]).toBe(30 * 3600); + }); + + it('day commits shift the instants: date and time ranges emit, duration stays silent', async () => { + await commitInto(h, 0, '24.7.2026'); + + expect(h.host.dateRanges).toEqual([ + { start: dayToDbEntry('2026-07-24'), end: dayEndToDbEntry('2026-07-25') }, + ]); + expect(h.host.timeRanges).toEqual([ + { start: at('2026-07-24', '21:00'), end: at('2026-07-25', '06:00') }, + ]); + 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] + .componentInstance as AngularInlineTime; + endControl.saved.subscribe(() => endSessions++); + + await commitInto(h, LENGTH, '3:00'); + + expect(h.host.end()).toBe(at('2026-07-22', '00:00')); + expect(endSessions).toBe(0); + }); +}); + +// ============================================================================= +// T5b — the group IS the form control +// ============================================================================= + +@Component({ + imports: [ + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + FormField, + ], + template: ` +
+ + + + +
+ `, +}) +class BoundGroupHost { + 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; +} + +// The consumer's model has NO duration key — the shape-echo case. +@Component({ + imports: [ + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + DateTimeRangeGroup, + RangeDay, + RangeStart, + RangeEnd, + RangeLength, + FormField, + ], + template: ` +
+ + + + +
+ `, +}) +class RangeOnlyHost { + model = signal({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-22', '06:00'), + }); + field = form(this.model); + + now = () => NOW; +} + +// Mixed mode: a field-bound leaf inside a field-bound group — must throw. +@Component({ + imports: [AngularInlineTime, DateTimeRangeGroup, RangeStart, FormField], + template: ` +
+ +
+ `, +}) +class MixedModeHost { + model = signal(null); + field = form(this.model); + + leafModel = signal(null); + leafField = form(this.leafModel); +} + +function boundSetup(type: Type) { + const fixture = TestBed.createComponent(type); + fixture.detectChanges(); + + return { + fixture, + host: fixture.componentInstance, + inputs: () => + [...fixture.nativeElement.querySelectorAll(LEAF_INPUTS)] as HTMLInputElement[], + }; +} + +async function commitIntoBound( + fixture: ComponentFixture, + inputs: () => HTMLInputElement[], + index: number, + text: string, +) { + const input = inputs()[index]; + input.focus(); + fixture.detectChanges(); + + input.value = text; + input.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), + ); + fixture.detectChanges(); + + input.blur(); + await new Promise((resolve) => setTimeout(resolve)); + fixture.detectChanges(); +} + +describe('DateTimeRangeGroup as FormValueControl (T5b)', () => { + it('the bound value flows DOWN: unbound leaves render the composed model', async () => { + const h = boundSetup(BoundGroupHost); + 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 leaf commit flows UP: one composed model write, one savedModelChange', async () => { + const h = boundSetup(BoundGroupHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, h.inputs, END, '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, + }); + expect(h.host.commits).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 end in the SAME composed write', async () => { + const h = boundSetup(BoundGroupHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + await commitIntoBound(h.fixture, h.inputs, LENGTH, '2:00'); + + expect(h.host.model()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:00'), + duration: 2 * 3600, + }); + }); + + it('the form value flows DOWN on external writes', async () => { + const h = boundSetup(BoundGroupHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + h.host.model.set({ + start: at('2026-08-01', '08:00'), + end: at('2026-08-01', '12:00'), + duration: 4 * 3600, + }); + h.fixture.detectChanges(); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + expect(h.inputs().map((input) => input.value)).toEqual([ + 'Aug 1, 2026', + '08:00', + '12:00', + '04:00', + ]); + }); + + it('shape-echo: a {start, end} binding never grows a duration key', async () => { + const h = boundSetup(RangeOnlyHost); + await h.fixture.whenStable(); + h.fixture.detectChanges(); + + // The duration leaf still DISPLAYS the derived length… + expect(h.inputs()[LENGTH].value).toBe('09:00'); + + await commitIntoBound(h.fixture, h.inputs, END, '23:30'); + + // …but the model echoes the bound shape: no duration key. + expect(h.host.model()).toEqual({ + start: at('2026-07-21', '21:00'), + end: at('2026-07-21', '23:30'), + }); + }); + + it('mixed mode throws: a field-bound leaf inside a field-bound group', () => { + expect(() => { + const fixture = TestBed.createComponent(MixedModeHost); + fixture.detectChanges(); + }).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', + ]); + }); +}); + +// ============================================================================= +// 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 new file mode 100644 index 0000000..c6ef776 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/range-group/range-group.ts @@ -0,0 +1,1119 @@ +import { + Directive, + InjectionToken, + Injector, + computed, + effect, + inject, + input, + linkedSignal, + model, + output, + signal, + untracked, + type Signal, + type WritableSignal, +} from '@angular/core'; +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 { INLINE_TIME_DAY_OFFSET } from '../angular-inline-time/day-offset'; +import { AngularInlineDuration } from '../angular-inline-duration/angular-inline-duration'; +import { + INLINE_TEMPORAL_BUBBLE_SIDE, + INLINE_TEMPORAL_LEAF_STATE, + type TemporalLeafState, +} from '../leaf-state'; +import { INLINE_TEMPORAL_ZONE } from '../datetime/zone'; +import { + addLocalDays, + composeDbEntry, + dayToDbEntry, + dayEndToDbEntry, + diffDbEntrySeconds, + localDayDiff, + localDayOf, + localTimeOf, + rollDbEntryForward, + shiftDbEntry, + type DbDateTime, +} from '../datetime/db-entry'; + +/** + * The group's composed DATE value: the stay's day boundaries as DB entries + * (`startOf('day')` … `endOf('day')`, over-count intrinsic to the end). + */ +export interface ComposedDateRange { + start: DbDateTime; + end: DbDateTime; +} + +/** The group's composed TIME value: both endpoint instants as DB entries. */ +export interface ComposedTimeRange { + start: DbDateTime; + end: DbDateTime; +} + +function sameRange( + a: { start: string; end: string } | null, + b: { start: string; end: string } | null, +): boolean { + if (a === null || b === null) return a === b; + return a.start === b.start && a.end === b.end; +} + +/** + * The group's OWN form value — the domain shape the server speaks + * (`DomainResult['model']` without the redundant `date`): DB entries + + * seconds. `duration` is SHAPE-ECHOED: bind `{ start, end }` and it stays + * internal-only; it is always computed inside and can never disagree with + * the range. + */ +export interface TemporalRangeValue { + start: DbDateTime | null; + end: DbDateTime | null; + duration?: number | null; +} + +function sameTemporalValue( + a: TemporalRangeValue | null, + b: TemporalRangeValue | null, +): boolean { + if (a === null || b === null) return a === b; + return a.start === b.start && a.end === b.end && a.duration === b.duration; +} + +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; +} + +/** + * 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 + * follows the start — the overnight case lands IN the value. + * - Committing a duration MOVES the end (`end = start + duration`). + * - 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, 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. + */ +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). */ + 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). + */ + 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. */ + const day = computed(() => { + const control = dayCtl(); + if (!control) return null; + + const start = toInternalRange(control.value()).start; + return start === null ? null : localDayOf(start, zone()); + }); + + /** 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, 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 }; + }); + + /** + * `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. + */ + const orderingErrors = computed(() => { + const startValue = start(); + const endValue = end(); + // DB entries are fixed-width UTC ISO strings — lexicographic order IS + // instant order. + if (startValue !== null && endValue !== null && endValue < startValue) { + return [{ kind: 'temporal-order', message: 'The end lies before the start.' }]; + } + + return []; + }); + + /** + * The end field's `+n` badge: LOCAL calendar days between the two + * instants — intrinsic to the values now that they carry their days. + */ + const endDayOffset = computed(() => { + const startValue = start(); + if (startValue === null) return 0; + + const endValue = end(); + if (endValue !== null) { + return Math.max(0, localDayDiff(startValue, endValue, zone()) ?? 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. */ + 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, endDayOffset()), zone()), + }; + }); + + /** The composed TIME value: both endpoint instants, or `null` while incomplete. */ + const timeRange = computed(() => { + const startValue = start(); + const endValue = end(); + return startValue !== null && endValue !== null + ? { start: startValue, end: endValue } + : null; + }); + + // -- Write helpers ----------------------------------------------------------- + + function writeStart(next: DbDateTime) { + const control = startCtl(); + if (control && control.value() !== next) control.value.set(next); + + const times = timesCtl(); + if (times && times.internalRange().start !== next) { + times.value.set({ start: next, end: times.internalRange().end }); + } + } + + function writeEnd(next: DbDateTime) { + const control = endCtl(); + if (control && control.value() !== next) control.value.set(next); + + const times = timesCtl(); + if (times && times.internalRange().end !== next) { + times.value.set({ start: times.internalRange().start, end: next }); + } + } + + 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). */ + function pushDown(next: TemporalRangeValue | null) { + const startValue = next?.start ?? null; + const endValue = next?.end ?? null; + const duration = + next === null + ? null + : next.duration !== undefined + ? next.duration + : startValue !== null && endValue !== null + ? diffDbEntrySeconds(startValue, endValue) + : null; + + startCtl()?.value.set(startValue); + endCtl()?.value.set(endValue); + // The ranged pair speaks the object shape — both endpoints in one value. + 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()), + ); + } + + /** + * 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. + */ + 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 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); + } + } + + // `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; + + function emitChanges() { + syncDayLeaves(); + + const date = dateRange(); + const dateChanged = lastDate === undefined || !sameRange(date, lastDate); + lastDate = date; + + const time = timeRange(); + const timeChanged = lastTime === undefined || !sameRange(time, lastTime); + lastTime = time; + + const duration = length(); + const durationChanged = lastLength === undefined || duration !== lastLength; + lastLength = duration; + + if (!dateChanged && !timeChanged && !durationChanged) return; + + // The composite commit: value settles synchronously (the outbound + // 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 laws --------------------------------------------------------------- + + /** Rolls `end` forward by whole LOCAL days until it strictly follows `start`, then induces. */ + function induceFrom(startValue: DbDateTime, endValue: DbDateTime) { + endValue = rollDbEntryForward(startValue, endValue, zone()); + + writeEnd(endValue); + writeLength(diffDbEntrySeconds(startValue, endValue)!); + } + + /** + * The start settled — `induceFromTimeRange`: duration follows from the + * instants as they stand (multi-day ends survive); with no end but a + * duration, the end is filled from `start + duration`. + */ + function startCommitted() { + const startValue = start(); + + if (startValue !== null) { + const endValue = end(); + + if (endValue !== null) { + induceFrom(startValue, endValue); + } else { + const duration = length(); + if (duration !== null) writeEnd(shiftDbEntry(startValue, duration)); + } + } + + emitChanges(); + } + + /** + * The end settled: a typed end time is WALL-CLOCK intent — re-anchor it + * onto the start's own day first, then roll forward while it does not + * follow the start (`23:30` lands the same evening, `06:00` the next + * morning), then induce the duration. A typed OVERFLOW (`'24:30'` → +1, + * `'240:30'` → +10) is an explicit over-count: it anchors on the start's + * day directly. + */ + function endCommitted(dayOverflow = 0, explicitDay = false) { + const startValue = start(); + const endValue = end(); + + 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(startValue, endValue)!; + writeLength(diff > 0 ? diff : null); + } else { + const anchoredDay = addLocalDays(localDayOf(startValue, zone())!, dayOverflow); + induceFrom(startValue, composeDbEntry(anchoredDay, localTimeOf(endValue, zone())!, zone())); + } + } + + 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). + */ + function endDayCommitted() { + const typedDay = endDay(); + const endValue = end(); + + if (typedDay !== null && endValue !== null) { + writeEnd(composeDbEntry(typedDay, localTimeOf(endValue, zone())!, zone())); + + const startValue = start(); + if (startValue !== null) { + const diff = diffDbEntrySeconds(startValue, end()!)!; + writeLength(diff > 0 ? diff : null); + } + } + + emitChanges(); + } + + /** A duration settled — `shiftFromDuration`: the end MOVES (`start + duration`). */ + function lengthCommitted() { + const startValue = start(); + const duration = length(); + if (startValue !== null && duration !== null) writeEnd(shiftDbEntry(startValue, duration)); + + emitChanges(); + } + + /** + * The day settled: shift BOTH instants onto it — wall-clock times and + * the end's day over-count are preserved. + */ + 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 (endValue !== null) { + writeEnd( + composeDbEntry( + addLocalDays(typedDay, offset), + localTimeOf(endValue, zone())!, + zone(), + ), + ); + } + } + + emitChanges(); + } + + // -- Boundary effects ------------------------------------------------------------ + + // 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(); + + if (lastDate === undefined) { + lastDate = date; + lastTime = time; + lastLength = duration; + } + }, + { injector }, + ); + + // 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.`, + ); + } + } + + 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. 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, { optional: true }); + const core = inject(RANGE_ROLE_CORE, { self: true }); + return { + 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() ?? []), + ...(core()?.orderingErrors() ?? []), + ]) + : NO_ERRORS, + }; + }, + }; +} + +/** Whether THIS leaf element carries its own `[formField]` (legacy per-leaf mode). */ +const leafHasOwnField = () => inject(FormField, { optional: true, self: true }) !== null; + +/** + * 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: [ + provideRoleCore(), + provideLeafState(false), + { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }, + ], +}) +export class RangeDay { + rangeDay = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineDate); + 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) this.resolvedCore()?.dayCommitted(); + }); + } +} + +/** + * 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: [ + provideRoleCore(), + provideLeafState(false), + { provide: INLINE_TEMPORAL_BUBBLE_SIDE, useValue: 'start' }, + ], +}) +export class RangeStart { + rangeStart = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineTime); + 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) this.resolvedCore()?.startCommitted(); + }); + } +} + +/** + * 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: () => { + const core = inject(RANGE_ROLE_CORE, { self: true }); + return computed(() => core()?.endDayOffset() ?? 0); + }, + }, + ], +}) +export class RangeEnd { + rangeEnd = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineTime); + 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) this.resolvedCore()?.endCommitted(session.dayOverflow, session.explicitDay); + }); + } +} + +/** + * Marks ONE ranged time control carrying BOTH endpoints: + * `` (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 + * 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: [provideRoleCore(), provideLeafState(true)], +}) +export class RangeTimes { + rangeTimes = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineTime); + 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; + 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; 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: [provideRoleCore(), provideLeafState(true)], +}) +export class RangeEndDay { + rangeEndDay = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineDate); + 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) this.resolvedCore()?.endDayCommitted(); + }); + } +} + +/** + * Marks the group's duration (DI via the bare attribute, a headless group + * by reference — see `RangeDay`). + */ +@Directive({ + selector: 'angular-inline-duration[rangeLength]', + providers: [provideRoleCore(), provideLeafState(false)], +}) +export class RangeLength { + rangeLength = input(''); + + readonly resolvedCore: Signal; + + constructor() { + const control = inject(AngularInlineDuration); + 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) this.resolvedCore()?.lengthCommitted(); + }); + } +} 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/angular-inline-select/tsconfig.lib.json b/projects/angular-inline-select/tsconfig.lib.json new file mode 100644 index 0000000..c13ded4 --- /dev/null +++ b/projects/angular-inline-select/tsconfig.lib.json @@ -0,0 +1,27 @@ +/* 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", + "phone/src/**/*.ts", + "json/src/**/*.ts", + "temporal/src/**/*.ts", + "temporal-mat/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..bc396fc --- /dev/null +++ b/projects/angular-inline-select/tsconfig.spec.json @@ -0,0 +1,29 @@ +/* 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", + "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", + "temporal-mat/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 0000000..57614f9 Binary files /dev/null and b/projects/app/public/favicon.ico differ 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..c41d4ec --- /dev/null +++ b/projects/app/src/app/app.html @@ -0,0 +1,105 @@ + + + + + +
+ +
+ + +
+
+ + + + + @for (sect of navSections; track sect.heading) { +

{{ sect.heading }}

+ @for (item of sect.items; track item.link) { + {{ item.label }} + } + } +
+
+ + + + @if (showSectionTabs()) { + + } + + + + + +
diff --git a/projects/app/src/app/app.routes.ts b/projects/app/src/app/app.routes.ts new file mode 100644 index 0000000..397a555 --- /dev/null +++ b/projects/app/src/app/app.routes.ts @@ -0,0 +1,88 @@ +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', + children: [ + { + path: '', + loadComponent: () => + import('./pages/text-playground/text-playground').then((m) => m.TextPlayground), + }, + ...docChildren('text'), + ], + }, + { + path: 'number', + children: [ + { + path: '', + loadComponent: () => + import('./pages/number-playground/number-playground').then((m) => m.NumberPlayground), + }, + ...docChildren('number'), + ], + }, + { + path: 'phone', + children: [ + { + path: '', + loadComponent: () => + import('./pages/phone-playground/phone-playground').then((m) => m.PhonePlayground), + }, + ...docChildren('phone'), + ], + }, + { + path: 'temporal', + children: [ + { + path: '', + loadComponent: () => + import('./pages/temporal-playground/temporal-playground').then( + (m) => m.TemporalPlayground, + ), + }, + ...docChildren('temporal'), + ], + }, + { + path: 'json', + children: [ + { + path: '', + loadComponent: () => + import('./pages/json-playground/json-playground').then((m) => m.JsonPlayground), + }, + ...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.scss b/projects/app/src/app/app.scss new file mode 100644 index 0000000..6cb8b29 --- /dev/null +++ b/projects/app/src/app/app.scss @@ -0,0 +1,91 @@ +@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( + ( + container-background-color: transparent, + ) + ); + + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + position: sticky; + 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); + + // 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 { + flex: 1 1 auto; +} + +.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.spec.ts b/projects/app/src/app/app.spec.ts new file mode 100644 index 0000000..784fb3f --- /dev/null +++ b/projects/app/src/app/app.spec.ts @@ -0,0 +1,18 @@ +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(); + }); + + 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..842d32e --- /dev/null +++ b/projects/app/src/app/app.ts @@ -0,0 +1,175 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + + // Signals + signal, + computed, + linkedSignal, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +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, 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]', + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.Eager, + styleUrl: './app.scss', + imports: [ + // Router + RouterOutlet, + RouterLink, + RouterLinkActive, + + // Material + MatToolbarModule, + MatButtonModule, + MatIconModule, + MatSidenavModule, + MatListModule, + MatTabsModule, + + // Components + AngularInlineText, + ], + host: { + '[class]': 'themeClass()', + }, +}) +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: grouped sections + // --------------------------------------------------------------------------- + /** 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) + .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'; + }); + + /** + * 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 + // --------------------------------------------------------------------------- + // Lazy like the pages: the dialog carries the phone engine (metadata) and + // the temporal trio — statically importing it would drag both into main. + protected async openLoginDialog() { + const { Login } = await import('./login/login'); + + const ref = this.#dialog.open(Login, { + width: 'min(60ch, 100dvw)', + height: 'min(60dvh, 100dvh)', + }); + + 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/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..f615302 --- /dev/null +++ b/projects/app/src/app/docs/docs-data.ts @@ -0,0 +1,1097 @@ +/** + * 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' }, + { path: 'json', label: 'JSON' }, +] 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-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)', + 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).', + }, + ], +}; + +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 +// ----------------------------------------------------------------------------- + +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], + }, + + 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/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]); +} diff --git a/projects/app/src/app/login/login.html b/projects/app/src/app/login/login.html new file mode 100644 index 0000000..3604fb3 --- /dev/null +++ b/projects/app/src/app/login/login.html @@ -0,0 +1,112 @@ +

Sign In

+ + + diff --git a/projects/app/src/app/login/login.scss b/projects/app/src/app/login/login.scss new file mode 100644 index 0000000..22d1554 --- /dev/null +++ b/projects/app/src/app/login/login.scss @@ -0,0 +1,34 @@ +.login-content { + display: flex; + flex-direction: column; +} + +.login-intro { + text-align: center; + color: var(--mat-sys-on-surface-variant); +} + +// Centered two-column form: labels hug their width, fields take the rest. +.login-form { + margin: auto; + width: 100%; + max-width: 44ch; + + display: grid; + grid-template-columns: max-content 1fr; + gap: 20px 24px; + align-items: baseline; +} + +.login-form__label { + font: var(--mat-sys-label-large); + color: var(--mat-sys-on-surface-variant); +} + +.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..6775166 --- /dev/null +++ b/projects/app/src/app/login/login.ts @@ -0,0 +1,120 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, + computed, +} from '@angular/core'; +import { FormField, form, required } from '@angular/forms/signals'; + +// Material +import { MatDialogModule } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; + +// Components +import { AngularInlineText } from '../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; +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'; + +// Phone entry point +import { AngularInlinePhone, createLibphonenumberCodec } from 'angular-inline-select/phone'; +import metadata from 'libphonenumber-js/metadata.min.json'; +import examples from 'libphonenumber-js/examples.mobile.json'; + +// Temporal entry point +import { + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + composeDbEntry, + localDayOf, + dateToDbEntry, +} from 'angular-inline-select/temporal'; + +const phoneCodec = createLibphonenumberCodec(metadata, examples); + +/** + * Sign-in dialog: a centered signal form exercising every inline control — + * text (required), number, number + euro suffix, two phone fields + * (one prefilled + required, one empty), and the temporal trio: date of + * birth, day-start in military time (24 h via the `hc-h23` locale + * extension — zero codec changes) and a daily focus duration. The trio is + * deliberately UNLINKED — the T5 `DateTimeRangeGroup` + * (day/start/end/duration speaking to each other) will be sandboxed + * against exactly this setup. + */ +@Component({ + selector: 'app-login', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatDialogModule, + MatButtonModule, + MatIconModule, + + // Forms + FormField, + + // Components + AngularInlineText, + AngularInlineNumber, + AngularInlinePhone, + AngularInlineDate, + AngularInlineTime, + AngularInlineDuration, + EditableSuffix, + ], + templateUrl: './login.html', + styleUrl: './login.scss', +}) +export class Login { + protected codec = phoneCodec; + + /** + * The sign-in model. `name` is returned as the dialog result on + * "Sign In" and becomes the app's toolbar title. + */ + protected signInModel = signal<{ + name: string; + age: number | null; + income: number | null; + telephone: string | null; + mobile: string | null; + /** UTC ISO DB entry (local start-of-day) — the date control's canonical value. */ + dateOfBirth: string | null; + /** UTC ISO DB entry — displayed in military time via the `hc-h23` locale. */ + dayStart: string | null; + /** Seconds — the duration control's canonical value. */ + focusTime: number | null; + }>({ + name: '', + age: null, + income: null, + telephone: '+49301234567', + mobile: null, + dateOfBirth: null, + dayStart: composeDbEntry(localDayOf(dateToDbEntry(new Date()))!, '06:30'), + focusTime: null, + }); + + protected signInForm = form(this.signInModel, (path) => { + required(path.name); + required(path.telephone); + }); + + // Which error the projected [editable-error] content describes — + // WHEN errors show is the field's job. + protected nameMissing = computed(() => + this.signInForm.name().errors().some((error) => error.kind === 'required'), + ); + + protected telephoneMissing = computed(() => + this.signInForm.telephone().errors().some((error) => error.kind === 'required'), + ); + + /** Two decimals for the income field — the € lives in the suffix. */ + protected incomeFormat = (value: number | null): string => + value === null ? '' : value.toFixed(2); +} 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/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)); +} 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..3535e0b --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.html @@ -0,0 +1,156 @@ +
+
+
+

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 }} + } +
+ } +
+ +
+

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 new file mode 100644 index 0000000..a96237a --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.scss @@ -0,0 +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 new file mode 100644 index 0000000..3d8a8a6 --- /dev/null +++ b/projects/app/src/app/pages/json-playground/json-playground.ts @@ -0,0 +1,113 @@ +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'; +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; + return config; +} + +@Component({ + selector: 'app-json-playground', + templateUrl: './json-playground.html', + styleUrl: './json-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + MatTableModule, + + // 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 5,000-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'), + ); + + // --------------------------------------------------------------------------- + // 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([]); + + 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/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/phone-playground/phone-playground.html b/projects/app/src/app/pages/phone-playground/phone-playground.html new file mode 100644 index 0000000..9553d1b --- /dev/null +++ b/projects/app/src/app/pages/phone-playground/phone-playground.html @@ -0,0 +1,115 @@ +
+
+
+

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. +

+
+ +
+
+

Fresh entry — empty field

+

+ New number: + + — starting from nothing. Type “/” for the country menu (Notion-style: only at the start), or tap the flag + for the searchable picker. Both set the calling code so you can type the rest — model: + {{ freshPhone() ?? '∅' }}. +

+
+ +
+

Standalone [(value)]

+

+ Support hotline: + + — the flag shows the detected country; tap it for the searchable picker, which swaps the calling + code while keeping the national number (try switching to another country — the digits stay). The panel + previews the engine's interpretation on every keystroke — 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 🇫🇷). Type “/” for the country menu — /germany, + /deutschland, /de and /49 all find 🇩🇪 regardless of the Menu locale. + 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..4b85197 --- /dev/null +++ b/projects/app/src/app/pages/phone-playground/phone-playground.ts @@ -0,0 +1,80 @@ +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; + + // --------------------------------------------------------------------------- + // Fresh-entry example — empty field, no pre-filled number + // --------------------------------------------------------------------------- + protected freshPhone = signal(null); + + // --------------------------------------------------------------------------- + // 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 menuLocale = signal<'de' | 'en'>('en'); + + 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/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.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(); + }); +}); 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 new file mode 100644 index 0000000..d2c75b2 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.html @@ -0,0 +1,36 @@ +
+
+
+

Inline temporal editables

+

+ 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. +

+
+ +
+ + + + + + + + + + @if (emittedEvents().length > 0) { +
+
+ @for (entry of emittedEvents(); track $index) { + {{ entry }} + } +
+
+ } +
+
+
diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.scss b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss new file mode 100644 index 0000000..e1686d8 --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.scss @@ -0,0 +1 @@ +@use '../demo'; diff --git a/projects/app/src/app/pages/temporal-playground/temporal-playground.ts b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts new file mode 100644 index 0000000..efd977f --- /dev/null +++ b/projects/app/src/app/pages/temporal-playground/temporal-playground.ts @@ -0,0 +1,48 @@ +import { + Component, + ChangeDetectionStrategy, + + // Signals + signal, +} from '@angular/core'; + +// 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, + imports: [ + DateCard, + TimeCard, + DurationCard, + QuartetCard, + QuartetTableCard, + MatTableCard, + MatQuartetCard, + MatBaselineCard, + ], +}) +export class TemporalPlayground { + // 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'); + + // Event console: newest first, fed by every card's `emitted` output. + protected emittedEvents = signal([]); + + 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 }); + } +} 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..8d5954a --- /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/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..5dceab2 --- /dev/null +++ b/projects/app/src/styles.scss @@ -0,0 +1,46 @@ +// 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-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%; + 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..3f42f8f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,49 @@ +/* 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": ["./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" + ] + }, + "resolveJsonModule": true, + "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" + } + ] +}