@agent-ui-kit/components 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-styles.css +8 -0
- package/dist/component-styles.css +147 -0
- package/dist/controls/_base/index.d.ts +3 -0
- package/dist/controls/_base/index.js +9 -0
- package/dist/controls/_base/indicator-element.d.ts +132 -0
- package/dist/controls/_base/indicator-element.js +158 -0
- package/dist/controls/_base/listbox-element.d.ts +70 -0
- package/dist/controls/_base/listbox-element.js +74 -0
- package/dist/controls/_base/range-element.d.ts +120 -0
- package/dist/controls/_base/range-element.js +158 -0
- package/dist/controls/_surface/container-box.css +301 -0
- package/dist/controls/_surface/container.css +156 -0
- package/dist/controls/_token-surface/token-surface.d.ts +35 -0
- package/dist/controls/_token-surface/token-surface.js +97 -0
- package/dist/controls/attachment/attachment-meta.d.ts +8 -0
- package/dist/controls/attachment/attachment-meta.js +84 -0
- package/dist/controls/attachment/attachment.css +91 -0
- package/dist/controls/attachment/attachment.d.ts +40 -0
- package/dist/controls/attachment/attachment.js +70 -0
- package/dist/controls/avatar/avatar-initials.d.ts +4 -0
- package/dist/controls/avatar/avatar-initials.js +30 -0
- package/dist/controls/avatar/avatar.css +87 -0
- package/dist/controls/avatar/avatar.d.ts +40 -0
- package/dist/controls/avatar/avatar.js +77 -0
- package/dist/controls/badge/badge.css +175 -0
- package/dist/controls/badge/badge.d.ts +36 -0
- package/dist/controls/badge/badge.js +71 -0
- package/dist/controls/bar-chart/bar-chart.css +110 -0
- package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
- package/dist/controls/bar-chart/bar-chart.js +80 -0
- package/dist/controls/bar-chart/bar-math.d.ts +31 -0
- package/dist/controls/bar-chart/bar-math.js +106 -0
- package/dist/controls/button/button.css +263 -0
- package/dist/controls/button/button.d.ts +61 -0
- package/dist/controls/button/button.js +115 -0
- package/dist/controls/calendar/calendar.css +495 -0
- package/dist/controls/calendar/calendar.d.ts +161 -0
- package/dist/controls/calendar/calendar.js +1004 -0
- package/dist/controls/card/card-content.d.ts +24 -0
- package/dist/controls/card/card-content.js +133 -0
- package/dist/controls/card/card-footer.d.ts +3 -0
- package/dist/controls/card/card-footer.js +16 -0
- package/dist/controls/card/card-header.d.ts +3 -0
- package/dist/controls/card/card-header.js +18 -0
- package/dist/controls/card/card.css +445 -0
- package/dist/controls/card/card.d.ts +51 -0
- package/dist/controls/card/card.js +61 -0
- package/dist/controls/checkbox/checkbox.css +204 -0
- package/dist/controls/checkbox/checkbox.d.ts +114 -0
- package/dist/controls/checkbox/checkbox.js +130 -0
- package/dist/controls/code/code.css +72 -0
- package/dist/controls/code/code.d.ts +23 -0
- package/dist/controls/code/code.js +40 -0
- package/dist/controls/color-picker/color-picker.css +209 -0
- package/dist/controls/color-picker/color-picker.d.ts +84 -0
- package/dist/controls/color-picker/color-picker.js +545 -0
- package/dist/controls/color-picker/color.d.ts +40 -0
- package/dist/controls/color-picker/color.js +159 -0
- package/dist/controls/column/column.css +151 -0
- package/dist/controls/column/column.d.ts +107 -0
- package/dist/controls/column/column.js +57 -0
- package/dist/controls/combo-box/combo-box.css +397 -0
- package/dist/controls/combo-box/combo-box.d.ts +133 -0
- package/dist/controls/combo-box/combo-box.js +680 -0
- package/dist/controls/command-modal/command-modal.css +226 -0
- package/dist/controls/command-modal/command-modal.d.ts +42 -0
- package/dist/controls/command-modal/command-modal.js +315 -0
- package/dist/controls/disclosure/disclosure.css +162 -0
- package/dist/controls/disclosure/disclosure.d.ts +37 -0
- package/dist/controls/disclosure/disclosure.js +207 -0
- package/dist/controls/field/field.css +140 -0
- package/dist/controls/field/field.d.ts +27 -0
- package/dist/controls/field/field.js +228 -0
- package/dist/controls/form-provider/form-provider.css +40 -0
- package/dist/controls/form-provider/form-provider.d.ts +42 -0
- package/dist/controls/form-provider/form-provider.js +105 -0
- package/dist/controls/grid/grid.css +72 -0
- package/dist/controls/grid/grid.d.ts +60 -0
- package/dist/controls/grid/grid.js +56 -0
- package/dist/controls/icon/icon.css +37 -0
- package/dist/controls/icon/icon.d.ts +25 -0
- package/dist/controls/icon/icon.js +55 -0
- package/dist/controls/index.d.ts +57 -0
- package/dist/controls/index.js +136 -0
- package/dist/controls/ladder/ladder.css +89 -0
- package/dist/controls/ladder/ladder.d.ts +27 -0
- package/dist/controls/ladder/ladder.js +78 -0
- package/dist/controls/list/list.css +118 -0
- package/dist/controls/list/list.d.ts +84 -0
- package/dist/controls/list/list.js +48 -0
- package/dist/controls/menu/menu.css +195 -0
- package/dist/controls/menu/menu.d.ts +44 -0
- package/dist/controls/menu/menu.js +248 -0
- package/dist/controls/modal/modal.css +99 -0
- package/dist/controls/modal/modal.d.ts +61 -0
- package/dist/controls/modal/modal.js +155 -0
- package/dist/controls/popover/popover.css +83 -0
- package/dist/controls/popover/popover.d.ts +44 -0
- package/dist/controls/popover/popover.js +164 -0
- package/dist/controls/progress/progress.css +106 -0
- package/dist/controls/progress/progress.d.ts +28 -0
- package/dist/controls/progress/progress.js +80 -0
- package/dist/controls/radio/radio-group.css +84 -0
- package/dist/controls/radio/radio-group.d.ts +152 -0
- package/dist/controls/radio/radio-group.js +350 -0
- package/dist/controls/radio/radio.css +219 -0
- package/dist/controls/radio/radio.d.ts +19 -0
- package/dist/controls/radio/radio.js +75 -0
- package/dist/controls/ramp/ramp.css +75 -0
- package/dist/controls/ramp/ramp.d.ts +29 -0
- package/dist/controls/ramp/ramp.js +72 -0
- package/dist/controls/row/row.css +152 -0
- package/dist/controls/row/row.d.ts +95 -0
- package/dist/controls/row/row.js +45 -0
- package/dist/controls/segment/segment.css +66 -0
- package/dist/controls/segment/segment.d.ts +3 -0
- package/dist/controls/segment/segment.js +26 -0
- package/dist/controls/segmented-control/segmented-control.css +189 -0
- package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
- package/dist/controls/segmented-control/segmented-control.js +64 -0
- package/dist/controls/select/select.css +467 -0
- package/dist/controls/select/select.d.ts +147 -0
- package/dist/controls/select/select.js +538 -0
- package/dist/controls/slider/slider.css +231 -0
- package/dist/controls/slider/slider.d.ts +111 -0
- package/dist/controls/slider/slider.js +60 -0
- package/dist/controls/slider-multi/slider-multi.css +197 -0
- package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
- package/dist/controls/slider-multi/slider-multi.js +380 -0
- package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
- package/dist/controls/sparkline/sparkline-math.js +79 -0
- package/dist/controls/sparkline/sparkline.css +61 -0
- package/dist/controls/sparkline/sparkline.d.ts +27 -0
- package/dist/controls/sparkline/sparkline.js +74 -0
- package/dist/controls/split/constrain.d.ts +44 -0
- package/dist/controls/split/constrain.js +126 -0
- package/dist/controls/split/split-pane.css +63 -0
- package/dist/controls/split/split-pane.d.ts +49 -0
- package/dist/controls/split/split-pane.js +42 -0
- package/dist/controls/split/split.css +127 -0
- package/dist/controls/split/split.d.ts +42 -0
- package/dist/controls/split/split.js +364 -0
- package/dist/controls/stat/stat-model.d.ts +22 -0
- package/dist/controls/stat/stat-model.js +69 -0
- package/dist/controls/stat/stat.css +101 -0
- package/dist/controls/stat/stat.d.ts +30 -0
- package/dist/controls/stat/stat.js +73 -0
- package/dist/controls/status-stream/status-stream.css +151 -0
- package/dist/controls/status-stream/status-stream.d.ts +108 -0
- package/dist/controls/status-stream/status-stream.js +417 -0
- package/dist/controls/swatch/swatch.css +66 -0
- package/dist/controls/swatch/swatch.d.ts +28 -0
- package/dist/controls/swatch/swatch.js +74 -0
- package/dist/controls/swiper/swiper-item.css +17 -0
- package/dist/controls/swiper/swiper-item.d.ts +31 -0
- package/dist/controls/swiper/swiper-item.js +47 -0
- package/dist/controls/swiper/swiper-label.css +10 -0
- package/dist/controls/swiper/swiper-label.d.ts +4 -0
- package/dist/controls/swiper/swiper-label.js +20 -0
- package/dist/controls/swiper/swiper-paddles.css +26 -0
- package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
- package/dist/controls/swiper/swiper-paddles.js +62 -0
- package/dist/controls/swiper/swiper-pagination.css +60 -0
- package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
- package/dist/controls/swiper/swiper-pagination.js +78 -0
- package/dist/controls/swiper/swiper.css +153 -0
- package/dist/controls/swiper/swiper.d.ts +158 -0
- package/dist/controls/swiper/swiper.js +659 -0
- package/dist/controls/switch/switch.css +194 -0
- package/dist/controls/switch/switch.d.ts +5 -0
- package/dist/controls/switch/switch.js +18 -0
- package/dist/controls/table/table-model.d.ts +43 -0
- package/dist/controls/table/table-model.js +124 -0
- package/dist/controls/table/table.css +90 -0
- package/dist/controls/table/table.d.ts +29 -0
- package/dist/controls/table/table.js +178 -0
- package/dist/controls/tabs/tab-panel.d.ts +10 -0
- package/dist/controls/tabs/tab-panel.js +89 -0
- package/dist/controls/tabs/tab.d.ts +26 -0
- package/dist/controls/tabs/tab.js +67 -0
- package/dist/controls/tabs/tabs.css +181 -0
- package/dist/controls/tabs/tabs.d.ts +61 -0
- package/dist/controls/tabs/tabs.js +176 -0
- package/dist/controls/text/href.d.ts +26 -0
- package/dist/controls/text/href.js +48 -0
- package/dist/controls/text/text.css +328 -0
- package/dist/controls/text/text.d.ts +85 -0
- package/dist/controls/text/text.js +273 -0
- package/dist/controls/text-field/text-field.css +498 -0
- package/dist/controls/text-field/text-field.d.ts +226 -0
- package/dist/controls/text-field/text-field.js +905 -0
- package/dist/controls/textarea/textarea.css +251 -0
- package/dist/controls/textarea/textarea.d.ts +136 -0
- package/dist/controls/textarea/textarea.js +312 -0
- package/dist/controls/theme-provider/theme-provider.css +48 -0
- package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
- package/dist/controls/theme-provider/theme-provider.js +40 -0
- package/dist/controls/timeline/timeline.css +33 -0
- package/dist/controls/timeline/timeline.d.ts +39 -0
- package/dist/controls/timeline/timeline.js +52 -0
- package/dist/controls/timeline-item/timeline-item.css +495 -0
- package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
- package/dist/controls/timeline-item/timeline-item.js +360 -0
- package/dist/controls/toast/toast-region.css +56 -0
- package/dist/controls/toast/toast-region.d.ts +20 -0
- package/dist/controls/toast/toast-region.js +113 -0
- package/dist/controls/toast/toast.css +96 -0
- package/dist/controls/toast/toast.d.ts +28 -0
- package/dist/controls/toast/toast.js +191 -0
- package/dist/controls/toolbar/toolbar.css +125 -0
- package/dist/controls/toolbar/toolbar.d.ts +108 -0
- package/dist/controls/toolbar/toolbar.js +68 -0
- package/dist/controls/tooltip/tooltip.css +80 -0
- package/dist/controls/tooltip/tooltip.d.ts +57 -0
- package/dist/controls/tooltip/tooltip.js +205 -0
- package/dist/descriptor/component-descriptor.d.ts +112 -0
- package/dist/descriptor/component-descriptor.js +492 -0
- package/dist/descriptor/index.d.ts +1 -0
- package/dist/descriptor/index.js +5 -0
- package/dist/dom/container.d.ts +45 -0
- package/dist/dom/container.js +60 -0
- package/dist/dom/element.d.ts +98 -0
- package/dist/dom/element.js +221 -0
- package/dist/dom/form.d.ts +240 -0
- package/dist/dom/form.js +399 -0
- package/dist/dom/index.d.ts +10 -0
- package/dist/dom/index.js +31 -0
- package/dist/dom/props.d.ts +56 -0
- package/dist/dom/props.js +231 -0
- package/dist/dom/repeat.d.ts +10 -0
- package/dist/dom/repeat.js +202 -0
- package/dist/dom/template.d.ts +161 -0
- package/dist/dom/template.js +634 -0
- package/dist/dom/watch.d.ts +11 -0
- package/dist/dom/watch.js +82 -0
- package/dist/foundation-styles.css +9 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/reactive/graph.d.ts +65 -0
- package/dist/reactive/graph.js +310 -0
- package/dist/reactive/index.d.ts +3 -0
- package/dist/reactive/index.js +4 -0
- package/dist/reactive/scheduler.d.ts +13 -0
- package/dist/reactive/scheduler.js +49 -0
- package/dist/traits/area-drag.d.ts +12 -0
- package/dist/traits/area-drag.js +70 -0
- package/dist/traits/form-registry.d.ts +36 -0
- package/dist/traits/form-registry.js +108 -0
- package/dist/traits/index.d.ts +24 -0
- package/dist/traits/index.js +19 -0
- package/dist/traits/overlay.d.ts +50 -0
- package/dist/traits/overlay.js +277 -0
- package/dist/traits/pane-resize.d.ts +26 -0
- package/dist/traits/pane-resize.js +103 -0
- package/dist/traits/press-activation.d.ts +11 -0
- package/dist/traits/press-activation.js +55 -0
- package/dist/traits/roving-focus.d.ts +52 -0
- package/dist/traits/roving-focus.js +214 -0
- package/dist/traits/scroll-fade.d.ts +28 -0
- package/dist/traits/scroll-fade.js +120 -0
- package/dist/traits/selection-commit.d.ts +30 -0
- package/dist/traits/selection-commit.js +146 -0
- package/dist/traits/tabbable.d.ts +12 -0
- package/dist/traits/tabbable.js +59 -0
- package/dist/traits/track-user-invalid.d.ts +36 -0
- package/dist/traits/track-user-invalid.js +52 -0
- package/dist/traits/value-codec.d.ts +120 -0
- package/dist/traits/value-codec.js +497 -0
- package/dist/traits/value-drag.d.ts +18 -0
- package/dist/traits/value-drag.js +84 -0
- package/package.json +263 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// value-codec.ts — the valueCodec controller: format-on-blur + parse-on-commit for display-codec fields
|
|
2
|
+
// (Wave 3 ui-text-field type=number/currency — ADR-0044). Rebuilt in Wave 5A (ADR-0047) on a shared
|
|
3
|
+
// numeric core for multi-currency fraction digits + unit/percent codecs. Extended in Wave 5B (ADR-0048)
|
|
4
|
+
// with date and time codecs (dateCodecOptions / timeCodecOptions).
|
|
5
|
+
//
|
|
6
|
+
// Codec-agnostic: parse and format are caller-supplied. The trait manages the display↔canonical split:
|
|
7
|
+
// • Focus (capture on host, fires BEFORE the control's direct editor focus listener): host.value ←
|
|
8
|
+
// canonical.value (raw, editable form value — e.g. "1234.56"). Using capture ensures the control's
|
|
9
|
+
// own focus handler (#committed ← this.value) records the canonical, not the formatted display, so
|
|
10
|
+
// a focus→no-change→blur cycle does not emit a spurious `change` event.
|
|
11
|
+
// • Blur (direct on editor, fires AFTER the control's own blur listener): parse(display) → if ok:
|
|
12
|
+
// canonical ← parsed; host.value ← format(parsed) (e.g. "1,234.56"); if err: hasError ← true.
|
|
13
|
+
// • The control's formValue() override returns canonical.value — host.value after blur is the
|
|
14
|
+
// formatted display string, NOT the canonical submission value.
|
|
15
|
+
// • hasError drives the control's formValidity() customError verdict.
|
|
16
|
+
// • setCanonical(v): used by affordances (clear, steppers) that change the value programmatically
|
|
17
|
+
// outside the normal blur path, so the canonical stays in sync.
|
|
18
|
+
//
|
|
19
|
+
// TKT-0023 — the unfocused-write resync (root-cause fix, not a consumer-facing method): a THIRD reactive
|
|
20
|
+
// path, alongside focus/blur, watches host.value directly so a programmatic `host.value = …` write reaches
|
|
21
|
+
// canonical without waiting for a real blur. It is its OWN effect() (not nested in the control's type-effect
|
|
22
|
+
// — see the untracked() seed note below for why that separation matters), so it tracks host.value on its
|
|
23
|
+
// own graph node:
|
|
24
|
+
// • First run (at attach/type-switch) is a no-op — the seed line above already copied host.value into
|
|
25
|
+
// canonical for this same run; re-deriving it here would risk flipping hasError on a bad *seeded*
|
|
26
|
+
// value that the pre-fix code never flagged before an interaction. Out of scope for this fix.
|
|
27
|
+
// • While FOCUSED (the codec's own `focused` flag, set by the same onFocus/onBlur pair — NOT
|
|
28
|
+
// document.activeElement, so the seam matches how focus/blur are actually driven: synthetic dispatch
|
|
29
|
+
// in tests, real focus in the browser): SKIPPED. The documented semantic (TKT-0023 acceptance,
|
|
30
|
+
// mid-edit case) is display-is-source-of-truth while the user is mid-edit — canonical still resyncs
|
|
31
|
+
// on the NEXT blur, same as any typed edit. A programmatic write while focused updates the surface
|
|
32
|
+
// (the existing model→surface effect) but does not fight the in-flight edit.
|
|
33
|
+
// • While UNFOCUSED: resync canonical exactly as a blur would — '' short-circuits to canonical=''/
|
|
34
|
+
// hasError=false (the same "known-good empty" precedent setCanonical's callers already rely on, e.g.
|
|
35
|
+
// the clear button); non-empty parses through opts.parse, mirroring onBlur's success/failure branches
|
|
36
|
+
// (parse failure sets hasError, canonical stays untouched — never a silent wrong-canonical).
|
|
37
|
+
// • Never rewrites host.value/display — the model→surface caret-guard effect already reflects the raw
|
|
38
|
+
// written value; reformatting-on-write is not part of this fix's contract (only blur reformats).
|
|
39
|
+
//
|
|
40
|
+
// Lifetime: listeners ride host.listen() → the connection AbortSignal → zero residue on disconnect.
|
|
41
|
+
// release() is the idempotent early-teardown guard; the control calls it from the type-change effect
|
|
42
|
+
// cleanup or from disconnected(). The unfocused-write effect above is disposed by the SAME release() call.
|
|
43
|
+
//
|
|
44
|
+
// Layering: traits → reactive (L0) + dom (L1) — downward imports, import-layering trip-wire holds.
|
|
45
|
+
import { signal, untracked, effect } from "../reactive/index.js";
|
|
46
|
+
// ── valueCodec ────────────────────────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Attach the display-codec behaviour to a form control. Invoke from connected(), AFTER the control
|
|
49
|
+
* has created its [data-part="editor"] child (so the editor is present for listener registration).
|
|
50
|
+
*
|
|
51
|
+
* @param host The control — must have a `value` reactive prop and a `[data-part="editor"]` child.
|
|
52
|
+
* @param opts The codec: parse / format / errorMessage.
|
|
53
|
+
* @param typeSignal Optional per-type-run `AbortSignal` (from the type-effect's `AbortController`). When
|
|
54
|
+
* provided, both the focus (capture on host) and blur (on editor) listeners are tied
|
|
55
|
+
* to THIS signal, so the type-effect's `typeAc.abort()` removes them immediately on
|
|
56
|
+
* type change. Without the signal, they fall back to `host.listen()` (connection scope)
|
|
57
|
+
* and live until disconnect — which leaks N−1 inert listener pairs after N type-switches.
|
|
58
|
+
* Wave 5B date/time codecs MUST pass `typeAc.signal` on the same seam.
|
|
59
|
+
* @returns Controller: canonical + hasError + errorMessage + setCanonical + release.
|
|
60
|
+
*/
|
|
61
|
+
export function valueCodec(host, opts, typeSignal) {
|
|
62
|
+
let released = false;
|
|
63
|
+
// Seed canonical from the host's current value so a pre-filled field round-trips on first blur.
|
|
64
|
+
// untracked: this read must not create a dependency in any calling effect — the type-effect should
|
|
65
|
+
// only re-run on type change, not on every value write.
|
|
66
|
+
const canonical = signal(untracked(() => host.value));
|
|
67
|
+
const hasError = signal(false);
|
|
68
|
+
const editor = host.querySelector('[data-part="editor"]');
|
|
69
|
+
// The codec's own focus-tracking flag — driven by the SAME onFocus/onBlur pair below, not
|
|
70
|
+
// document.activeElement (this control is exercised by synthetic focus/blur dispatch throughout the
|
|
71
|
+
// test suite, which never moves real DOM focus). The unfocused-write resync effect (below) reads this.
|
|
72
|
+
let focused = false;
|
|
73
|
+
// Focus (captured on host): fires in the capture phase BEFORE the control's direct-on-editor focus
|
|
74
|
+
// listener. This ensures host.value is reverted to canonical BEFORE the control records #committed,
|
|
75
|
+
// so a focus→no-change→blur cycle does not emit a spurious change event.
|
|
76
|
+
const onFocus = (event) => {
|
|
77
|
+
if (released)
|
|
78
|
+
return;
|
|
79
|
+
// Guard: only the editor's own focus, not a descendant button (e.g. reveal toggle) getting focus.
|
|
80
|
+
if (editor && event.target !== editor)
|
|
81
|
+
return;
|
|
82
|
+
focused = true;
|
|
83
|
+
if (!hasError.value)
|
|
84
|
+
host.value = canonical.value;
|
|
85
|
+
};
|
|
86
|
+
// Blur (direct on editor): fires AFTER the control's own blur listener (same element, later
|
|
87
|
+
// registration = later in dispatch order). The control's change-on-commit fires first, then the
|
|
88
|
+
// codec reformats the display — no spurious second change from host.value being rewritten.
|
|
89
|
+
const onBlur = () => {
|
|
90
|
+
if (released)
|
|
91
|
+
return;
|
|
92
|
+
focused = false;
|
|
93
|
+
const parsed = opts.parse(host.value);
|
|
94
|
+
if (parsed === null) {
|
|
95
|
+
hasError.value = true;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
hasError.value = false;
|
|
99
|
+
canonical.value = parsed;
|
|
100
|
+
// Writing host.value triggers the control's model→surface caret-guard effect, which syncs the
|
|
101
|
+
// editor's textContent. Safe on blur — no active caret to protect at this point.
|
|
102
|
+
host.value = opts.format(parsed);
|
|
103
|
+
};
|
|
104
|
+
// TKT-0023 — the unfocused-write resync: an OWN effect() (not nested inside the control's type-effect),
|
|
105
|
+
// so its tracked read of host.value never becomes a dependency of the caller's outer effect (the same
|
|
106
|
+
// untracked-read discipline the seed line above protects — see the header note). Skips its own first
|
|
107
|
+
// run (the seed already covers it), skips entirely while focused (the documented mid-edit semantic —
|
|
108
|
+
// canonical catches up on the next blur), and otherwise mirrors blur's parse/hasError contract without
|
|
109
|
+
// ever touching host.value (no reformat-on-write — only blur reformats the display).
|
|
110
|
+
let firstRun = true;
|
|
111
|
+
const disposeResync = effect(() => {
|
|
112
|
+
const v = host.value; // tracked — the whole point: wake on ANY host.value write, typed or programmatic
|
|
113
|
+
if (released)
|
|
114
|
+
return;
|
|
115
|
+
// firstRun is consumed BEFORE the isConnected early-return (review L1): if the seed run ever executed
|
|
116
|
+
// while disconnected, a still-armed firstRun would otherwise silently swallow the first REAL
|
|
117
|
+
// post-connect write. Unreachable from the shipped control today (valueCodec runs inside connected()'s
|
|
118
|
+
// type-effect), but the ordering costs nothing and removes the latent trap.
|
|
119
|
+
if (firstRun) {
|
|
120
|
+
firstRun = false;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Defense-in-depth for the no-typeSignal fallback path (this function's only caller without a real
|
|
124
|
+
// control wrapping it is the trait-level unit test): host.listen()'s fallback listeners ride the
|
|
125
|
+
// connection AbortSignal, which this free-standing effect() has no access to (host.connectionSignal
|
|
126
|
+
// is `protected`— a trait cannot reach it, same reasoning as the file header's `internals` note). An
|
|
127
|
+
// isConnected guard gives the same observable outcome — zero state changes once the host is gone —
|
|
128
|
+
// without widening UIElement's protected surface for a defensive-only need.
|
|
129
|
+
if (!host.isConnected)
|
|
130
|
+
return;
|
|
131
|
+
if (focused)
|
|
132
|
+
return; // mid-edit: display is source of truth; canonical resyncs on the existing blur path
|
|
133
|
+
if (v === '') {
|
|
134
|
+
// The same "known-good empty" precedent setCanonical's internal callers rely on (clear button et al.).
|
|
135
|
+
canonical.value = '';
|
|
136
|
+
hasError.value = false;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parsed = opts.parse(v);
|
|
140
|
+
if (parsed === null) {
|
|
141
|
+
hasError.value = true; // mirrors onBlur: canonical stays untouched, let the caller correct it
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
hasError.value = false;
|
|
145
|
+
canonical.value = parsed;
|
|
146
|
+
});
|
|
147
|
+
if (editor) {
|
|
148
|
+
if (typeSignal) {
|
|
149
|
+
// Per-type-run signal: listeners die when typeAc.abort() fires (type change or disconnect),
|
|
150
|
+
// so each type-switch leaves exactly zero stale codec listeners rather than accumulating
|
|
151
|
+
// N−1 inert pairs for N numeric type-switches (the C10 root-cause fix — M1).
|
|
152
|
+
host.addEventListener('focus', onFocus, { capture: true, signal: typeSignal });
|
|
153
|
+
editor.addEventListener('blur', onBlur, { signal: typeSignal });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Fallback (no per-type signal): listeners ride the connection AbortSignal via host.listen(),
|
|
157
|
+
// dying on disconnect. Callers that can provide a finer-grained signal SHOULD (Wave 5B seam).
|
|
158
|
+
host.listen(host, 'focus', onFocus, { capture: true });
|
|
159
|
+
host.listen(editor, 'blur', onBlur);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
canonical,
|
|
164
|
+
hasError,
|
|
165
|
+
errorMessage: opts.errorMessage,
|
|
166
|
+
setCanonical: (value) => {
|
|
167
|
+
canonical.value = value;
|
|
168
|
+
hasError.value = false;
|
|
169
|
+
},
|
|
170
|
+
release: () => {
|
|
171
|
+
released = true;
|
|
172
|
+
disposeResync(); // idempotent (EffectNode.dispose) — tears down the unfocused-write resync effect too
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// ── Shared numeric core (Wave 5A, ADR-0047) ───────────────────────────────────
|
|
177
|
+
//
|
|
178
|
+
// All numeric codecs (number / currency / unit) share one parse+format core: strip to [digits · decimal ·
|
|
179
|
+
// minus], parseFloat, Intl.NumberFormat. The only axis that varies per codec is the fraction-digit
|
|
180
|
+
// constraint (free for number/unit, per-currency for currency) and the error message. A single factory
|
|
181
|
+
// assembles the codec from those scalars, keeping the boundary logic (the string↔number crossing) in
|
|
182
|
+
// one place and verifiable in one test.
|
|
183
|
+
//
|
|
184
|
+
// Locale caveat (recorded, ADR-0044/ADR-0047): the strip-to-digits parse is robust for grouping
|
|
185
|
+
// separators but loose for locales where the decimal separator is "," ("1.234,56" strips to "1.234.56" →
|
|
186
|
+
// parseFloat("1.234") = 1.234 — a rounded parse). Pin locale='en-US' in tests for determinism.
|
|
187
|
+
function numericCodecCore(opts) {
|
|
188
|
+
const fmtOpts = {};
|
|
189
|
+
if (opts.minFraction !== undefined)
|
|
190
|
+
fmtOpts.minimumFractionDigits = opts.minFraction;
|
|
191
|
+
if (opts.maxFraction !== undefined)
|
|
192
|
+
fmtOpts.maximumFractionDigits = opts.maxFraction;
|
|
193
|
+
const fmt = new Intl.NumberFormat(opts.locale, fmtOpts);
|
|
194
|
+
return {
|
|
195
|
+
parse(display) {
|
|
196
|
+
// Strip everything that is not a digit, decimal point, or minus sign — handles locale grouping
|
|
197
|
+
// separators (e.g. "1,234.56" → "1234.56", "1.234,56" → "1234.56" for simple decimal stripping).
|
|
198
|
+
const cleaned = display.replace(/[^\d.\-]/g, '');
|
|
199
|
+
const n = parseFloat(cleaned);
|
|
200
|
+
return Number.isFinite(n) ? String(n) : null;
|
|
201
|
+
},
|
|
202
|
+
format(value) {
|
|
203
|
+
const n = parseFloat(value);
|
|
204
|
+
return Number.isFinite(n) ? fmt.format(n) : value;
|
|
205
|
+
},
|
|
206
|
+
errorMessage: opts.errorMessage ?? 'Please enter a valid number.',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// ── Built-in codec factories (Intl.NumberFormat-based, zero-dep / native) ────
|
|
210
|
+
/**
|
|
211
|
+
* Codec options for type=number. Parses by stripping grouping separators (accepts "1,234.56" → 1234.56);
|
|
212
|
+
* formats with Intl.NumberFormat (locale-aware grouping). Accepts both raw and formatted display strings.
|
|
213
|
+
*
|
|
214
|
+
* @param locale BCP 47 locale (default: runtime locale). Pin to 'en-US' in tests for determinism.
|
|
215
|
+
*/
|
|
216
|
+
export function numberCodecOptions(locale) {
|
|
217
|
+
return numericCodecCore({ locale });
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Codec options for type=currency. Parses like numberCodecOptions; formats with the per-currency
|
|
221
|
+
* fraction-digit count resolved from Intl (USD 2 · JPY 0 · BHD 3). The currency symbol is displayed
|
|
222
|
+
* as the control's leading adornment (not embedded in the formatted number string) — use
|
|
223
|
+
* `currencySymbol()` to resolve the glyph separately.
|
|
224
|
+
*
|
|
225
|
+
* @param currency ISO 4217 currency code (e.g. 'USD', 'JPY', 'BHD'). Falls back to 2 fraction digits
|
|
226
|
+
* on an invalid code (Intl throws a RangeError — caught defensively).
|
|
227
|
+
* @param locale BCP 47 locale (default: runtime locale).
|
|
228
|
+
*/
|
|
229
|
+
export function currencyCodecOptions(currency, locale) {
|
|
230
|
+
// Resolve the per-currency fraction-digit count: USD → 2, JPY → 0, BHD → 3 (ISO 4217 minor units).
|
|
231
|
+
// Intl resolves these natively — no hardcoded table needed.
|
|
232
|
+
let minFrac = 2;
|
|
233
|
+
let maxFrac = 2;
|
|
234
|
+
try {
|
|
235
|
+
const resolved = new Intl.NumberFormat(locale, { style: 'currency', currency }).resolvedOptions();
|
|
236
|
+
minFrac = resolved.minimumFractionDigits ?? 2;
|
|
237
|
+
maxFrac = resolved.maximumFractionDigits ?? 2;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// Invalid currency code → fall back to 2 fraction digits (the monetary precision convention).
|
|
241
|
+
}
|
|
242
|
+
return numericCodecCore({ locale, minFraction: minFrac, maxFraction: maxFrac, errorMessage: 'Please enter a valid amount.' });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Codec options for type=unit. Delegates to the numeric core today; the `unit` param is RESERVED for
|
|
246
|
+
* future per-unit precision (e.g. integer-only for °C vs. many decimals for currency ratios). The
|
|
247
|
+
* unit-specificity that matters today is the trailing adornment label, not the parse/format — so there
|
|
248
|
+
* is zero distinct numeric behaviour, and this is an honest reserved seam (ADR-0047 alt.2).
|
|
249
|
+
*
|
|
250
|
+
* @param unit CLDR unit identifier (reserved — no numeric effect today).
|
|
251
|
+
* @param locale BCP 47 locale (default: runtime locale).
|
|
252
|
+
*/
|
|
253
|
+
export function unitCodecOptions(unit, locale) {
|
|
254
|
+
void unit; // reserved — the unit specificity that matters today is the adornment label
|
|
255
|
+
return numericCodecCore({ locale });
|
|
256
|
+
}
|
|
257
|
+
// ── Label helpers (co-located with the Intl logic — Wave 5A, ADR-0047) ────────
|
|
258
|
+
/**
|
|
259
|
+
* Extract the narrow currency symbol for the given ISO 4217 currency code (e.g. 'USD' → '$',
|
|
260
|
+
* 'JPY' → '¥', 'EUR' → '€'). Uses `currencyDisplay: 'narrowSymbol'` for the compact glyph.
|
|
261
|
+
* Falls back to the currency string itself on Intl error (invalid code or unsupported locale feature).
|
|
262
|
+
*
|
|
263
|
+
* @param currency ISO 4217 currency code.
|
|
264
|
+
* @param locale BCP 47 locale (default: runtime locale). Affects symbol variants in some locales.
|
|
265
|
+
*/
|
|
266
|
+
export function currencySymbol(currency, locale) {
|
|
267
|
+
try {
|
|
268
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
269
|
+
style: 'currency',
|
|
270
|
+
currency,
|
|
271
|
+
currencyDisplay: 'narrowSymbol',
|
|
272
|
+
}).formatToParts(0);
|
|
273
|
+
return parts.find((p) => p.type === 'currency')?.value ?? currency;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return currency;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the short localized unit label for a CLDR unit identifier (e.g. 'kilogram' → 'kg',
|
|
281
|
+
* 'mile-per-hour' → 'mph'). Falls back to the raw `unit` string if Intl rejects it (an invalid CLDR
|
|
282
|
+
* id raises a RangeError — caught defensively). The trimmed `unit` part of `formatToParts(1)` is used
|
|
283
|
+
* to extract just the label without the numeric value.
|
|
284
|
+
*
|
|
285
|
+
* @param unit CLDR unit identifier (e.g. 'kilogram', 'mile-per-hour').
|
|
286
|
+
* @param locale BCP 47 locale (default: runtime locale).
|
|
287
|
+
*/
|
|
288
|
+
export function unitLabel(unit, locale) {
|
|
289
|
+
if (!unit)
|
|
290
|
+
return '';
|
|
291
|
+
try {
|
|
292
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
293
|
+
style: 'unit',
|
|
294
|
+
unit,
|
|
295
|
+
unitDisplay: 'short',
|
|
296
|
+
}).formatToParts(1);
|
|
297
|
+
return parts.find((p) => p.type === 'unit')?.value?.trim() ?? unit;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Intl throws a RangeError for invalid CLDR unit ids → use the raw string as the suffix.
|
|
301
|
+
return unit;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// ── Date/time codecs (Wave 5B, ADR-0048) ─────────────────────────────────────
|
|
305
|
+
//
|
|
306
|
+
// Both codecs share the same parse→format contract as the numeric codecs: `format` takes the
|
|
307
|
+
// canonical form value and returns a localized display string; `parse` takes the display string
|
|
308
|
+
// and returns the canonical form (or null on invalid input). They plug into the same
|
|
309
|
+
// valueCodec(host, opts, typeSignal) seam with no new listener wiring — 5B-3 passes
|
|
310
|
+
// typeAc.signal identically to the numeric codecs.
|
|
311
|
+
//
|
|
312
|
+
// TIMEZONE NOTE (date codec): Never use new Date('YYYY-MM-DD') — it parses as UTC midnight,
|
|
313
|
+
// which at negative UTC offsets (e.g. US Pacific = UTC-8) renders the PREVIOUS calendar day.
|
|
314
|
+
// Throughout this codec, dates are constructed with new Date(year, month-1, day) (local time)
|
|
315
|
+
// and read back with getFullYear() / getMonth() / getDate() (local time).
|
|
316
|
+
//
|
|
317
|
+
// Locale caveat (date parse): Intl has NO parser. The codec accepts ISO reliably (step 1).
|
|
318
|
+
// The locale heuristic (step 2: formatToParts learns y/m/d order from a reference date, then
|
|
319
|
+
// splits the typed input on delimiters) handles numeric date entry. Named-month input falls
|
|
320
|
+
// through to new Date(display) as a last resort (step 3). null on unrecognised input. The
|
|
321
|
+
// calendar is the authoritative entry path; typing is intentionally lenient (ADR-0048 §4).
|
|
322
|
+
/**
|
|
323
|
+
* Codec options for type=date. Canonical form: ISO `YYYY-MM-DD`. Format: localized medium
|
|
324
|
+
* date string via Intl.DateTimeFormat `{dateStyle:'medium'}`. Parse: ISO always accepted
|
|
325
|
+
* (timezone-safe local-time constructor); locale heuristic for numeric inputs (e.g. "03/04/2024"
|
|
326
|
+
* in en-US → 2024-03-04); last-resort new Date() for named-month forms; null on invalid.
|
|
327
|
+
* Error message: 'Please enter a valid date.'
|
|
328
|
+
*
|
|
329
|
+
* @param locale BCP 47 locale (default: runtime locale). Pin to 'en-US' in tests for determinism.
|
|
330
|
+
*/
|
|
331
|
+
export function dateCodecOptions(locale) {
|
|
332
|
+
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
|
|
333
|
+
// Learn the component ORDER (year/month/day) from a reference date whose digits are all
|
|
334
|
+
// distinct: 2001-03-04 (year=2001, month=3, day=4). Named month in the formatToParts output
|
|
335
|
+
// is irrelevant — we only need the positional ordering, which is locale-specific (en-US is
|
|
336
|
+
// month/day/year; de-DE is day/month/year).
|
|
337
|
+
const componentOrder = fmt
|
|
338
|
+
.formatToParts(new Date(2001, 2, 4)) // local-time constructor for 2001-03-04
|
|
339
|
+
.filter((p) => p.type === 'year' || p.type === 'month' || p.type === 'day')
|
|
340
|
+
.map((p) => p.type);
|
|
341
|
+
/** Parse an ISO YYYY-MM-DD string to a local-time Date (timezone-safe). Returns null on invalid. */
|
|
342
|
+
function parseIso(str) {
|
|
343
|
+
const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
344
|
+
if (!m)
|
|
345
|
+
return null;
|
|
346
|
+
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
|
347
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31)
|
|
348
|
+
return null;
|
|
349
|
+
// Construct in LOCAL time to avoid the UTC-midnight / timezone off-by-one trap.
|
|
350
|
+
const date = new Date(y, mo - 1, d);
|
|
351
|
+
// Round-trip validation: JS Date overflow wraps invalid days (e.g. Feb 29 in a non-leap
|
|
352
|
+
// year becomes Mar 1). A mismatch between what we asked for and what we got means the
|
|
353
|
+
// input day/month exceeded the calendar — reject it.
|
|
354
|
+
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d)
|
|
355
|
+
return null;
|
|
356
|
+
return date;
|
|
357
|
+
}
|
|
358
|
+
/** Serialize a local-time Date to an ISO YYYY-MM-DD string. */
|
|
359
|
+
function dateToIso(date) {
|
|
360
|
+
const y = date.getFullYear();
|
|
361
|
+
const mo = String(date.getMonth() + 1).padStart(2, '0');
|
|
362
|
+
const d = String(date.getDate()).padStart(2, '0');
|
|
363
|
+
return `${y}-${mo}-${d}`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Locale heuristic for numeric inputs (e.g. "03/04/2024" in en-US → 2024-03-04).
|
|
367
|
+
* Splits on any delimiter and maps three numeric tokens to y/m/d using the component order
|
|
368
|
+
* learned from formatToParts. Named-month tokens fail parseInt → returns null, letting the
|
|
369
|
+
* caller fall through to the new Date() last-resort path.
|
|
370
|
+
*/
|
|
371
|
+
function parseWithHeuristic(display) {
|
|
372
|
+
const tokens = display.split(/[\s/\-.,:]+/).filter(Boolean);
|
|
373
|
+
if (tokens.length !== 3 || componentOrder.length !== 3)
|
|
374
|
+
return null;
|
|
375
|
+
let y = 0, mo = 0, d = 0;
|
|
376
|
+
for (let i = 0; i < 3; i++) {
|
|
377
|
+
const n = parseInt(tokens[i], 10);
|
|
378
|
+
if (!Number.isFinite(n))
|
|
379
|
+
return null;
|
|
380
|
+
if (componentOrder[i] === 'year')
|
|
381
|
+
y = n;
|
|
382
|
+
else if (componentOrder[i] === 'month')
|
|
383
|
+
mo = n;
|
|
384
|
+
else
|
|
385
|
+
d = n;
|
|
386
|
+
}
|
|
387
|
+
if (!y || !mo || !d)
|
|
388
|
+
return null;
|
|
389
|
+
const date = new Date(y, mo - 1, d);
|
|
390
|
+
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d)
|
|
391
|
+
return null;
|
|
392
|
+
return date;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
parse(display) {
|
|
396
|
+
const trimmed = display.trim();
|
|
397
|
+
if (!trimmed)
|
|
398
|
+
return null;
|
|
399
|
+
// 1. ISO — always accepted; timezone-safe construction.
|
|
400
|
+
const fromIso = parseIso(trimmed);
|
|
401
|
+
if (fromIso)
|
|
402
|
+
return dateToIso(fromIso);
|
|
403
|
+
// 2. Locale heuristic — numeric month/day/year in locale component order.
|
|
404
|
+
const fromHeuristic = parseWithHeuristic(trimmed);
|
|
405
|
+
if (fromHeuristic)
|
|
406
|
+
return dateToIso(fromHeuristic);
|
|
407
|
+
// 3. Last-resort fallback — new Date() handles named-month forms ("Jul 4, 2024").
|
|
408
|
+
// Non-ISO strings parse in LOCAL time in V8 (not UTC), so getDate() is timezone-safe.
|
|
409
|
+
// Guard: skip if the input looks like ISO (step 1 already rejected it). Without this
|
|
410
|
+
// guard, V8 normalizes invalid ISO dates ("2023-02-29" → Feb 28) instead of returning
|
|
411
|
+
// Invalid Date, silently producing a wrong canonical.
|
|
412
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
413
|
+
const fromNative = new Date(trimmed);
|
|
414
|
+
if (!Number.isNaN(fromNative.getTime()))
|
|
415
|
+
return dateToIso(fromNative);
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
},
|
|
419
|
+
format(iso) {
|
|
420
|
+
const date = parseIso(iso);
|
|
421
|
+
return date ? fmt.format(date) : iso;
|
|
422
|
+
},
|
|
423
|
+
errorMessage: 'Please enter a valid date.',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Codec options for type=time. Canonical form: `HH:MM` (24h, zero-padded). Format: localized
|
|
428
|
+
* short time string via Intl.DateTimeFormat `{timeStyle:'short'}`. Parse: accepts HH:MM (24h)
|
|
429
|
+
* and H:MM AM/PM (best-effort 12h → 24h conversion, handles en-US Intl output including
|
|
430
|
+
* narrow no-break space variants); null on invalid. Error message: 'Please enter a valid time.'
|
|
431
|
+
*
|
|
432
|
+
* @param locale BCP 47 locale (default: runtime locale). Pin to 'en-US' in tests for determinism.
|
|
433
|
+
*/
|
|
434
|
+
export function timeCodecOptions(locale) {
|
|
435
|
+
const fmt = new Intl.DateTimeFormat(locale, { timeStyle: 'short' });
|
|
436
|
+
/** Zero-pad hours and minutes to HH:MM canonical form. */
|
|
437
|
+
function toHHMM(h, m) {
|
|
438
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
parse(display) {
|
|
442
|
+
const trimmed = display.trim();
|
|
443
|
+
if (!trimmed)
|
|
444
|
+
return null;
|
|
445
|
+
// 1. HH:MM (24h) — canonical form; always accepted as typed.
|
|
446
|
+
const m24 = trimmed.match(/^(\d{1,2}):(\d{2})$/);
|
|
447
|
+
if (m24) {
|
|
448
|
+
const h = Number(m24[1]), m = Number(m24[2]);
|
|
449
|
+
if (h <= 23 && m <= 59)
|
|
450
|
+
return toHHMM(h, m);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
// 2. H:MM AM/PM — best-effort 12h → 24h conversion.
|
|
454
|
+
// \s* matches both regular space and narrow no-break space (U+202F), which some
|
|
455
|
+
// ICU versions emit between the time and the AM/PM period (Unicode CLDR change).
|
|
456
|
+
const m12 = trimmed.match(/^(\d{1,2}):(\d{2})\s*(am|pm)$/i);
|
|
457
|
+
if (m12) {
|
|
458
|
+
let h = Number(m12[1]);
|
|
459
|
+
const m = Number(m12[2]);
|
|
460
|
+
const period = m12[3].toLowerCase();
|
|
461
|
+
if (m > 59)
|
|
462
|
+
return null;
|
|
463
|
+
if (period === 'am') {
|
|
464
|
+
if (h === 12)
|
|
465
|
+
h = 0; // 12:xx AM → 00:xx (midnight)
|
|
466
|
+
else if (h > 12)
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
if (h === 12) { /* 12:xx PM → stays 12 (noon) */ }
|
|
471
|
+
else if (h < 12)
|
|
472
|
+
h += 12; // 1..11 PM → 13..23
|
|
473
|
+
else
|
|
474
|
+
return null; // h > 12 PM is invalid
|
|
475
|
+
}
|
|
476
|
+
if (h > 23)
|
|
477
|
+
return null;
|
|
478
|
+
return toHHMM(h, m);
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
},
|
|
482
|
+
format(canonical) {
|
|
483
|
+
const match = canonical.match(/^(\d{2}):(\d{2})$/);
|
|
484
|
+
if (!match)
|
|
485
|
+
return canonical;
|
|
486
|
+
const h = Number(match[1]), m = Number(match[2]);
|
|
487
|
+
if (h > 23 || m > 59)
|
|
488
|
+
return canonical;
|
|
489
|
+
// Construct in local time on a fixed reference date so DST transitions don't shift
|
|
490
|
+
// the displayed hour (the specific calendar date Jan 1 2000 is arbitrary — it is
|
|
491
|
+
// never shown, only the time component is formatted).
|
|
492
|
+
const date = new Date(2000, 0, 1, h, m, 0, 0);
|
|
493
|
+
return fmt.format(date);
|
|
494
|
+
},
|
|
495
|
+
errorMessage: 'Please enter a valid time.',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export interface ValueDragOptions {
|
|
3
|
+
/** Live accessor returning the track element (the draggable rail). Re-read on each pointerdown. */
|
|
4
|
+
track: () => HTMLElement | null;
|
|
5
|
+
/** Live accessor returning the range minimum. */
|
|
6
|
+
min: () => number;
|
|
7
|
+
/** Live accessor returning the range maximum. */
|
|
8
|
+
max: () => number;
|
|
9
|
+
/** Live accessor returning the step (≤ 0 = continuous, no snap). */
|
|
10
|
+
step: () => number;
|
|
11
|
+
/** Called with the pointer-mapped, step-snapped value during drag (for live `input` events). */
|
|
12
|
+
onValue: (value: number) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Wire pointer→value gesture tracking on a `UIElement` host. Invoke from `connected()` so listeners
|
|
16
|
+
* ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
17
|
+
*/
|
|
18
|
+
export declare function valueDrag(host: UIElement, opts: ValueDragOptions): () => void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// value-drag.ts — the pointer→value gesture controller (range-element LLD-C4). Maps pointer events
|
|
2
|
+
// on a track element to a [min,max] numeric value snapped to `step`. Separable from UIRangeElement
|
|
3
|
+
// so the gesture logic is independently testable and reusable: ui-slider, ui-slider-multi, and any
|
|
4
|
+
// future range-style control all wire this controller.
|
|
5
|
+
//
|
|
6
|
+
// Protocol: pointerdown → setPointerCapture → pointermove (position along track → snapped value
|
|
7
|
+
// → onValue(v)) → pointerup / lostpointercapture / pointercancel (commit, end drag).
|
|
8
|
+
//
|
|
9
|
+
// The outer pointerdown listener rides host.listen (connection-signal-scoped, zero residue on
|
|
10
|
+
// disconnect). Per-drag listeners live on the track under a per-drag AbortController that is
|
|
11
|
+
// aborted on any drag-end event, bounding their lifetime to the drag itself.
|
|
12
|
+
//
|
|
13
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only.
|
|
14
|
+
/** Convert a position ratio [0,1] to a step-snapped value in [min, max]. */
|
|
15
|
+
function snapValue(ratio, min, max, step) {
|
|
16
|
+
if (min >= max)
|
|
17
|
+
return min; // degenerate — zero-length range
|
|
18
|
+
const raw = min + Math.max(0, Math.min(1, ratio)) * (max - min);
|
|
19
|
+
if (step <= 0)
|
|
20
|
+
return raw; // continuous — no snap
|
|
21
|
+
const snapped = Math.round((raw - min) / step) * step + min;
|
|
22
|
+
return Math.max(min, Math.min(max, snapped)); // clamp: handles last-step undershoot
|
|
23
|
+
}
|
|
24
|
+
/** Map a clientX coordinate to a [0,1] ratio along the track rect. */
|
|
25
|
+
function ratioFromX(clientX, rect) {
|
|
26
|
+
if (rect.width <= 0)
|
|
27
|
+
return 0;
|
|
28
|
+
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wire pointer→value gesture tracking on a `UIElement` host. Invoke from `connected()` so listeners
|
|
32
|
+
* ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
33
|
+
*/
|
|
34
|
+
export function valueDrag(host, opts) {
|
|
35
|
+
let released = false;
|
|
36
|
+
// Listen on the host so this listener auto-removes on disconnect (host.listen = connection-signal-
|
|
37
|
+
// scoped). A pointerdown on a track child bubbles up here; opts.track() is re-read on every press.
|
|
38
|
+
host.listen(host, 'pointerdown', (event) => {
|
|
39
|
+
if (released)
|
|
40
|
+
return;
|
|
41
|
+
const track = opts.track();
|
|
42
|
+
if (!track)
|
|
43
|
+
return;
|
|
44
|
+
const pe = event;
|
|
45
|
+
// Ignore presses that did not originate from within the track.
|
|
46
|
+
if (!track.contains(pe.target))
|
|
47
|
+
return;
|
|
48
|
+
const min = opts.min();
|
|
49
|
+
const max = opts.max();
|
|
50
|
+
// Degenerate range — zero-length, no drag (value is pinned to min by the caller).
|
|
51
|
+
if (min >= max)
|
|
52
|
+
return;
|
|
53
|
+
track.setPointerCapture(pe.pointerId);
|
|
54
|
+
// Snapshot the rect on press; re-snapshot on each move (accounts for scroll / resize).
|
|
55
|
+
let rect = track.getBoundingClientRect();
|
|
56
|
+
opts.onValue(snapValue(ratioFromX(pe.clientX, rect), min, max, opts.step()));
|
|
57
|
+
// Per-drag AbortController — aborted on any drag-end event, bounding all drag listeners.
|
|
58
|
+
const dragAc = new AbortController();
|
|
59
|
+
const endDrag = () => { dragAc.abort(); };
|
|
60
|
+
track.addEventListener('pointermove', (e) => {
|
|
61
|
+
const me = e;
|
|
62
|
+
if (me.pointerId !== pe.pointerId)
|
|
63
|
+
return;
|
|
64
|
+
rect = track.getBoundingClientRect();
|
|
65
|
+
opts.onValue(snapValue(ratioFromX(me.clientX, rect), opts.min(), opts.max(), opts.step()));
|
|
66
|
+
}, { signal: dragAc.signal });
|
|
67
|
+
track.addEventListener('pointerup', (e) => {
|
|
68
|
+
if (e.pointerId !== pe.pointerId)
|
|
69
|
+
return;
|
|
70
|
+
endDrag();
|
|
71
|
+
}, { signal: dragAc.signal });
|
|
72
|
+
track.addEventListener('lostpointercapture', (e) => {
|
|
73
|
+
if (e.pointerId !== pe.pointerId)
|
|
74
|
+
return;
|
|
75
|
+
endDrag();
|
|
76
|
+
}, { signal: dragAc.signal });
|
|
77
|
+
track.addEventListener('pointercancel', (e) => {
|
|
78
|
+
if (e.pointerId !== pe.pointerId)
|
|
79
|
+
return;
|
|
80
|
+
endDrag();
|
|
81
|
+
}, { signal: dragAc.signal });
|
|
82
|
+
});
|
|
83
|
+
return () => { released = true; };
|
|
84
|
+
}
|