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..e69de29 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..c1478a5 --- /dev/null +++ b/projects/angular-inline-select/json/src/angular-inline-json.ts @@ -0,0 +1,637 @@ +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..bccd764 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-editor.ts @@ -0,0 +1,318 @@ +import { EditorState, RangeSetBuilder, type Extension } from '@codemirror/state'; +import { + Decoration, + EditorView, + ViewPlugin, + keymap, + lineNumbers, + type DecorationSet, + type ViewUpdate, +} 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'; +import { printEditableJson } from './json-doc'; + +// ----------------------------------------------------------------------------- +// 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]; +}); + +// ----------------------------------------------------------------------------- +// Indent guides — faint vertical rules under each indentation level, so nesting +// reads at a glance. Our editing form is space-indented two per level +// (`printEditableJson`), and the dialect is space-only, so counting leading +// spaces is exact. Each indented line carries a `--cm-indent-levels` custom +// property; the CSS (_editable-json.scss) draws that many evenly-spaced rules +// with a clipped repeating gradient. One decoration object per level is cached. +// ----------------------------------------------------------------------------- + +const INDENT_COLUMNS = 2; + +const indentGuideDecorations = new Map(); + +function indentGuideDecoration(levels: number): Decoration { + let deco = indentGuideDecorations.get(levels); + if (deco === undefined) { + deco = Decoration.line({ + attributes: { class: 'cm-indentGuides', style: `--cm-indent-levels:${levels}` }, + }); + indentGuideDecorations.set(levels, deco); + } + return deco; +} + +function buildIndentGuides(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + + for (const { from, to } of view.visibleRanges) { + for (let pos = from; pos <= to; ) { + const line = view.state.doc.lineAt(pos); + const text = line.text; + + let spaces = 0; + while (spaces < text.length && text.charCodeAt(spaces) === 32) spaces++; + + const levels = Math.floor(spaces / INDENT_COLUMNS); + if (levels > 0) builder.add(line.from, line.from, indentGuideDecoration(levels)); + + pos = line.to + 1; + } + } + + return builder.finish(); +} + +const indentGuides = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildIndentGuides(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildIndentGuides(update.view); + } + } + }, + { decorations: (plugin) => plugin.decorations }, +); + +// ----------------------------------------------------------------------------- +// Format — "prettier", but for OUR dialect. Real Prettier would re-quote the +// bare identifier keys we deliberately allow; instead we round-trip through the +// same `printEditableJson` that seeds a session (pretty, two-space, bare keys). +// ----------------------------------------------------------------------------- + +/** + * The formatted (editing-form) rendering of `text` IF it both parses and + * differs from it — otherwise `null`. `null` is the "nothing to do" verdict + * shared by every caller: an unparseable/empty draft, or one already in pretty + * form. Computed once so the reformat and the "can format?" badge never + * disagree. + */ +function pendingFormat(text: string): string | null { + const parsed = parseJsonDraft(text); + if (parsed.error !== undefined || parsed.value === undefined) return null; + + const formatted = printEditableJson(parsed.value); + return formatted === text ? null : formatted; +} + +/** + * Whether a reformat would change anything right now — drives the red-dot badge + * on the Format control. False for empty, invalid, and already-pretty drafts, + * so the badge means exactly "clicking Format will do something". + */ +export function canFormatJson(text: string): boolean { + return pendingFormat(text) !== null; +} + +/** + * Reflow the whole document into the editing form when it currently parses. + * Idempotent — already-pretty text is left untouched, so a no-op never adds a + * history entry or jumps the caret; an unparseable (or empty) draft is a no-op + * too, the lint diagnostic already explaining why. The caret is clamped to the + * reflowed length rather than mapped: a total reformat has no position map. + * Returns whether anything changed. Shared by the button, the shortcut, and the + * paste handler below. + */ +export function formatJsonDoc(view: EditorView): boolean { + const text = view.state.doc.toString(); + + const formatted = pendingFormat(text); + if (formatted === null) return false; + + view.dispatch({ + changes: { from: 0, to: text.length, insert: formatted }, + selection: { anchor: Math.min(view.state.selection.main.anchor, formatted.length) }, + userEvent: 'format', + }); + return true; +} + +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(), + indentGuides, + // An (empty) theme whose only job is declaring the scheme — flips every + // `&dark` rule in CM's base theme. + EditorView.theme({}, { dark: options.dark ?? false }), + syntaxHighlighting(githubJsonHighlight), + bracketMatching(), + indentOnInput(), + history(), + EditorView.lineWrapping, + jsonLinter, + lintGutter(), + // Format Document — VS Code's shortcut. First in the list so it wins the + // chord; always consumes it (never falls through to type an 'F'). + keymap.of([ + { + key: 'Shift-Alt-f', + run: (view) => { + formatJsonDoc(view); + return true; + }, + }, + ...defaultKeymap, + ...historyKeymap, + ...lintKeymap, + indentWithTab, + ]), + // Format-on-paste: let the paste apply, then tidy the whole document if it + // now parses (a pasted fragment that doesn't is left as-is). The reformat + // runs a microtask later — after CM has committed the paste transaction — + // so it operates on the pasted text, not the pre-paste doc. + EditorView.domEventHandlers({ + paste: (_event, view) => { + queueMicrotask(() => formatJsonDoc(view)); + return false; + }, + }), + 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..82afdb6 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-session.html @@ -0,0 +1,76 @@ +
+
+ @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..3f1b7b6 --- /dev/null +++ b/projects/angular-inline-select/json/src/json-session.ts @@ -0,0 +1,137 @@ +import { + Component, + DestroyRef, + ElementRef, + Signal, + TemplateRef, + afterNextRender, + inject, + signal, + viewChild, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; + +import { EDITABLE_DIALOG_DATA } from 'angular-inline-select'; + +import { EditorView } from '@codemirror/view'; + +import { canFormatJson, createJsonEditorState, formatJsonDoc } 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'); + + /** + * Whether a reformat would currently change the draft — drives the red-dot + * badge on (and the auto-reveal of) the Format control. Recomputed on every + * keystroke; seeded from the initial document below. + */ + protected canFormat = signal(false); + + #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); + this.canFormat.set(canFormatJson(text)); + }, + }, + { dark }, + ); + + this.#view = new EditorView({ state, parent: container }); + this.canFormat.set(canFormatJson(this.data.seed)); + this.#view.focus(); + + // COLD-OPEN REMEASURE. The editor mounts the very first time inside a + // dialog whose layout has not settled yet; CM takes its one line-height + // measurement there and lands on a stale default (14px), caching it in the + // height map. Every gutter line number is then sized to 14px while the + // wrapped lines render at ~18px, so the numbers drift a little lower each + // row until "1" sits a full line below the `{` and a phantom last number + // hangs past the end. Nothing resizes the scroller afterwards (the dialog + // enters via transform, invisible to CM's ResizeObserver), so CM never + // re-measures on its own. Force one re-measure now that the row is laid + // out — it re-reads the real line height and re-aligns the gutter. + this.#view.requestMeasure(); + }); + + #destroyView = inject(DestroyRef).onDestroy(() => this.#view?.destroy()); + + /** Format: pretty-print the draft in place, then keep focus in the editor. */ + protected format() { + const view = this.#view; + if (view === null) return; + formatJsonDoc(view); + view.focus(); + } + + /** 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..c7d7ca9 --- /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..1673c18 --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.spec.ts @@ -0,0 +1,628 @@ +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, + type InlineTextWrapBehavior, +} 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], + template: ` + + `, +}) +class WrapHost { + value = signal('a value long enough to need a decision about wrapping'); + isSingleLine = signal(false); + wrapBehavior = signal('noWrap'); +} + +@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(); + }); +}); + +// `isSingleLine` owns the VALUE (may a line break exist?), `wrapBehavior` owns +// the PAINT (what happens at a width constraint) — all four combinations are +// legal, and neither input may reach into the other's concern. +describe('AngularInlineText — isSingleLine vs wrapBehavior', () => { + let h: Harness; + + const paintsNoWrap = () => h.display().classList.contains('editable-text__display--no-wrap'); + + const set = (isSingleLine: boolean, wrapBehavior: InlineTextWrapBehavior = 'noWrap') => { + h.host.isSingleLine.set(isSingleLine); + h.host.wrapBehavior.set(wrapBehavior); + h.fixture.detectChanges(); + }; + + beforeEach(() => { + h = setup(WrapHost); + }); + + it('single-line: noWrap by default, wrap on request', () => { + set(true); + expect(paintsNoWrap()).toBe(true); + + // A single-line value wrapped over several visual lines + set(true, 'wrap'); + expect(paintsNoWrap()).toBe(false); + }); + + it('multi-line always wraps — wrapBehavior is inert outside single-line', () => { + set(false, 'noWrap'); + expect(paintsNoWrap()).toBe(false); + + set(false, 'wrap'); + expect(paintsNoWrap()).toBe(false); + }); + + it('single-line still forbids line breaks in the value while wrapping', async () => { + set(true, 'wrap'); + + await typeText(h, 'first\nsecond'); + accept(h); + + expect(h.host.value()).toBe('first second'); + }); + + it('multi-line keeps line breaks in the value, whatever wrapBehavior says', async () => { + set(false, 'noWrap'); + + await typeText(h, 'first\nsecond'); + accept(h); + + expect(h.host.value()).toBe('first\nsecond'); + }); + + it('single-line still accepts on Enter while wrapping', async () => { + set(true, 'wrap'); + + await typeText(h, 'typed'); + h.editor()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + h.fixture.detectChanges(); + + expect(h.host.value()).toBe('typed'); + expect(h.editable().editing()).toBe(false); + }); + + it('the elevated editor always wraps — it never carries the no-wrap paint', async () => { + set(true, 'noWrap'); + + await elevate(h); + + expect(h.editor()!.classList.contains('editable-text__display--no-wrap')).toBe(false); + }); +}); 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..1f0350a --- /dev/null +++ b/projects/angular-inline-select/src/lib/angular-inline-text/angular-inline-text.ts @@ -0,0 +1,1079 @@ +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; +} + +/** + * How a SINGLE-LINE in-flow display paints text wider than its container. + * + * - `'noWrap'` (default) — stay on the one line and ellipsize at the + * constraint. + * - `'wrap'` — break onto the next line: at whitespace first, inside long words + * when there is no whitespace to break at, hyphenating where the browser can. + * + * Multi-line fields always wrap and ignore this input entirely. + */ +export type InlineTextWrapBehavior = 'wrap' | 'noWrap'; + +/** 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); + + /** + * Whether the VALUE is a single line: no line break may exist in it. Enter + * accepts instead of inserting one, and pasted breaks collapse to spaces. + * Says nothing about how the text is PAINTED — a single-line value may still + * wrap over several visual lines (`wrapBehavior`). + */ + isSingleLine = input(false); + + /** + * SINGLE-LINE only — how the in-flow display handles a width constraint: + * `'noWrap'` (default) keeps the one line and ellipsizes at the constraint; + * `'wrap'` paints the one logical line over as many visual lines as the + * container needs, breaking at whitespace first, inside long words when + * there is none (hyphenated where the browser can). Multi-line fields + * ignore it entirely and always wrap. The elevated editor always wraps: a + * fixed readable measure is the point of the panel, and text you cannot + * see is text you cannot edit. + */ + wrapBehavior = input('noWrap'); + + /** `wrapBehavior` gated to its scope: multi-line fields always wrap. */ + protected wrapMode = computed(() => + this.isSingleLine() ? this.wrapBehavior() : 'wrap', + ); + + 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.wrapMode(); + 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..be81a30 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-json.scss @@ -0,0 +1,305 @@ +// ============================================================================= +// 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-inline: var(--mat-sys-inner-spacing, 16px); + padding-block-end: var(--mat-sys-inner-spacing, 16px); +} + +// The positioning context for the hover toolbar and the flex item in the line +// (the editor itself just fills it). Column flow so the editor stretches in the +// full-screen dialog mode below. +.editable-json__editor-wrap { + position: relative; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +// The floating "Format" menu, pinned to the editor's top-right. Hidden until +// the editor is hovered or holds focus — quiet at rest, discoverable on +// approach. `pointer-events` gate keeps it from swallowing clicks while faded. +.editable-json__toolbar { + position: absolute; + inset-block-start: calc(var(--mat-sys-inner-spacing, 16px) * 0.5); + inset-inline-end: calc(var(--mat-sys-inner-spacing, 16px) * 0.5); + z-index: 2; + + display: flex; + gap: calc(var(--mat-sys-spacing, 0.25rem) * 1.5); + + opacity: 0; + pointer-events: none; + transition: opacity 0.15s var(--editable-ease-standard, cubic-bezier(0.4, 0, 0.2, 1)); + + // Revealed on approach (hover/focus) OR self-announced: when a reformat would + // actually change the draft, the toolbar surfaces itself so the badge below + // can do its job — the gacha "there's something here" cue. + .editable-json__editor-wrap:hover &, + .editable-json__editor-wrap:focus-within &, + .editable-json__editor-wrap--formattable & { + opacity: 1; + pointer-events: auto; + } +} + +// Compact against the base action; a subtle surface so it reads over code. +// `relative` anchors the badge to the button's own top-right corner. +.editable-json__format { + position: relative; + height: calc(var(--mat-sys-spacing, 0.25rem) * 6); + box-shadow: var(--editable-json-toolbar-shadow, 0 1px 2px rgb(0 0 0 / 0.12)); +} + +// A small dot tucked INSIDE the button's top-right corner — present only while +// `canFormat` (the @if in the template), so its mere existence is the signal. +// Absolutely positioned, so appearing/disappearing never reflows the label +// (no layout shift). +.editable-json__badge { + position: absolute; + inset-block-start: 0.1875rem; + inset-inline-end: 0.1875rem; + + inline-size: 0.25rem; + block-size: 0.25rem; + border-radius: 50%; + background: var(--editable-json-badge-color, #e5484d); +} + +.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)); + } + + // NB: the gutter gets NO manual block padding. CM mirrors `.cm-content`'s + // top padding onto the first gutter element (a `margin-top`) once it has + // measured correctly, so every line number aligns with its line on its own. + // Adding padding here double-counts that offset and drops each number a full + // line below its code — the very misalignment this used to cause. + .cm-lineNumbers .cm-gutterElement { + padding-inline: var(--mat-sys-inner-spacing, 16px) + calc(var(--mat-sys-inner-spacing, 16px) * 0.25); + min-width: 3ch; + } + + // Indent guides: one faint vertical rule per nesting level, drawn as a + // repeating gradient clipped to exactly `--cm-indent-levels` columns (set per + // line by the indentGuides plugin). `content-box` origin skips CM's 6px line + // padding so the first rule sits under text column 0; `1ch * 2` is one + // two-space indent in the monospace font, so every rule lands on an indent + // stop. Purely decorative — it rides behind the caret and selection. + .cm-line.cm-indentGuides { + background-image: repeating-linear-gradient( + to right, + var(--editable-json-indent-guide, light-dark(#d8dee4, #30363d)) 0, + var(--editable-json-indent-guide, light-dark(#d8dee4, #30363d)) 1px, + transparent 1px, + transparent calc(1ch * 2) + ); + background-origin: content-box; + background-repeat: no-repeat; + background-position: 0 0; + background-size: calc(var(--cm-indent-levels) * 1ch * 2) 100%; + } + + // CodeMirror uses the NATIVE caret (no drawSelection extension). The dark + // base theme (json-editor.ts passes the scheme at mount) already keeps it + // visible; this pins it to the family caret token on top. + .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, + .editable-json__toolbar { + 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..7e51947 --- /dev/null +++ b/projects/angular-inline-select/src/lib/styles/_editable-text.scss @@ -0,0 +1,216 @@ +// ============================================================================= +// 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; + cursor: text; + outline: none; + + // WRAPPING (`wrapBehavior: 'wrap'`) — break at whitespace first, inside a + // long word when there is no whitespace to break at, and hyphenate where the + // browser can. It lives in the base rule and the `--no-wrap` modifier (the + // default) overrides it, so the wrapping properties stay in one place. + // `hyphens: auto` only fires when a language is known + // (a `lang` attribute on the document or an ancestor); without one the + // browser silently skips hyphenation and `overflow-wrap` still breaks the + // word, just without the hyphen. `--editable-text-hyphens: none` opts out. + white-space: pre-wrap; + overflow-wrap: anywhere; + hyphens: var(--editable-text-hyphens, auto); + + 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 + // no-wrap 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: no-wrap (`wrapBehavior: 'noWrap'`) — the text stays on the lines + // it was authored on and ellipsizes when the container is narrower than the + // content (e.g. fixed-layout table cells). This is paint only: whether the + // VALUE may contain a line break is `isSingleLine`'s business, so a + // multi-line value here keeps its breaks and ellipsizes each line. + // + // `white-space: pre` — not `nowrap`: `nowrap` would fold authored breaks into + // spaces and collapse interior space runs, so the display would disagree with + // the editor (which is `pre-wrap`) about the very text it is showing. + // + // `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. + // --------------------------------------------------------------------------- + &--no-wrap { + display: inline-block; + min-width: 0; + max-width: 100%; + + white-space: pre; + hyphens: none; // nothing wraps, so a hyphen could only ever be noise + overflow: clip; + overflow-clip-margin: 0.6em; + text-overflow: ellipsis; + } +} + +// ----------------------------------------------------------------------------- +// ELEMENT: elevated editing surface (inside .editable-panel) +// +// Always wraps, whatever `wrapBehavior` says: the panel exists to give the +// draft a fixed readable measure, and text clipped out of view is text the +// user cannot edit. No hyphenation either — the editor shows the value +// literally, so nothing appears in it that isn't in the string. +// ----------------------------------------------------------------------------- +.editable-text__editor { + display: block; + white-space: pre-wrap; + overflow-wrap: anywhere; + hyphens: none; + 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..01c5023 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.html @@ -0,0 +1,182 @@ + + + @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..e559ae2 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/angular-inline-date.ts @@ -0,0 +1,920 @@ +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, + sideSize, + wireEditingBridge, + type SideCore, + type SideKey, +} from '../side-session'; +import { TemporalIntl } from '../temporal-intl'; +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 localizable chrome strings (nav/clear/quick-pick labels, revert prose). */ + protected intl = inject(TemporalIntl); + + /** 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 this.intl.fieldLabel(this.ariaLabel() ?? this.intl.dateLabel(), 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(); + } + + #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..1ceb0ad --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.html @@ -0,0 +1,67 @@ +
+ +
{{ monthLabel() }}
+ +
+ +
+
+ @for (name of weekdayNames(); track $index) { + {{ + name.narrow + }} + } +
+ @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..a63db12 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-date/calendar/calendar.ts @@ -0,0 +1,391 @@ +import { + Component, + DestroyRef, + ElementRef, + Injector, + afterNextRender, + computed, + inject, + + // Signals + 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'; +import { TemporalIntl } from '../../temporal-intl'; + +interface CalendarDay { + iso: IsoDate; + day: number; + outside: boolean; + today: boolean; +} + +/** Distinct id per instance — the grid's `aria-labelledby` target. */ +let nextCalendarId = 0; + +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); + protected intl = inject(TemporalIntl); + + /** The month heading's id — the grid names itself by pointing here. */ + protected labelId = `temporal-cal-label-${nextCalendarId++}`; + + /** 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()); + // The visible header is `narrow` ('M') — ambiguous to a screen reader, + // and doubly so outside English — so each column header carries the + // `long` name as its accessible label. Both come from `Intl`: the + // aria upgrade stays at zero bundled translations. + const format = (style: 'narrow' | 'long', day: number) => { + try { + // 2023-01-01 was a Sunday — a stable anchor for weekday names. + return new Intl.DateTimeFormat(this.locale(), { weekday: style }).format( + new Date(2023, 0, 1 + day), + ); + } catch { + return style === 'narrow' + ? 'SMTWTFS'[day] + : ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][day]; + } + }; + + return Array.from({ length: 7 }, (_, index) => { + const day = (first + index) % 7; + return { narrow: format('narrow', day), long: format('long', day) }; + }); + }); + + 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..75f546b --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-duration/angular-inline-duration.ts @@ -0,0 +1,493 @@ +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 { TemporalIntl } from '../temporal-intl'; +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 }, + ]; + + #intl = inject(TemporalIntl); + + 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 ? '' : formatDuration(value, this.durationFormat()); + this.revertNotice.set(this.#intl.revertedLabel(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..caabefd --- /dev/null +++ b/projects/angular-inline-select/temporal/src/angular-inline-time/angular-inline-time.ts @@ -0,0 +1,945 @@ +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, + sideSize, + wireEditingBridge, + type SideCore, + type SideKey, +} from '../side-session'; +import { TemporalIntl } from '../temporal-intl'; +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()); + + /** Localizable chrome strings — here, the side words on a ranged label. */ + #intl = inject(TemporalIntl); + + /** 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 this.#intl.fieldLabel(this.ariaLabel() ?? this.#intl.timeLabel(), 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..b865ae2 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/public-api.ts @@ -0,0 +1,20 @@ +/* + * 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 './temporal-intl'; +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..3243ef4 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/side-session.ts @@ -0,0 +1,224 @@ +import { + DestroyRef, + Injector, + afterNextRender, + computed, + effect, + inject, + linkedSignal, + signal, + untracked, + type Signal, + type WritableSignal, +} from '@angular/core'; + +import { TemporalIntl } from './temporal-intl'; + +/** + * 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); +} + +/** + * 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 intl = inject(TemporalIntl); + 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(intl.revertedLabel(restored)); + revertFlash.set(key); + + if (flashTimer !== null) clearTimeout(flashTimer); + flashTimer = setTimeout(() => revertFlash.set(null), 600); + }, + }; +} diff --git a/projects/angular-inline-select/temporal/src/temporal-intl.ts b/projects/angular-inline-select/temporal/src/temporal-intl.ts new file mode 100644 index 0000000..37a7e86 --- /dev/null +++ b/projects/angular-inline-select/temporal/src/temporal-intl.ts @@ -0,0 +1,68 @@ +import { Injectable, signal } from '@angular/core'; + +import type { SideKey } from './side-session'; + +/** + * The temporal controls' localizable CHROME — every fixed UI string a date, + * time or calendar speaks that isn't itself a date value. Date values keep + * localizing through `Intl` at the codec boundary (zero bundled translations); + * this class is only for the surrounding affordance labels and announcements, + * which `Intl` can't reach. + * + * One `providedIn: 'root'` override point, signal-backed so a runtime locale + * switch re-renders every consumer. This is the `MatDatepickerIntl` pattern, + * deliberately NOT Angular `$localize`: a distributable library that ships + * `i18n`-marked templates forces every consumer to extract and merge ITS + * message IDs into their own catalog, and fights runtime locale switching. + * A consumer localizes the whole surface by providing a subclass: + * + * { provide: TemporalIntl, useClass: GermanTemporalIntl } + * + * and wiring the string signals to whatever i18n backend they already run + * (transloco, ngx-translate, `$localize`, or plain constants). The + * word-order-sensitive strings are METHODS so an override can restructure + * them, not merely swap tokens into an English frame. + */ +@Injectable({ providedIn: 'root' }) +export class TemporalIntl { + /** Calendar month-navigation buttons. */ + readonly prevMonthLabel = signal('Previous month'); + readonly nextMonthLabel = signal('Next month'); + + /** The 📅 affordance that opens the calendar panel. */ + readonly openCalendarLabel = signal('Open calendar'); + + /** The quick-pick command group. */ + readonly quickPicksLabel = signal('Quick picks'); + + /** Default accessible names, used when the consumer sets no `ariaLabel`. */ + readonly dateLabel = signal('Date'); + readonly timeLabel = signal('Time'); + + /** The side words a ranged field appends to each input's accessible name. */ + readonly rangeStartLabel = signal('start'); + readonly rangeEndLabel = signal('end'); + + /** The value a cleared field snaps back to, named in the revert announcement. */ + readonly emptyLabel = signal('empty'); + + #sideWord(side: SideKey): string { + return side === 'start' ? this.rangeStartLabel() : this.rangeEndLabel(); + } + + /** Ranged fields suffix the side onto the accessible name. */ + fieldLabel(base: string, side: SideKey, ranged: boolean): string { + return ranged ? `${base} ${this.#sideWord(side)}` : base; + } + + /** The clear-button accessible name (`'single'` → no side word). */ + clearLabel(side: SideKey | 'single'): string { + const noun = this.dateLabel().toLowerCase(); + return side === 'single' ? `Clear ${noun}` : `Clear ${this.#sideWord(side)} ${noun}`; + } + + /** The snap-back announcement; `''` restored → the empty word. */ + revertedLabel(restored: string): string { + return `Reverted to ${restored === '' ? this.emptyLabel() : restored}`; + } +} 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..3b837aa --- /dev/null +++ b/projects/app/src/app/docs/docs-data.ts @@ -0,0 +1,1110 @@ +/** + * 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-hyphens', + fallback: 'auto', + description: + 'Hyphenation of the in-flow display while wrapping (`wrapBehavior: "wrap"`). `auto` needs a language to be known — a `lang` attribute on the document or an ancestor — otherwise the browser skips the hyphen and breaks the word plainly. Set to `none` to never hyphenate.', + }, + { + 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: + 'Constrains the VALUE to one line: no line break may exist in it (Enter accepts, pasted breaks collapse to spaces). Says nothing about how the text is painted — see wrapBehavior.', + }, + { + name: 'wrapBehavior', + type: "'noWrap' | 'wrap'", + default: "'noWrap'", + description: + "Single-line only — how the display handles a width constraint: 'noWrap' keeps the one line and ellipsizes, 'wrap' paints it over several visual lines (breaking at whitespace, inside long words when there is none, hyphenated where the browser can). Multi-line fields ignore it entirely and always wrap. The elevated editor always wraps.", + }, + { + 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..73f43c9 --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.html @@ -0,0 +1,231 @@ + + + +
+
+
+

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

Single Line in Table

+

+ isSingleLine cells: one logical line — Enter saves, pasted line breaks + collapse to spaces — regardless of the paint. noWrap ellipsizes at the column + edge; wrap grows the row instead, breaking at whitespace, or inside the second + column's long words (hyphenated where the browser can). +

+ + noWrap + wrap + +
+ +
+ + + + + + + + + + + + + + + + + + +
#{{ row.position }}Long title + + With an unbreakable word + +
+
+
+ +
+
+

Text Area in Table

+

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

+
+ +
+ + + + + + + + + + + + + + + + + + +
#{{ row.position }}Authored lines + + With an unbreakable word + +
+
+
+
diff --git a/projects/app/src/app/pages/text-playground/text-playground.scss b/projects/app/src/app/pages/text-playground/text-playground.scss new file mode 100644 index 0000000..378822c --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.scss @@ -0,0 +1,95 @@ +@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; +} + +/* --- Table examples: isSingleLine (the value) vs wrapBehavior (the paint) --- */ + +// Content-sized sections: 4 rows never justify the fill-the-viewport default +// of `.example`, so the table sections opt out and end where the table ends. +.example--fit { + min-height: 0; +} + +.table-scroll { + overflow: auto; + max-height: 70vh; + 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 — and every column stays narrower than + // the text in it, which is the whole point of these tables. + table-layout: fixed; + + .mat-column-position { + width: 4rem; + } + + // Room for the editable's focus ring inside cells + td { + padding-top: 4px; + padding-bottom: 4px; + + // Wrapped cells are as tall as their content: keep the text at the top so + // a growing cell doesn't drag its neighbours' baselines around with it. + vertical-align: top; + } +} + +/* --- 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..0c631f2 --- /dev/null +++ b/projects/app/src/app/pages/text-playground/text-playground.ts @@ -0,0 +1,211 @@ +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 { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatTableModule } from '@angular/material/table'; + +// Components +import { + AngularInlineText, + type InlineTextWrapBehavior, +} from '../../../../../angular-inline-select/src/lib/angular-inline-text/angular-inline-text'; + +export interface DemoRow { + position: number; + /** One logical line, always far too long for its column. */ + title: string; + /** One logical line whose overflow is a single unbreakable word. */ + compound: string; + /** Several authored lines, each too long for its column. */ + note: string; + /** Several authored lines with an unbreakable word in them. */ + report: string; +} + +const INITIAL_PROJECT_NAME = 'Aurora'; +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.'; + +// Every cell below is deliberately too long for its column: what these tables +// demonstrate is what happens AT the width constraint, so nothing may fit. + +/** One logical line — no line break anywhere, plenty of whitespace to break at. */ +const SAMPLE_TITLES = [ + 'Junction Point Observatory of the Western Rim, survey sector nine', + 'Flux Capacitor Calibration and Maintenance Facility, Northwest Approach', + 'The Extraordinarily Long Research Vessel Designation That Never Fits Anywhere', + 'Gossamer Drift Relay, secondary handshake array and telemetry mast', + 'Aurora Borealis Deep Field Observation Platform, upper orbital ring', +]; + +/** One logical line whose overflow is a single word — nowhere to break politely. */ +const SAMPLE_COMPOUNDS = [ + 'Rekalibrierungsmaßnahmenverordnung filed against the starboard array', + 'Pending: interplanetaryhyperspectralimagingandtelemetrysubsystemoverhaul', + 'Betriebssicherheitsüberprüfungsbescheinigung issued for the whole ring', + 'Escalated as antidisestablishmentarianism_of_the_docking_clamp_committee', + 'Filed under Höchstgeschwindigkeitsbegrenzungsüberschreitung, third cycle', +]; + +/** + * Several authored lines — the line breaks are user content and must survive. + * Some samples include a BLANK line (paragraph break): an empty line is user + * content too, and both paints must keep it. + */ +const SAMPLE_NOTES = [ + 'Recalibrated twice this cycle and the drift is still within tolerance.\n' + + 'Keep an eye on the secondary readings until the next full diagnostic.', + 'The array was realigned after the last storm season and power draw is nominal.\n' + + 'Relay handshake completes in under forty milliseconds, every attempt.\n' + + '\n' + + 'Crew rotation is scheduled for the third week, pending transport.', + 'Follow-up needed during the next maintenance window, whenever that lands.\n' + + '\n' + + 'Nothing here is urgent, but none of it should be forgotten either.', +]; + +/** Several authored lines, at least one of which is an unbreakable word. */ +const SAMPLE_REPORTS = [ + 'Status: Verkehrsinfrastrukturfinanzierungsgesellschaft review outstanding.\n' + + 'Everything else on the checklist came back clean on the first pass.', + 'Flagged: supercalifragilisticexpialidocious_diagnostic_output_channel_seven\n' + + 'Downgraded to advisory after the second read, no action required today.\n' + + '\n' + + 'Next audit lands with the quarterly rotation.', + 'Awaiting Grundstücksverkehrsgenehmigungszuständigkeitsübertragungsverordnung.\n' + + 'The paperwork trails the work by about a week, as it always does.', +]; + +// Striding across pools of different sizes so the four columns of a row never +// line up into the same combination twice down the table. +function makeDemoRows(count: number): DemoRow[] { + return Array.from({ length: count }, (_, i) => ({ + position: i + 1, + title: SAMPLE_TITLES[i % SAMPLE_TITLES.length], + compound: SAMPLE_COMPOUNDS[(i + 2) % SAMPLE_COMPOUNDS.length], + note: SAMPLE_NOTES[i % SAMPLE_NOTES.length], + report: SAMPLE_REPORTS[(i + 1) % SAMPLE_REPORTS.length], + })); +} + +@Component({ + selector: 'app-text-playground', + templateUrl: './text-playground.html', + styleUrl: './text-playground.scss', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + // Material + MatButtonModule, + MatButtonToggleModule, + 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 examples: isSingleLine (the VALUE) vs wrapBehavior (the PAINT) + // + // Two tables — one per isSingleLine — each with an exclusive wrapBehavior + // toggle, so every combination is two clicks away. Each column is narrower + // than its content, so every cell has to make the decision under test. + // --------------------------------------------------------------------------- + + /** "Single Line in Table": one whitespace-heavy column, one unbreakable-word column. */ + protected singleLineColumns = ['position', 'title', 'compound']; + protected singleLineRows: DemoRow[] = makeDemoRows(4); + protected singleLineWrap = signal('noWrap'); + + /** + * "Text Area in Table": authored line breaks, with and without a long word. + * No paint controls here — multi-line always wraps (`wrapBehavior` is + * single-line-only). + */ + protected textAreaColumns = ['position', 'note', 'report']; + protected textAreaRows: DemoRow[] = makeDemoRows(4); + + // --------------------------------------------------------------------------- + // Floating nav + // --------------------------------------------------------------------------- + // Plain `href="#…"` resolves against `` and would navigate to + // "/#…", losing the /text route — so the nav scrolls programmatically. + // scrollIntoView also handles the real scroll container (the sidenav + // content, not the document); `scroll-margin-top` clears the sticky tabs. + protected scrollToExample(event: Event, id: string) { + event.preventDefault(); + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + +} 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" + } + ] +}