@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,108 @@
|
|
|
1
|
+
// form-registry.ts — the reactive form-registry CONTROLLER (ADR-0050 §2/§3, LLD-C3): the aggregation half of
|
|
2
|
+
// the connect-time registration mechanism form.ts's `ui-form-connect` protocol event feeds. Invoked from
|
|
3
|
+
// `ui-form-provider`'s `connected()` — the connection scope + AbortSignal are live there, the same
|
|
4
|
+
// `trackUserInvalid` controller pattern — NOT invoked from a plain control.
|
|
5
|
+
//
|
|
6
|
+
// Discovery rides the protocol event alone (no MutationObserver): a control connected under the provider
|
|
7
|
+
// dispatches `ui-form-connect` at the END of its own `connectedCallback` (form.ts), the listener installed
|
|
8
|
+
// here registers it, and `stopPropagation()` scopes membership to the NEAREST provider under nesting.
|
|
9
|
+
// Teardown rides the event's connection-scoped `AbortSignal`, not a second event — a disconnected control
|
|
10
|
+
// cannot dispatch, so the registry subscribes to the handle's `abort` instead (dual-lifetime: the control's
|
|
11
|
+
// OWN disconnect aborts its handle → its entry alone is removed; the PROVIDER's disconnect aborts the HOST
|
|
12
|
+
// signal every one of these listeners rides via `host.listen` → all per-member listeners die at once — zero
|
|
13
|
+
// residue either way, no dead node ever lingers in `members`).
|
|
14
|
+
//
|
|
15
|
+
// The four aggregates (entries/values/invalid/valid) are `computed`s over the plain `members` signal —
|
|
16
|
+
// UN-owned (not wrapped in `scope.run`): a computed's subscriber set lives only while a reading effect
|
|
17
|
+
// subscribes to it (kernel semantics), so residue dies with the READERS, not with this controller. Every
|
|
18
|
+
// add/remove writes a NEW frozen array into `members` (Object.is cutoff ⇒ fine-grained waking for anyone
|
|
19
|
+
// reading a single aggregate, not all four).
|
|
20
|
+
//
|
|
21
|
+
// Layering: traits → reactive (L0) + dom (L1) — both downward, the import-layering trip-wire holds. The
|
|
22
|
+
// runtime `instanceof UIFormElement` guard (not just the TS type) is defense: the event never fires from a
|
|
23
|
+
// non-form child, but the guard makes that a proven negative control (s9), not an assumption.
|
|
24
|
+
import { computed, signal } from "../reactive/index.js";
|
|
25
|
+
import { UIFormElement, FORM_CONNECT_EVENT } from "../dom/index.js";
|
|
26
|
+
/**
|
|
27
|
+
* Attach the reactive form registry to a `ui-form-provider` host. Invoke from `connected()` (connection
|
|
28
|
+
* scope + AbortSignal live). Listens for `ui-form-connect` on the host, registers each control at the
|
|
29
|
+
* NEAREST provider (`stopPropagation`), and deregisters on the control's connection-scoped `abort` — a
|
|
30
|
+
* late-added control is discovered by construction (its own connect dispatch re-fires), a removed one drops
|
|
31
|
+
* out with zero residue.
|
|
32
|
+
*/
|
|
33
|
+
export function formRegistry(host) {
|
|
34
|
+
let released = false;
|
|
35
|
+
const members = signal([]);
|
|
36
|
+
// ── the four aggregates — computeds over `members`, un-owned (kernel semantics; see the header note) ──
|
|
37
|
+
const entries = computed(() => {
|
|
38
|
+
const result = [];
|
|
39
|
+
for (const member of members.value) {
|
|
40
|
+
if (member.control.name === '' || member.control.effectiveDisabled())
|
|
41
|
+
continue;
|
|
42
|
+
const value = member.value();
|
|
43
|
+
if (value === null)
|
|
44
|
+
continue;
|
|
45
|
+
result.push([member.control.name, value]);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
});
|
|
49
|
+
const values = computed(() => {
|
|
50
|
+
const result = {};
|
|
51
|
+
for (const [name, value] of entries.value)
|
|
52
|
+
result[name] = value; // last entry wins on a duplicate name
|
|
53
|
+
return result;
|
|
54
|
+
});
|
|
55
|
+
const invalid = computed(() => {
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const member of members.value) {
|
|
58
|
+
if (member.control.effectiveDisabled())
|
|
59
|
+
continue; // native parity: disabled is barred from validation
|
|
60
|
+
if (!member.validity().valid)
|
|
61
|
+
result.push(member.control);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
});
|
|
65
|
+
const valid = computed(() => invalid.value.length === 0);
|
|
66
|
+
// ── membership writes — always a NEW frozen array (Object.is cutoff) ──────────────────────────────────
|
|
67
|
+
const addMember = (member) => {
|
|
68
|
+
members.value = Object.freeze([...members.value, member]);
|
|
69
|
+
};
|
|
70
|
+
const removeMember = (control) => {
|
|
71
|
+
members.value = Object.freeze(members.value.filter((member) => member.control !== control));
|
|
72
|
+
};
|
|
73
|
+
// ── the connect listener (ADR-0050 §2/§3) ─────────────────────────────────────────────────────────────
|
|
74
|
+
const onConnect = (event) => {
|
|
75
|
+
if (released)
|
|
76
|
+
return;
|
|
77
|
+
const detail = event.detail;
|
|
78
|
+
// Defense, not the discovery path — the event never fires from a non-form child (s9 negative control).
|
|
79
|
+
if (!(detail?.control instanceof UIFormElement))
|
|
80
|
+
return;
|
|
81
|
+
event.stopPropagation(); // nearest-provider scoping — stopped even for a dup/aborted registration below
|
|
82
|
+
if (detail.signal.aborted)
|
|
83
|
+
return; // a stale reconnect handle can never register
|
|
84
|
+
if (members.value.some((member) => member.control === detail.control))
|
|
85
|
+
return; // idempotence guard
|
|
86
|
+
addMember({ control: detail.control, value: detail.value, validity: detail.validity });
|
|
87
|
+
// Dual-lifetime: the control's OWN disconnect aborts `detail.signal` → this fires → the entry is
|
|
88
|
+
// removed. The PROVIDER's disconnect aborts the connection signal `host.listen` rides → this listener
|
|
89
|
+
// itself is removed (no residue on a still-connected control's signal).
|
|
90
|
+
host.listen(detail.signal, 'abort', () => {
|
|
91
|
+
if (released)
|
|
92
|
+
return;
|
|
93
|
+
removeMember(detail.control);
|
|
94
|
+
}, { once: true });
|
|
95
|
+
};
|
|
96
|
+
host.listen(host, FORM_CONNECT_EVENT, onConnect);
|
|
97
|
+
return {
|
|
98
|
+
members,
|
|
99
|
+
entries,
|
|
100
|
+
values,
|
|
101
|
+
invalid,
|
|
102
|
+
valid,
|
|
103
|
+
release: () => {
|
|
104
|
+
released = true;
|
|
105
|
+
members.value = Object.freeze([]);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { tabbable } from './tabbable.js';
|
|
2
|
+
export type { TabbableOptions } from './tabbable.js';
|
|
3
|
+
export { trackUserInvalid } from './track-user-invalid.js';
|
|
4
|
+
export type { TrackUserInvalidOptions, TrackUserInvalidController } from './track-user-invalid.js';
|
|
5
|
+
export { formRegistry } from './form-registry.js';
|
|
6
|
+
export type { FormRegistryController, FormMember } from './form-registry.js';
|
|
7
|
+
export { rovingFocus } from './roving-focus.js';
|
|
8
|
+
export type { RovingFocusOptions, RovingOrientation } from './roving-focus.js';
|
|
9
|
+
export { overlay } from './overlay.js';
|
|
10
|
+
export type { OverlayOptions, OverlayHandle, OverlayPlacement } from './overlay.js';
|
|
11
|
+
export { selectionCommit } from './selection-commit.js';
|
|
12
|
+
export type { SelectionCommitOptions, SelectionMode } from './selection-commit.js';
|
|
13
|
+
export { valueDrag } from './value-drag.js';
|
|
14
|
+
export type { ValueDragOptions } from './value-drag.js';
|
|
15
|
+
export { paneResize } from './pane-resize.js';
|
|
16
|
+
export type { PaneResizeOptions, PaneResizeHandle } from './pane-resize.js';
|
|
17
|
+
export { valueCodec, numberCodecOptions, currencyCodecOptions } from './value-codec.js';
|
|
18
|
+
export type { ValueCodecOptions, ValueCodecController } from './value-codec.js';
|
|
19
|
+
export { scrollFade } from './scroll-fade.js';
|
|
20
|
+
export type { ScrollFadeOptions } from './scroll-fade.js';
|
|
21
|
+
export { pressActivation } from './press-activation.js';
|
|
22
|
+
export type { PressActivationOptions } from './press-activation.js';
|
|
23
|
+
export { areaDrag } from './area-drag.js';
|
|
24
|
+
export type { AreaDragOptions } from './area-drag.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// traits — stateless `(host, opts) => cleanup` behaviours and stateful controllers, invoked directly from a
|
|
2
|
+
// control's `connected()` (e.g. `tabbable(this, …)`); there is no `host.use()`.
|
|
3
|
+
export { tabbable } from "./tabbable.js";
|
|
4
|
+
export { trackUserInvalid } from "./track-user-invalid.js";
|
|
5
|
+
// The form registry controller (G7 — ADR-0050/ADR-0051, LLD-C3): the ui-form-provider discovery/aggregation
|
|
6
|
+
// controller, invoked from a provider's `connected()` — the same trackUserInvalid controller pattern above.
|
|
7
|
+
export { formRegistry } from "./form-registry.js";
|
|
8
|
+
export { rovingFocus } from "./roving-focus.js";
|
|
9
|
+
export { overlay } from "./overlay.js";
|
|
10
|
+
export { selectionCommit } from "./selection-commit.js";
|
|
11
|
+
export { valueDrag } from "./value-drag.js";
|
|
12
|
+
// ui-split's N-separator drag gesture (app-surfaces-m4.lld.md LLD-C2) — a NEW sibling to `value-drag`,
|
|
13
|
+
// deliberately not a generalization of it (axis+RTL+multi-separator+delta vs value-drag's 1-D/LTR/absolute
|
|
14
|
+
// value contract; widening value-drag risks the shipped ui-slider/ui-slider-multi).
|
|
15
|
+
export { paneResize } from "./pane-resize.js";
|
|
16
|
+
export { valueCodec, numberCodecOptions, currencyCodecOptions } from "./value-codec.js";
|
|
17
|
+
export { scrollFade } from "./scroll-fade.js";
|
|
18
|
+
export { pressActivation } from "./press-activation.js";
|
|
19
|
+
export { areaDrag } from "./area-drag.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export type OverlayPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end';
|
|
3
|
+
export interface OverlayOptions {
|
|
4
|
+
/** The popup element (`[popover]` part — owned by the host control). */
|
|
5
|
+
popup: HTMLElement;
|
|
6
|
+
/** The trigger element the popup anchors to. */
|
|
7
|
+
anchor: HTMLElement;
|
|
8
|
+
/**
|
|
9
|
+
* Initial placement preference. The JS controller flips/shifts to keep the popup in-viewport.
|
|
10
|
+
* Default: `'bottom-start'`.
|
|
11
|
+
*/
|
|
12
|
+
placement?: OverlayPlacement;
|
|
13
|
+
/**
|
|
14
|
+
* `true` → `popover=auto` (light-dismiss via Escape + outside-click).
|
|
15
|
+
* `false` → `popover=manual` (tooltip: dismiss on blur/leave). Default: `true`.
|
|
16
|
+
*/
|
|
17
|
+
auto?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Move focus into the popup on open (menu/listbox pattern). `false` for tooltip (no focus move).
|
|
20
|
+
* Focus is restored to `anchor` on close. Default: `true`.
|
|
21
|
+
*/
|
|
22
|
+
focusOnOpen?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The object returned by `overlay()` — imperative control + cleanup. */
|
|
25
|
+
export interface OverlayHandle {
|
|
26
|
+
/** Show the popup (`showPopover()` + position); emits `toggle` on a real show (ADR-0101). No-op (no event) if already open. */
|
|
27
|
+
open: () => void;
|
|
28
|
+
/** Hide the popup (`hidePopover()` + restore focus); emits `close`+`toggle` on a real hide (ADR-0101). No-op (no event) if already closed. */
|
|
29
|
+
close: () => void;
|
|
30
|
+
/** Toggle the popup. */
|
|
31
|
+
toggle: () => void;
|
|
32
|
+
/** Remove all listeners/observers (idempotent); also called automatically on host disconnect. */
|
|
33
|
+
cleanup: () => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Measure the anchor + popup + viewport and compute the fixed `top`/`left` for the given `placement`.
|
|
37
|
+
* Flips to the opposite side when the preferred side lacks space (flip). Clamps the result to the
|
|
38
|
+
* viewport edges (shift). Returns the resolved placement for `data-placement`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function computePosition(pref: OverlayPlacement, anchorRect: DOMRect, popupRect: DOMRect, vw: number, vh: number, gap?: number): {
|
|
41
|
+
top: number;
|
|
42
|
+
left: number;
|
|
43
|
+
placement: OverlayPlacement;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Wire a Popover API top-layer popup on a `UIElement` host. Invoke from `connected()` so listeners
|
|
47
|
+
* ride the connection AbortSignal (auto-removed on disconnect). Returns an `OverlayHandle`; `cleanup`
|
|
48
|
+
* is also called automatically on host disconnect via a scope-owned effect disposer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function overlay(host: UIElement, opts: OverlayOptions): OverlayHandle;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// overlay.ts — the non-modal Overlay controller (overlay-controller LLD-C1..C4). Gives any host a
|
|
2
|
+
// top-layer, light-dismissable, anchored popup via the native Popover API (`popup.showPopover()` /
|
|
3
|
+
// `hidePopover()`) + a zero-dep JS positioning controller (measure-and-place; mechanism settled by
|
|
4
|
+
// the team-lead, support-verified — see overlay-controller.lld.md).
|
|
5
|
+
//
|
|
6
|
+
// CSS anchor-positioning is a PROGRESSIVE ENHANCEMENT (`@supports (anchor-name: --x)` / feature-detect)
|
|
7
|
+
// where available — NOT a v1 dual-path requirement; the JS controller is the reliable path.
|
|
8
|
+
//
|
|
9
|
+
// Boundary: a true MODAL (focus-trapped) stays on `ui-modal`'s `<dialog>` `showModal()` (ADR-0017).
|
|
10
|
+
// This controller is the NON-MODAL path (select popup, menu, tooltip, popover).
|
|
11
|
+
//
|
|
12
|
+
// Announce contract (ADR-0101): the trait announces every ACTUAL open-state transition — platform-,
|
|
13
|
+
// component-, or model-driven — `toggle` on a real show/hide, `close` alongside every real hide, fired
|
|
14
|
+
// after the host's own `open` prop has settled. Native ToggleEvent timing fidelity; see `open()`/`close()`.
|
|
15
|
+
//
|
|
16
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only.
|
|
17
|
+
// Opposite side map (as const + literal union — the pattern for erasableSyntaxOnly, no enum).
|
|
18
|
+
const FLIP_SIDE = {
|
|
19
|
+
bottom: 'top',
|
|
20
|
+
top: 'bottom',
|
|
21
|
+
left: 'right',
|
|
22
|
+
right: 'left',
|
|
23
|
+
};
|
|
24
|
+
function splitPlacement(p) {
|
|
25
|
+
const i = p.indexOf('-');
|
|
26
|
+
return [p.slice(0, i), p.slice(i + 1)];
|
|
27
|
+
}
|
|
28
|
+
// ── Zero-dep JS positioning controller (LLD-C3) ─────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Measure the anchor + popup + viewport and compute the fixed `top`/`left` for the given `placement`.
|
|
31
|
+
* Flips to the opposite side when the preferred side lacks space (flip). Clamps the result to the
|
|
32
|
+
* viewport edges (shift). Returns the resolved placement for `data-placement`.
|
|
33
|
+
*/
|
|
34
|
+
export function computePosition(pref, anchorRect, popupRect, vw, vh, gap = 0) {
|
|
35
|
+
let [side, align] = splitPlacement(pref);
|
|
36
|
+
const pH = popupRect.height;
|
|
37
|
+
const pW = popupRect.width;
|
|
38
|
+
// Flip: switch to the opposite side when the preferred side lacks space but the opposite has room.
|
|
39
|
+
// The gap counts against the space needed, so a panel flips when the preferred side can't fit the
|
|
40
|
+
// panel PLUS its anchor gap (viewport-collision-aware placement).
|
|
41
|
+
const spaceFor = {
|
|
42
|
+
bottom: vh - anchorRect.bottom,
|
|
43
|
+
top: anchorRect.top,
|
|
44
|
+
right: vw - anchorRect.right,
|
|
45
|
+
left: anchorRect.left,
|
|
46
|
+
};
|
|
47
|
+
const needed = (side === 'bottom' || side === 'top' ? pH : pW) + gap;
|
|
48
|
+
if (spaceFor[side] < needed && spaceFor[FLIP_SIDE[side]] >= needed) {
|
|
49
|
+
side = FLIP_SIDE[side];
|
|
50
|
+
}
|
|
51
|
+
// Compute the anchored offset for the resolved side + alignment. The gap separates the panel edge
|
|
52
|
+
// from the anchor edge (a small breathing margin between the trigger and its dropdown).
|
|
53
|
+
let top;
|
|
54
|
+
let left;
|
|
55
|
+
if (side === 'bottom') {
|
|
56
|
+
top = anchorRect.bottom + gap;
|
|
57
|
+
left = align === 'start' ? anchorRect.left : anchorRect.right - pW;
|
|
58
|
+
}
|
|
59
|
+
else if (side === 'top') {
|
|
60
|
+
top = anchorRect.top - pH - gap;
|
|
61
|
+
left = align === 'start' ? anchorRect.left : anchorRect.right - pW;
|
|
62
|
+
}
|
|
63
|
+
else if (side === 'right') {
|
|
64
|
+
top = align === 'start' ? anchorRect.top : anchorRect.bottom - pH;
|
|
65
|
+
left = anchorRect.right + gap;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// side === 'left'
|
|
69
|
+
top = align === 'start' ? anchorRect.top : anchorRect.bottom - pH;
|
|
70
|
+
left = anchorRect.left - pW - gap;
|
|
71
|
+
}
|
|
72
|
+
// Shift: clamp so no edge escapes the viewport (keeps the panel on-screen even after the gap offset).
|
|
73
|
+
top = Math.max(0, Math.min(top, vh - pH));
|
|
74
|
+
left = Math.max(0, Math.min(left, vw - pW));
|
|
75
|
+
return { top, left, placement: `${side}-${align}` };
|
|
76
|
+
}
|
|
77
|
+
/** Is the popup ACTUALLY in the top layer? `:popover-open` throws in engines that lack it — guard it. */
|
|
78
|
+
function matchesPopoverOpen(popup) {
|
|
79
|
+
try {
|
|
80
|
+
return popup.matches(':popover-open');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false; // pre-`:popover-open` engine — fall back to the internal flag at the call site
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── The controller ───────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Wire a Popover API top-layer popup on a `UIElement` host. Invoke from `connected()` so listeners
|
|
89
|
+
* ride the connection AbortSignal (auto-removed on disconnect). Returns an `OverlayHandle`; `cleanup`
|
|
90
|
+
* is also called automatically on host disconnect via a scope-owned effect disposer.
|
|
91
|
+
*/
|
|
92
|
+
export function overlay(host, opts) {
|
|
93
|
+
const { popup, anchor } = opts;
|
|
94
|
+
const prefPlacement = opts.placement ?? 'bottom-start';
|
|
95
|
+
const auto = opts.auto ?? true;
|
|
96
|
+
const focusOnOpen = opts.focusOnOpen ?? true;
|
|
97
|
+
// Set the popover type on the popup part (done once — the part is created once, ADR-0017 pattern).
|
|
98
|
+
popup.setAttribute('popover', auto ? 'auto' : 'manual');
|
|
99
|
+
let isOpen = false;
|
|
100
|
+
let opener = null;
|
|
101
|
+
// AbortController for positioning scroll/resize listeners (active only while the popup is open).
|
|
102
|
+
let positionAc = null;
|
|
103
|
+
let rafId = null;
|
|
104
|
+
let cleaned = false;
|
|
105
|
+
// ── Positioning (LLD-C3) ─────────────────────────────────────────────────────────────────────
|
|
106
|
+
function position() {
|
|
107
|
+
// The anchor↔panel gap = 0.25rem, resolved against the root font-size so it scales with the theme
|
|
108
|
+
// (a small breathing margin between a trigger and its dropdown; also counts toward flip collision).
|
|
109
|
+
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
110
|
+
const gap = 0.25 * rootPx;
|
|
111
|
+
const { top, left, placement } = computePosition(prefPlacement, anchor.getBoundingClientRect(), popup.getBoundingClientRect(), window.innerWidth, window.innerHeight, gap);
|
|
112
|
+
popup.style.position = 'fixed';
|
|
113
|
+
popup.style.top = `${top}px`;
|
|
114
|
+
popup.style.left = `${left}px`;
|
|
115
|
+
popup.style.bottom = 'auto';
|
|
116
|
+
popup.style.right = 'auto';
|
|
117
|
+
popup.style.margin = '0';
|
|
118
|
+
popup.setAttribute('data-placement', placement);
|
|
119
|
+
}
|
|
120
|
+
function schedulePosition() {
|
|
121
|
+
if (rafId !== null)
|
|
122
|
+
return; // already scheduled — throttle
|
|
123
|
+
rafId = requestAnimationFrame(() => {
|
|
124
|
+
rafId = null;
|
|
125
|
+
position();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function startPositioning() {
|
|
129
|
+
positionAc?.abort(); // guard: re-open without a close should not stack ACs
|
|
130
|
+
positionAc = new AbortController();
|
|
131
|
+
position(); // immediate placement on open
|
|
132
|
+
const signal = positionAc.signal;
|
|
133
|
+
window.addEventListener('scroll', schedulePosition, { signal, capture: true, passive: true });
|
|
134
|
+
window.addEventListener('resize', schedulePosition, { signal, passive: true });
|
|
135
|
+
}
|
|
136
|
+
function stopPositioning() {
|
|
137
|
+
positionAc?.abort();
|
|
138
|
+
positionAc = null;
|
|
139
|
+
if (rafId !== null) {
|
|
140
|
+
cancelAnimationFrame(rafId);
|
|
141
|
+
rafId = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ── Focus policy (LLD-C4: non-modal) ────────────────────────────────────────────────────────
|
|
145
|
+
function moveFocusIn() {
|
|
146
|
+
// Focus the first keyboard-focusable descendant; fall back to the popup itself if none present.
|
|
147
|
+
const focusable = popup.querySelector('a[href],area[href],button:not([disabled]),details>summary,' +
|
|
148
|
+
'input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])');
|
|
149
|
+
(focusable ?? popup).focus();
|
|
150
|
+
}
|
|
151
|
+
function restoreFocus() {
|
|
152
|
+
// Restore to the ANCHOR (the trigger) — the deterministic, DoD-expected target ("focus returns
|
|
153
|
+
// to the trigger on close"). `document.activeElement` at open() time is unreliable: WebKit does
|
|
154
|
+
// not focus a <button> on click (so it would be `body`), and it can be stale by the time the
|
|
155
|
+
// scope-owned effect runs. The captured `opener` is only a fallback for a non-focusable anchor.
|
|
156
|
+
const target = anchor && anchor.isConnected && typeof anchor.focus === 'function' ? anchor : opener;
|
|
157
|
+
opener = null;
|
|
158
|
+
if (target && target.isConnected && typeof target.focus === 'function')
|
|
159
|
+
target.focus();
|
|
160
|
+
}
|
|
161
|
+
// ── The shared "real hide has happened" tail (ADR-0101) ─────────────────────────────────────
|
|
162
|
+
//
|
|
163
|
+
// Stops positioning, restores focus, and announces `close`+`toggle` — the settle + announce work
|
|
164
|
+
// common to EVERY real hide, however it was driven. Pulled out of `close()` so the platform
|
|
165
|
+
// light-dismiss path (below) can share it WITHOUT re-invoking `hidePopover()` on a popup the
|
|
166
|
+
// platform already hid: calling `hidePopover()` a second time from inside its own native 'toggle'
|
|
167
|
+
// handler is a genuine cross-engine race (observed on both Chromium + WebKit) — some engines still
|
|
168
|
+
// consider the popup "showing" at that exact synchronous point, so the redundant call re-runs the
|
|
169
|
+
// platform's OWN hide algorithm and fires a SECOND real ToggleEvent, double-announcing. `close`
|
|
170
|
+
// fires first: any host-side listener that syncs its own `open` prop off `close` (the family
|
|
171
|
+
// pattern — menu.ts / select.ts / popover.ts / combo-box.ts all
|
|
172
|
+
// `this.listen(this, 'close', () => { this.open = false })`) runs synchronously inside this emit,
|
|
173
|
+
// so the prop is ALREADY false by the time `toggle` fires next (the ordering invariant, ADR-0101
|
|
174
|
+
// mechanic 3 — a renderer's two-way bind reads `el.open` at `toggle` listener time and gets the
|
|
175
|
+
// settled value on every path, commit or light-dismiss alike).
|
|
176
|
+
function announceHide() {
|
|
177
|
+
stopPositioning();
|
|
178
|
+
if (focusOnOpen)
|
|
179
|
+
restoreFocus();
|
|
180
|
+
host.emit('close');
|
|
181
|
+
host.emit('toggle'); // value:{prop:'open',event:'toggle'} two-way signal (ADR-0019)
|
|
182
|
+
}
|
|
183
|
+
// ── Platform → model: the Popover toggle listener (LLD-C2: light-dismiss) ──────────────────
|
|
184
|
+
//
|
|
185
|
+
// Rides the host's connection AbortSignal via `host.listen` — auto-removed on host disconnect.
|
|
186
|
+
// Discriminates a platform/light-dismiss close (isOpen is still true — WE never called close())
|
|
187
|
+
// from the platform's OWN ECHO of a close WE drove (isOpen was already set false before we called
|
|
188
|
+
// `hidePopover()`, so the platform's resulting toggle event is a no-op here — ADR-0101: we announce
|
|
189
|
+
// ourselves at the trait's transition points; the echo must not double-fire it).
|
|
190
|
+
host.listen(popup, 'toggle', (event) => {
|
|
191
|
+
if (cleaned)
|
|
192
|
+
return;
|
|
193
|
+
// ToggleEvent.newState is 'open' | 'closed'; cast safely for cross-engine compatibility.
|
|
194
|
+
const newState = event.newState;
|
|
195
|
+
if (newState === 'closed' && isOpen) {
|
|
196
|
+
// The platform closed the popup itself (Escape / outside-click) — WE never called close(), and
|
|
197
|
+
// the platform already hid it, so this path shares ONLY the settle+announce tail (ADR-0101
|
|
198
|
+
// mechanic 1: "the light-dismiss path flows through the same [announce] points") — NOT a
|
|
199
|
+
// redundant hidePopover() call (see announceHide()'s doc comment for why that would race).
|
|
200
|
+
isOpen = false;
|
|
201
|
+
announceHide();
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
// ── The imperative surface (OverlayHandle) ───────────────────────────────────────────────────
|
|
205
|
+
//
|
|
206
|
+
// ADR-0101 — the overlay trait announces EVERY actual open-state transition (platform-,
|
|
207
|
+
// component-, or model-driven): `open()` emits `toggle` on a real show; `close()` emits
|
|
208
|
+
// `close`+`toggle` on a real hide. Native ToggleEvent timing fidelity — ToggleEvents fire on
|
|
209
|
+
// programmatic showPopover()/hidePopover() too, not only platform light-dismiss. The no-transition
|
|
210
|
+
// early-returns below are the loop-breakers (mechanic 2: no transition ⇒ no event) — they also
|
|
211
|
+
// guard the echo-toggle from double-announcing what open()/close() already announced themselves.
|
|
212
|
+
function open() {
|
|
213
|
+
if (cleaned || isOpen)
|
|
214
|
+
return; // double-open is a no-op (idempotent) — the loop-breaker
|
|
215
|
+
if (focusOnOpen)
|
|
216
|
+
opener = document.activeElement;
|
|
217
|
+
// Set isOpen BEFORE showPopover() so the echo-toggle (newState:'open') is seen as already open
|
|
218
|
+
// (no re-entry from the toggle listener above, which only acts on newState:'closed').
|
|
219
|
+
isOpen = true;
|
|
220
|
+
popup.showPopover();
|
|
221
|
+
startPositioning();
|
|
222
|
+
if (focusOnOpen)
|
|
223
|
+
moveFocusIn();
|
|
224
|
+
host.emit('toggle'); // the real show, announced AFTER the host's own open-prop write has settled
|
|
225
|
+
}
|
|
226
|
+
function close() {
|
|
227
|
+
if (cleaned)
|
|
228
|
+
return;
|
|
229
|
+
// Idempotent, but resilient to flag desync: hide whenever the popup is ACTUALLY in the top layer,
|
|
230
|
+
// even if `isOpen` drifted false (a spurious/async platform toggle can desync the flag while the
|
|
231
|
+
// panel stays open — observed on the commit→close path). Basing the guard on `:popover-open`
|
|
232
|
+
// (with `isOpen` as the fallback for engines pre-`:popover-open`) makes model-driven close reliable.
|
|
233
|
+
const actuallyOpen = isOpen || matchesPopoverOpen(popup);
|
|
234
|
+
if (!actuallyOpen)
|
|
235
|
+
return; // no transition — the loop-breaker (ADR-0101 mechanic 2)
|
|
236
|
+
// Set isOpen BEFORE hidePopover() so the echo-toggle (newState:'closed') sees isOpen=false and
|
|
237
|
+
// skips re-entry (the toggle listener above only acts when isOpen is still true).
|
|
238
|
+
isOpen = false;
|
|
239
|
+
try {
|
|
240
|
+
popup.hidePopover(); // throws InvalidStateError only if not currently showing — a harmless no-op then
|
|
241
|
+
}
|
|
242
|
+
catch (_) {
|
|
243
|
+
// intentional: the popup was not in the top layer (already hidden) — nothing more to do
|
|
244
|
+
}
|
|
245
|
+
announceHide();
|
|
246
|
+
}
|
|
247
|
+
function toggle() {
|
|
248
|
+
if (isOpen)
|
|
249
|
+
close();
|
|
250
|
+
else
|
|
251
|
+
open();
|
|
252
|
+
}
|
|
253
|
+
function cleanup() {
|
|
254
|
+
if (cleaned)
|
|
255
|
+
return;
|
|
256
|
+
cleaned = true;
|
|
257
|
+
if (isOpen) {
|
|
258
|
+
isOpen = false;
|
|
259
|
+
try {
|
|
260
|
+
popup.hidePopover(); // may throw if the element is no longer a popover — guard it
|
|
261
|
+
}
|
|
262
|
+
catch (_) {
|
|
263
|
+
// intentional no-op: the popup is gone or no longer popover-typed; focus restore below
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
stopPositioning();
|
|
267
|
+
// restoreFocus() is intentionally NOT called here: on host disconnect the anchor is also
|
|
268
|
+
// leaving the document (it is a child of the same host). focus() on a detached element is a
|
|
269
|
+
// no-op; the browser correctly moves focus to body, which is the right behaviour on disconnect.
|
|
270
|
+
// This mirrors native <dialog>.close() co-removal: the trigger goes with the dialog.
|
|
271
|
+
}
|
|
272
|
+
// Register the cleanup as a scope-owned effect disposer so it fires automatically on host
|
|
273
|
+
// disconnect (scope.dispose() → effect disposed → cleanup()). Also usable as an early-teardown
|
|
274
|
+
// escape hatch via the returned handle's `cleanup` field.
|
|
275
|
+
host.effect(() => cleanup);
|
|
276
|
+
return { open, close, toggle, cleanup };
|
|
277
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export interface PaneResizeOptions {
|
|
3
|
+
/** Live accessor for the CURRENT separator elements, in order (re-read on every `pointerdown`). */
|
|
4
|
+
separators: () => HTMLElement[];
|
|
5
|
+
/** Live accessor for the resize axis — selects `clientX` (horizontal) vs `clientY` (vertical). */
|
|
6
|
+
axis: () => 'horizontal' | 'vertical';
|
|
7
|
+
/** Live accessor for whether the horizontal sense is right-to-left (invert the delta). */
|
|
8
|
+
rtl: () => boolean;
|
|
9
|
+
/** Called with the separator's index and the ratio delta since press. `commit=false` on each live
|
|
10
|
+
* pointermove (drives an `input`); `commit=true` on drag-end (drives a `change`). */
|
|
11
|
+
onResize: (separatorIndex: number, deltaRatio: number, commit: boolean) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface PaneResizeHandle {
|
|
14
|
+
/** Idempotent: stop the trait entirely (removes the outer pointerdown listener's effect + ends any
|
|
15
|
+
* in-flight drag without committing). Call from `disconnected()`. */
|
|
16
|
+
release: () => void;
|
|
17
|
+
/** End any IN-FLIGHT drag without emitting a resize (SPEC-R2 M2) — a no-op when no drag is active. The
|
|
18
|
+
* element's MutationObserver calls this FIRST on a mid-drag pane-count change, before re-deriving. */
|
|
19
|
+
abortDrag: () => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Wire the N-separator pointer-drag gesture on a `UIElement` host (the host IS the track — its own
|
|
23
|
+
* `getBoundingClientRect()` supplies the axis extent, the `ui-slider` precedent). Invoke from `connected()`
|
|
24
|
+
* so the outer listener rides the connection AbortSignal (auto-removed on disconnect via `host.listen`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function paneResize(host: UIElement, opts: PaneResizeOptions): PaneResizeHandle;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// pane-resize.ts — the axis+RTL-aware N-separator drag gesture controller (app-surfaces-m4.lld.md LLD-C2,
|
|
2
|
+
// SPEC-R3/R4). A NEW, sibling trait to `value-drag.ts` — DELIBERATELY not a generalization of it (LLD §2.2
|
|
3
|
+
// fork F2): `value-drag` is 1-D/horizontal/LTR-only (one track → one [min,max] value); `ui-split` needs an
|
|
4
|
+
// axis-aware (`clientX`/`clientY`) + RTL-aware (inverted horizontal sense) + per-separator DELTA (not an
|
|
5
|
+
// absolute value) mapping. Widening `value-drag`'s contract would risk the shipped `ui-slider`/
|
|
6
|
+
// `ui-slider-multi` (the test-the-whole-shape law); this sibling trait is zero-risk and independently
|
|
7
|
+
// testable. Mirrors `value-drag`'s host-scoped-pointerdown + per-drag-AbortController lifetime shape.
|
|
8
|
+
//
|
|
9
|
+
// Protocol (LLD §2.2): `pointerdown` on a `[data-separator]` element → identify its index (`indexOf` in the
|
|
10
|
+
// live `separators()` list — the slider-multi nearer-handle-gate precedent, here a straight lookup since
|
|
11
|
+
// each separator owns exactly one drag) → `setPointerCapture` → `pointermove` computes `deltaRatio` along
|
|
12
|
+
// `axis()` (RTL-inverted for horizontal) SINCE THE PRESS POINT, against the HOST's own live extent (the
|
|
13
|
+
// host IS the track — the `ui-slider` precedent, `track: () => this`) → `onResize(i, delta, false)` (live)
|
|
14
|
+
// → `pointerup`/`lostpointercapture`/`pointercancel` → `onResize(i, delta, true)` (commit).
|
|
15
|
+
//
|
|
16
|
+
// Mid-drag mutation (SPEC-R2 M2): the returned handle exposes a DISTINCT `abortDrag()` — not the release
|
|
17
|
+
// fn doubling as it (the review nit this LLD names) — so the element's MutationObserver can end an
|
|
18
|
+
// in-flight drag on a pane-count change WITHOUT emitting a resize against the now-stale separator index.
|
|
19
|
+
// `abortDrag()` is a silent stop (no `onResize` call): the caller's ratios are left exactly where the last
|
|
20
|
+
// LIVE update put them — "settle at pre-mutation ratios" (LLD §2.2) falls out for free, since the element's
|
|
21
|
+
// re-derive logic runs against whatever ratios currently stand, untouched by the aborted drag.
|
|
22
|
+
//
|
|
23
|
+
// Test-drive (SPEC-R3 INSTRUMENT-BRIDGE): the browser gate cannot exercise REAL `setPointerCapture` — a
|
|
24
|
+
// synthetic PointerEvent is not an active pointer (Playwright/WebKit throws NotFoundError) — so every drag
|
|
25
|
+
// is driven via synthetic `dispatchEvent(new PointerEvent(...))` with `setPointerCapture` stubbed to a
|
|
26
|
+
// no-op (the `slider.browser.test.ts` `stubCapture` precedent). Capture-continuity (a `pointermove` after a
|
|
27
|
+
// `pointerleave`, before `pointerup`, still resizes) is proven STRUCTURALLY: the move/up listeners live on
|
|
28
|
+
// the per-drag `AbortController`, bound to the SEPARATOR element (not the connection scope), so they never
|
|
29
|
+
// unbind on hover/leave — only on `pointerup`/`pointercancel`/`abortDrag()`.
|
|
30
|
+
//
|
|
31
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only.
|
|
32
|
+
/**
|
|
33
|
+
* Wire the N-separator pointer-drag gesture on a `UIElement` host (the host IS the track — its own
|
|
34
|
+
* `getBoundingClientRect()` supplies the axis extent, the `ui-slider` precedent). Invoke from `connected()`
|
|
35
|
+
* so the outer listener rides the connection AbortSignal (auto-removed on disconnect via `host.listen`).
|
|
36
|
+
*/
|
|
37
|
+
export function paneResize(host, opts) {
|
|
38
|
+
let released = false;
|
|
39
|
+
let dragAc = null;
|
|
40
|
+
const endDrag = () => {
|
|
41
|
+
dragAc?.abort();
|
|
42
|
+
dragAc = null;
|
|
43
|
+
};
|
|
44
|
+
host.listen(host, 'pointerdown', (event) => {
|
|
45
|
+
if (released)
|
|
46
|
+
return;
|
|
47
|
+
const pe = event;
|
|
48
|
+
const originEl = pe.target?.closest('[data-separator]');
|
|
49
|
+
if (!originEl)
|
|
50
|
+
return;
|
|
51
|
+
const separators = opts.separators();
|
|
52
|
+
const index = separators.indexOf(originEl);
|
|
53
|
+
if (index === -1)
|
|
54
|
+
return;
|
|
55
|
+
originEl.setPointerCapture(pe.pointerId);
|
|
56
|
+
const axis = opts.axis();
|
|
57
|
+
const rtl = opts.rtl();
|
|
58
|
+
const startCoord = axis === 'horizontal' ? pe.clientX : pe.clientY;
|
|
59
|
+
// Ratio delta since the press point, against the HOST's live extent — re-measured on every call
|
|
60
|
+
// (accounts for scroll/resize mid-drag, the value-drag `re-snapshot the rect on each move` precedent).
|
|
61
|
+
const deltaFor = (clientCoord) => {
|
|
62
|
+
const rect = host.getBoundingClientRect();
|
|
63
|
+
const extent = axis === 'horizontal' ? rect.width : rect.height;
|
|
64
|
+
if (extent <= 0)
|
|
65
|
+
return 0;
|
|
66
|
+
let px = clientCoord - startCoord;
|
|
67
|
+
if (axis === 'horizontal' && rtl)
|
|
68
|
+
px = -px; // RTL inverts the horizontal sense (SPEC-R3 AC3)
|
|
69
|
+
return px / extent;
|
|
70
|
+
};
|
|
71
|
+
const localAc = new AbortController();
|
|
72
|
+
dragAc = localAc;
|
|
73
|
+
originEl.addEventListener('pointermove', (e) => {
|
|
74
|
+
const me = e;
|
|
75
|
+
if (me.pointerId !== pe.pointerId)
|
|
76
|
+
return;
|
|
77
|
+
const coord = axis === 'horizontal' ? me.clientX : me.clientY;
|
|
78
|
+
opts.onResize(index, deltaFor(coord), false);
|
|
79
|
+
}, { signal: localAc.signal });
|
|
80
|
+
const commitEnd = (e) => {
|
|
81
|
+
const me = e;
|
|
82
|
+
if (me.pointerId !== pe.pointerId)
|
|
83
|
+
return;
|
|
84
|
+
const coord = axis === 'horizontal' ? me.clientX : me.clientY;
|
|
85
|
+
opts.onResize(index, deltaFor(coord), true);
|
|
86
|
+
endDrag();
|
|
87
|
+
};
|
|
88
|
+
// Deliberately NO `pointerleave` listener — binding drag-end to hover/leave is exactly the bug SPEC-R3
|
|
89
|
+
// AC2's capture-continuity assertion bites on. The drag ends ONLY on an explicit end signal or abort.
|
|
90
|
+
originEl.addEventListener('pointerup', commitEnd, { signal: localAc.signal });
|
|
91
|
+
originEl.addEventListener('lostpointercapture', commitEnd, { signal: localAc.signal });
|
|
92
|
+
originEl.addEventListener('pointercancel', commitEnd, { signal: localAc.signal });
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
release: () => {
|
|
96
|
+
released = true;
|
|
97
|
+
endDrag();
|
|
98
|
+
},
|
|
99
|
+
abortDrag: () => {
|
|
100
|
+
endDrag(); // silent — no onResize call, so the caller's ratios stay at their last LIVE value
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export interface PressActivationOptions {
|
|
3
|
+
/** True ⇒ the control is disabled and the trait is inert. Read fresh on each key event. */
|
|
4
|
+
disabled: () => boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Wire Space/Enter → `host.click()` on a `UIElement`, native-button style. Invoke from the control's
|
|
8
|
+
* `connected()` (where the host connection is live). Returns `release()` for early teardown (idempotent);
|
|
9
|
+
* otherwise the listeners auto-remove when the host disconnects.
|
|
10
|
+
*/
|
|
11
|
+
export declare function pressActivation(host: UIElement, opts: PressActivationOptions): () => void;
|