@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,55 @@
|
|
|
1
|
+
// press-activation.ts — the Space/Enter → click trait (plan §7). A light-DOM custom element does NOT
|
|
2
|
+
// inherit the native button's keyboard activation, so a control opts in by calling this from `connected()`.
|
|
3
|
+
//
|
|
4
|
+
// Semantics (native-button parity):
|
|
5
|
+
// · Space — `preventDefault` on keydown (stop the page from scrolling), activate on key UP.
|
|
6
|
+
// · Enter — `preventDefault` on keydown, THEN activate (bug-B fix, cross-engine e2e-caught): a real
|
|
7
|
+
// `<button>` has no OTHER default action competing for Enter, so nothing ever leaks; ours activates via
|
|
8
|
+
// an explicit `host.click()` inside a keydown LISTENER, which does not itself consume the keydown's
|
|
9
|
+
// default action. Chromium evaluates that default action AFTER the listener returns, against WHATEVER
|
|
10
|
+
// is focused AT THAT POINT — and `host.click()` can synchronously cascade into focus moving elsewhere
|
|
11
|
+
// (e.g. Enter-on-submit → provider.submit() → reportValidity() refocusing an invalid sibling field)
|
|
12
|
+
// before default-action time. An unconsumed Enter then lands wherever focus ended up — a contenteditable
|
|
13
|
+
// editor's native default action for Enter is "insert a line break," corrupting it. WebKit does not
|
|
14
|
+
// reproduce this (its default-action timing/target resolution differs), but the underlying hazard —
|
|
15
|
+
// Enter's default action must not survive to chase a mid-dispatch focus change — is real in any engine;
|
|
16
|
+
// consuming it explicitly, matching Space's own already-explicit `preventDefault`, closes it fleet-wide.
|
|
17
|
+
// · activation = `host.click()` (fires a native-parity click) — a plain method call, NOT itself affected
|
|
18
|
+
// by `preventDefault()` on the keydown (that only suppresses the BROWSER's own default reaction to the
|
|
19
|
+
// key event, never an imperative call a listener makes).
|
|
20
|
+
// · disabled-inert — when `opts.disabled()` is true the trait does NOTHING (no preventDefault, no click).
|
|
21
|
+
//
|
|
22
|
+
// Listeners ride `host.listen` → the host's connection `AbortSignal`, so they auto-remove on disconnect;
|
|
23
|
+
// `release()` is an early-teardown escape hatch (idempotent — it flips a guard the handlers check).
|
|
24
|
+
//
|
|
25
|
+
// `traits → dom` is the one allowed cross-layer direction (reactive ← dom ← traits); the host type only.
|
|
26
|
+
const isSpace = (key) => key === ' ' || key === 'Spacebar';
|
|
27
|
+
/**
|
|
28
|
+
* Wire Space/Enter → `host.click()` on a `UIElement`, native-button style. Invoke from the control's
|
|
29
|
+
* `connected()` (where the host connection is live). Returns `release()` for early teardown (idempotent);
|
|
30
|
+
* otherwise the listeners auto-remove when the host disconnects.
|
|
31
|
+
*/
|
|
32
|
+
export function pressActivation(host, opts) {
|
|
33
|
+
let released = false;
|
|
34
|
+
host.listen(host, 'keydown', (event) => {
|
|
35
|
+
if (released || opts.disabled())
|
|
36
|
+
return;
|
|
37
|
+
const e = event;
|
|
38
|
+
if (isSpace(e.key))
|
|
39
|
+
e.preventDefault(); // stop the page scrolling; activation waits for keyup
|
|
40
|
+
else if (e.key === 'Enter') {
|
|
41
|
+
e.preventDefault(); // consume the keydown's default action (bug-B fix) — see the header note
|
|
42
|
+
host.click(); // Enter activates on keydown
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
host.listen(host, 'keyup', (event) => {
|
|
46
|
+
if (released || opts.disabled())
|
|
47
|
+
return;
|
|
48
|
+
const e = event;
|
|
49
|
+
if (isSpace(e.key))
|
|
50
|
+
host.click(); // Space activates on keyup
|
|
51
|
+
});
|
|
52
|
+
return () => {
|
|
53
|
+
released = true;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
/** The tab-stop-ownership marker this trait stamps on every item it manages (`applyTabindexes`) and strips
|
|
3
|
+
* on release. `traits/tabbable.ts` checks for its presence to defer its own `tabIndex = 0` write — see the
|
|
4
|
+
* ROVING-MARKER CONTRACT banner above. */
|
|
5
|
+
export declare const ROVING_ITEM_ATTR = "data-roving";
|
|
6
|
+
export type RovingOrientation = 'vertical' | 'horizontal';
|
|
7
|
+
export interface RovingFocusOptions {
|
|
8
|
+
/** Live accessor returning the current ordered set of candidate items (re-read on each key event). */
|
|
9
|
+
items: () => HTMLElement[];
|
|
10
|
+
/** Arrow-key axis: Up/Down for `'vertical'`, Left/Right for `'horizontal'`. Default: `'vertical'`. */
|
|
11
|
+
orientation?: RovingOrientation;
|
|
12
|
+
/** Wrap from last → first and first → last. Default: `true`. */
|
|
13
|
+
loop?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Focus the next item whose visible text starts with the typed buffer (200 ms reset between bursts).
|
|
16
|
+
* Default: `true`.
|
|
17
|
+
*/
|
|
18
|
+
typeAhead?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Called each time the roving index changes. Use to couple selection-follows-focus (listbox/tabs).
|
|
21
|
+
* NOT an active-descendant seam: `moveTo()` unconditionally transfers REAL focus to the item, which
|
|
22
|
+
* is exactly what active-descendant hosts must never do — combo-box/command-modal correctly hand-roll
|
|
23
|
+
* their focus-stays-on-editor navigation instead (their in-file records; TKT-0065 lateral review
|
|
24
|
+
* trued this header to the shipped reality).
|
|
25
|
+
*/
|
|
26
|
+
onMove?: (index: number) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Seed the initial roving index rather than defaulting to the first non-disabled item. A function
|
|
29
|
+
* is re-called at each invocation of `rovingFocus` (useful when calling from `connected()` so the
|
|
30
|
+
* currently-selected position after reconnect is honoured). Out-of-range values fall back to
|
|
31
|
+
* `findFirst`. Default: `findFirst(items())`.
|
|
32
|
+
*/
|
|
33
|
+
initialIndex?: number | (() => number);
|
|
34
|
+
/**
|
|
35
|
+
* Called at the start of every keydown event to sync the internal roving index with external state.
|
|
36
|
+
* Use for selection-follows-focus hosts (tabs, listbox) where a click or programmatic selection
|
|
37
|
+
* change updates the host's own index without going through the trait's `onMove` callback.
|
|
38
|
+
* Returning a negative number or an out-of-range index is treated as "no update".
|
|
39
|
+
*/
|
|
40
|
+
syncIndex?: () => number;
|
|
41
|
+
/**
|
|
42
|
+
* The element to attach the keydown (and type-ahead) listener to. Defaults to `host` itself.
|
|
43
|
+
* Set to a container sub-element (e.g. a `[data-part=tablist]` strip) when the host contains
|
|
44
|
+
* other focusable regions whose Arrow keys must NOT be intercepted by this trait.
|
|
45
|
+
*/
|
|
46
|
+
container?: HTMLElement;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Wire keyboard + tabindex roving focus on a `UIElement` host. Invoke from `connected()` so the
|
|
50
|
+
* listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
51
|
+
*/
|
|
52
|
+
export declare function rovingFocus(host: UIElement, opts: RovingFocusOptions): () => void;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// roving-focus.ts — the keyboard + tabindex roving-focus trait (listbox-roving LLD-C1). Extracted
|
|
2
|
+
// from ui-tabs' prior art (tabs.ts:130-168) to serve any "set of items with a moving focus" host:
|
|
3
|
+
// ui-listbox, ui-menu, ui-select, ui-radio-group, ui-toolbar. Role-agnostic: `items` is a live
|
|
4
|
+
// accessor; the host declares the item role in its own CSS/internals.
|
|
5
|
+
//
|
|
6
|
+
// Exactly ONE item holds `tabindex=0`; the rest `-1`. Arrow keys (Up/Down for vertical; Left/Right
|
|
7
|
+
// for horizontal) + Home/End move the roving index AND transfer focus. `loop` wraps at the ends;
|
|
8
|
+
// `typeAhead` searches by the typed buffer (200ms reset). `onMove(index)` notifies the host: couple
|
|
9
|
+
// selection-follows-focus (listbox/tabs) or decouple (menu: focus moves, selection commits on Enter).
|
|
10
|
+
//
|
|
11
|
+
// THE ROVING-MARKER CONTRACT (ADR-0121 amendment, post-toolbar): custom-element connection is preorder
|
|
12
|
+
// (a parent's `connectedCallback` fires before its children's, for a subtree connected all at once) — so
|
|
13
|
+
// a host that roves over ITEMS WHICH OWN THEIR OWN TAB-STOP (e.g. `ui-button`'s `tabbable` trait,
|
|
14
|
+
// traits/tabbable.ts) can lose the race: this trait's synchronous init runs first and sets the correct
|
|
15
|
+
// tabindexes, but each item's OWN later-running `connected()` effect unconditionally re-asserts
|
|
16
|
+
// `tabIndex = 0`, silently breaking the "exactly one tabindex=0" contract. `ui-radio-group` → `ui-radio`
|
|
17
|
+
// carries this same latent bug (masked by jsdom's opposite child-then-parent connection order, which real
|
|
18
|
+
// Chromium/WebKit do not share) — `ui-toolbar` → `ui-button` is what surfaced it. The fix is two-sided,
|
|
19
|
+
// and BOTH traits document it:
|
|
20
|
+
// 1. HERE — `applyTabindexes` stamps every item it manages with `ROVING_ITEM_ATTR` (`data-roving`), on
|
|
21
|
+
// init and on every move, so the marker is always current for whatever the live `items()` set is;
|
|
22
|
+
// `release()` strips it from the last-known set. A settle pass — one `requestAnimationFrame` tick past
|
|
23
|
+
// connect (the tabs.ts `ready`-gate precedent) — re-reads `items()` and re-applies once more, closing
|
|
24
|
+
// the race for any item whose OWN trait installs after this one's synchronous init.
|
|
25
|
+
// 2. `traits/tabbable.ts` — its effect DEFERS the `tabIndex = 0` write while the host carries
|
|
26
|
+
// `ROVING_ITEM_ATTR` (checked on EVERY effect run, not just once), so a later re-enable mid-session
|
|
27
|
+
// still yields to the roving owner instead of reclaiming a second tab stop. `disabled`/`aria-disabled`
|
|
28
|
+
// semantics are untouched — only tab-stop OWNERSHIP yields. Absent the marker, tabbable is unchanged.
|
|
29
|
+
//
|
|
30
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only. `ROVING_ITEM_ATTR` is the
|
|
31
|
+
// one same-layer (traits → traits) export this module carries — tabbable.ts imports it (layering.test.ts
|
|
32
|
+
// permits same-layer imports; only an UPWARD import is a violation).
|
|
33
|
+
/** The tab-stop-ownership marker this trait stamps on every item it manages (`applyTabindexes`) and strips
|
|
34
|
+
* on release. `traits/tabbable.ts` checks for its presence to defer its own `tabIndex = 0` write — see the
|
|
35
|
+
* ROVING-MARKER CONTRACT banner above. */
|
|
36
|
+
export const ROVING_ITEM_ATTR = 'data-roving';
|
|
37
|
+
// True if the item carries either the native `disabled` attribute or `aria-disabled="true"`.
|
|
38
|
+
function isDisabled(item) {
|
|
39
|
+
return item.hasAttribute('disabled') || item.getAttribute('aria-disabled') === 'true';
|
|
40
|
+
}
|
|
41
|
+
// Apply roving tabindexes: the item at `index` gets 0; all others -1. When `index` is -1 (all
|
|
42
|
+
// disabled) every item gets -1. Stamps ROVING_ITEM_ATTR on every item in `list` (the ownership marker
|
|
43
|
+
// traits/tabbable.ts defers to — the ROVING-MARKER CONTRACT banner above); idempotent, cheap to re-apply
|
|
44
|
+
// on every call (init + every move).
|
|
45
|
+
function applyTabindexes(list, index) {
|
|
46
|
+
for (let i = 0; i < list.length; i++) {
|
|
47
|
+
list[i].tabIndex = i === index ? 0 : -1;
|
|
48
|
+
list[i].setAttribute(ROVING_ITEM_ATTR, '');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Index of the first non-disabled item, or -1 if the set is all-disabled.
|
|
52
|
+
function findFirst(list) {
|
|
53
|
+
return list.findIndex((item) => !isDisabled(item));
|
|
54
|
+
}
|
|
55
|
+
// Index of the last non-disabled item, or -1.
|
|
56
|
+
function findLast(list) {
|
|
57
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
58
|
+
if (!isDisabled(list[i]))
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
return -1;
|
|
62
|
+
}
|
|
63
|
+
// Walk from `from` in direction `dir` (+1 or -1), skipping disabled items. With `loop`, wraps
|
|
64
|
+
// modulo the list length. Without loop, stops at the boundary. Returns -1 when no candidate found.
|
|
65
|
+
function walkEnabled(list, from, dir, loop) {
|
|
66
|
+
const n = list.length;
|
|
67
|
+
for (let step = 1; step <= n; step++) {
|
|
68
|
+
const raw = from + dir * step;
|
|
69
|
+
const idx = loop ? ((raw % n) + n) % n : raw;
|
|
70
|
+
if (idx < 0 || idx >= n)
|
|
71
|
+
break;
|
|
72
|
+
if (!isDisabled(list[idx]))
|
|
73
|
+
return idx;
|
|
74
|
+
}
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Wire keyboard + tabindex roving focus on a `UIElement` host. Invoke from `connected()` so the
|
|
79
|
+
* listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
80
|
+
*/
|
|
81
|
+
export function rovingFocus(host, opts) {
|
|
82
|
+
const { items, orientation = 'vertical', loop = true, typeAhead = true, onMove, syncIndex, container } = opts;
|
|
83
|
+
let released = false;
|
|
84
|
+
// The current roving index (-1 = none; arises when the initial item set is all-disabled).
|
|
85
|
+
let rovingIndex = -1;
|
|
86
|
+
// Type-ahead: accumulate printable characters; reset after 200 ms of inactivity.
|
|
87
|
+
let typeBuffer = '';
|
|
88
|
+
let typeTimer = null;
|
|
89
|
+
// Move roving focus to `next`: update tabindexes, transfer real focus, notify the host.
|
|
90
|
+
function moveTo(list, next) {
|
|
91
|
+
if (next === -1 || next >= list.length)
|
|
92
|
+
return;
|
|
93
|
+
rovingIndex = next;
|
|
94
|
+
applyTabindexes(list, next);
|
|
95
|
+
list[next].focus();
|
|
96
|
+
onMove?.(next);
|
|
97
|
+
}
|
|
98
|
+
// Initialise tabindexes: use `initialIndex` (if provided) to seed from the host's current selection
|
|
99
|
+
// rather than defaulting to the first non-disabled item. This is required when the host's selection
|
|
100
|
+
// effect has already placed tabindex=0 on a non-first item (e.g. tabs reconnecting with tab 1 active).
|
|
101
|
+
const init = items();
|
|
102
|
+
if (opts.initialIndex !== undefined) {
|
|
103
|
+
const seed = typeof opts.initialIndex === 'function' ? opts.initialIndex() : opts.initialIndex;
|
|
104
|
+
rovingIndex = (seed >= 0 && seed < init.length) ? seed : findFirst(init);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
rovingIndex = findFirst(init);
|
|
108
|
+
}
|
|
109
|
+
applyTabindexes(init, rovingIndex);
|
|
110
|
+
// Settle pass — one requestAnimationFrame tick past connect (the tabs.ts `ready`-gate precedent):
|
|
111
|
+
// re-reads the live item set and re-applies tabindexes once more, closing the preorder-connection race
|
|
112
|
+
// for any item whose OWN focus-owning trait (traits/tabbable.ts) installs AFTER this synchronous init
|
|
113
|
+
// and would otherwise win the tabIndex write (the ROVING-MARKER CONTRACT banner above). Cancelled on
|
|
114
|
+
// early release.
|
|
115
|
+
const settleHandle = requestAnimationFrame(() => {
|
|
116
|
+
if (released)
|
|
117
|
+
return;
|
|
118
|
+
const list = items();
|
|
119
|
+
if (list.length === 0)
|
|
120
|
+
return;
|
|
121
|
+
let idx = rovingIndex;
|
|
122
|
+
if (idx < 0 || idx >= list.length || isDisabled(list[idx]))
|
|
123
|
+
idx = findFirst(list);
|
|
124
|
+
rovingIndex = idx;
|
|
125
|
+
applyTabindexes(list, idx);
|
|
126
|
+
});
|
|
127
|
+
// Keydown handler — attached to `container` (default: host) so it can be scoped to a sub-region
|
|
128
|
+
// (e.g. a tablist strip) without intercepting arrow keys inside focusable panel content.
|
|
129
|
+
const listenerTarget = container ?? host;
|
|
130
|
+
host.listen(listenerTarget, 'keydown', (event) => {
|
|
131
|
+
if (released)
|
|
132
|
+
return;
|
|
133
|
+
const e = event;
|
|
134
|
+
const list = items(); // live re-read: handles dynamic item sets
|
|
135
|
+
if (list.length === 0)
|
|
136
|
+
return;
|
|
137
|
+
// Sync external selection state (e.g. click/programmatic) before any key-based navigation so the
|
|
138
|
+
// next Arrow move starts from the correct position even when the host updated its own index
|
|
139
|
+
// via a path that bypassed `onMove` (tabs' #commit() on click, for example).
|
|
140
|
+
if (syncIndex !== undefined) {
|
|
141
|
+
const synced = syncIndex();
|
|
142
|
+
if (synced >= 0 && synced < list.length)
|
|
143
|
+
rovingIndex = synced;
|
|
144
|
+
}
|
|
145
|
+
const isNextKey = orientation === 'vertical' ? e.key === 'ArrowDown' : e.key === 'ArrowRight';
|
|
146
|
+
const isPrevKey = orientation === 'vertical' ? e.key === 'ArrowUp' : e.key === 'ArrowLeft';
|
|
147
|
+
const isHomeKey = e.key === 'Home';
|
|
148
|
+
const isEndKey = e.key === 'End';
|
|
149
|
+
if (!isNextKey && !isPrevKey && !isHomeKey && !isEndKey) {
|
|
150
|
+
// Type-ahead: single printable character with no modifier key shortcuts active.
|
|
151
|
+
if (typeAhead && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
152
|
+
typeBuffer += e.key.toLowerCase();
|
|
153
|
+
if (typeTimer !== null)
|
|
154
|
+
clearTimeout(typeTimer);
|
|
155
|
+
typeTimer = setTimeout(() => {
|
|
156
|
+
typeBuffer = '';
|
|
157
|
+
typeTimer = null;
|
|
158
|
+
}, 200);
|
|
159
|
+
// Search from the item AFTER current (wrapping), including current only as the last resort.
|
|
160
|
+
const from = rovingIndex; // -1 is safe: ((-1+1) % n + n) % n = 0
|
|
161
|
+
const n = list.length;
|
|
162
|
+
for (let step = 1; step <= n; step++) {
|
|
163
|
+
const idx = ((from + step) % n + n) % n;
|
|
164
|
+
if (!isDisabled(list[idx])) {
|
|
165
|
+
const label = (list[idx].textContent ?? '').trim().toLowerCase();
|
|
166
|
+
if (label.startsWith(typeBuffer)) {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
moveTo(list, idx);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
// Clamp rovingIndex to valid range in case items changed since the last move.
|
|
178
|
+
let current = rovingIndex;
|
|
179
|
+
if (current < 0 || current >= list.length) {
|
|
180
|
+
current = findFirst(list);
|
|
181
|
+
if (current === -1)
|
|
182
|
+
return; // all-disabled — nothing to do
|
|
183
|
+
}
|
|
184
|
+
let next;
|
|
185
|
+
if (isNextKey)
|
|
186
|
+
next = walkEnabled(list, current, 1, loop);
|
|
187
|
+
else if (isPrevKey)
|
|
188
|
+
next = walkEnabled(list, current, -1, loop);
|
|
189
|
+
else if (isHomeKey)
|
|
190
|
+
next = findFirst(list);
|
|
191
|
+
else
|
|
192
|
+
next = findLast(list); // isEndKey
|
|
193
|
+
moveTo(list, next);
|
|
194
|
+
});
|
|
195
|
+
// Strip the ownership marker (+ cancel a still-pending settle pass) — idempotent, so both the manual
|
|
196
|
+
// early-teardown call below AND the automatic disconnect path (next) can invoke it safely.
|
|
197
|
+
function cleanup() {
|
|
198
|
+
if (released)
|
|
199
|
+
return;
|
|
200
|
+
released = true;
|
|
201
|
+
cancelAnimationFrame(settleHandle);
|
|
202
|
+
// Best-effort — an item that has since left the DOM entirely needs no cleanup. A reconnect's fresh
|
|
203
|
+
// `rovingFocus()` call re-stamps it.
|
|
204
|
+
for (const item of items())
|
|
205
|
+
item.removeAttribute(ROVING_ITEM_ATTR);
|
|
206
|
+
}
|
|
207
|
+
// Auto-release on disconnect: the keydown LISTENER already dies via `host.listen`'s connection
|
|
208
|
+
// AbortSignal, but the marker is real DOM state with a real consumer (traits/tabbable.ts) — it must not
|
|
209
|
+
// outlive the roving host. A no-op-dependency `host.effect` is the scope-owned "run cleanup on dispose"
|
|
210
|
+
// idiom: it runs once (no signal reads to re-trigger it) and its RETURNED cleanup fires when the
|
|
211
|
+
// connection scope disposes at disconnect, exactly like every other scope-owned effect in this fleet.
|
|
212
|
+
host.effect(() => cleanup);
|
|
213
|
+
return cleanup;
|
|
214
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export interface ScrollFadeOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The scrolling viewport — measured (scrollTop/Height/clientHeight) AND painted (the `data-fade-*` flags +
|
|
5
|
+
* the published bracket offsets all land here). Usually `host` itself, sometimes a control-owned part.
|
|
6
|
+
*/
|
|
7
|
+
viewport?: HTMLElement;
|
|
8
|
+
/**
|
|
9
|
+
* The element whose DIRECT children are queried for a header/footer bracket — defaults to `viewport`. Set
|
|
10
|
+
* this when the brackets are NOT the viewport's own children (ui-card: `viewport` is `ui-card-content`
|
|
11
|
+
* itself, but the header/footer are the CARD's direct children, one level up).
|
|
12
|
+
*/
|
|
13
|
+
brackets?: HTMLElement;
|
|
14
|
+
/**
|
|
15
|
+
* Reactively gate the trait — read inside the host's effect, so toggling the underlying signal turns the
|
|
16
|
+
* fade attributes on/off live (and tears down the listener/observer when it goes false). Default: always on.
|
|
17
|
+
*/
|
|
18
|
+
enabled?: () => boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Toggle `data-fade-top`/`data-fade-bottom` on `viewport` from its own live scroll position, with the bracket
|
|
22
|
+
* band queried off `brackets` (default `viewport`). Invoke from the control's `connected()` (e.g.
|
|
23
|
+
* `scrollFade(this, { viewport: dialog })`, or ui-card's `scrollFade(this, { brackets: card })` when the
|
|
24
|
+
* viewport IS the host but the brackets live on a distinct ancestor). Returns `release()` for early teardown
|
|
25
|
+
* (idempotent); otherwise the effect (and the listener/observer it installs) is disposed when the host
|
|
26
|
+
* disconnects.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scrollFade(host: UIElement, opts?: ScrollFadeOptions): () => void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// scroll-fade.ts — the edge-aware scroll-fade trait (container box-model revision, ADR-0046 follow-up).
|
|
2
|
+
// A scroll viewport with a sticky header/footer bracket (container-box.css) has an inset gutter around the
|
|
3
|
+
// bracket — the bracket's own `background: inherit` does not reach the frame edge (the region floats inside,
|
|
4
|
+
// ADR-0046 revised 2026-07-04). Scrolled content can therefore show through that thin gutter. The fix is not
|
|
5
|
+
// on the bracket: it is on the CONTENT — fade its own edges before they reach the gutter, so nothing hard ever
|
|
6
|
+
// shows there. This trait supplies the "which edge" decision; container-box.css supplies the paint
|
|
7
|
+
// (`[data-fade-top]`/`[data-fade-bottom]` → a `mask-image` gradient, the same recipe card.css already shipped
|
|
8
|
+
// as a static, always-on fade).
|
|
9
|
+
//
|
|
10
|
+
// The BRACKET QUERY can differ from `viewport` (`opts.brackets`, added 2026-07-06 for ui-card, still needed):
|
|
11
|
+
// `bandOf()` queries `brackets`' DIRECT children for the header/footer, independent of which element is being
|
|
12
|
+
// measured/painted — ui-card-content IS the scroll viewport (and the masked element, below), but the header/
|
|
13
|
+
// footer are the CARD's own direct children, one level up. Defaults to `viewport` (unchanged for every other
|
|
14
|
+
// consumer, where the brackets genuinely are the viewport's own direct children).
|
|
15
|
+
//
|
|
16
|
+
// MEASURE and PAINT are always the SAME element (`viewport`) — a short-lived `opts.paintTarget` split existed
|
|
17
|
+
// 2026-07-05/06 for an EARLIER ui-card shape (the card measured, a sibling `ui-card-content` painted; then a
|
|
18
|
+
// nested `[scroll-wrapper]` measured while `ui-card-content` painted). REVISED 2026-07-07 (Kim): ui-card-content
|
|
19
|
+
// now directly manages its own overflow AND carries the mask — one box, no split — so `paintTarget` is retired
|
|
20
|
+
// (no remaining consumer ever set it distinct from `viewport`); this keeps the trait's surface to the two roles
|
|
21
|
+
// that are still genuinely independent (`viewport`/`brackets`), not three.
|
|
22
|
+
//
|
|
23
|
+
// PRESENCE-AWARE: the fade must ADAPT to a sticky/overlaid header/footer — the gradient itself does the
|
|
24
|
+
// occlusion (a bracket needs no background; a bit of gradient showing through it is fine), so the ramp reaches
|
|
25
|
+
// PAST a present bracket. The trait publishes each edge's bracket band — `--ui-box-head`/`--ui-box-foot`, the
|
|
26
|
+
// bracket's rendered block-size + its inset gutter, or 0px when that edge has none — and the consumer's own CSS
|
|
27
|
+
// (container-box.css's mask rule, or ui-card-content's own block-padding) reads it. Content thus stays faded
|
|
28
|
+
// until it has cleared a present bracket; a bracketless edge (0px) collapses to the exact pre-offset fade/padding.
|
|
29
|
+
//
|
|
30
|
+
// EDGE-AWARE, not symmetric: a flag is present only when content is genuinely hidden past THAT edge — a short
|
|
31
|
+
// / non-scrolling viewport gets neither flag (never faded), and an at-top/at-bottom viewport only fades the
|
|
32
|
+
// edge that still hides something.
|
|
33
|
+
//
|
|
34
|
+
// Deliberately JS-driven, not `animation-timeline: scroll()`: the decision is three comparisons against
|
|
35
|
+
// scrollTop/scrollHeight/clientHeight — trivially unit-testable in jsdom and immune to scroll-driven-animation
|
|
36
|
+
// engine-support gaps. The PAINT it drives is the well-proven `mask-image` mechanism already shipped (card.css).
|
|
37
|
+
//
|
|
38
|
+
// Reactive gate (mirrors tabbable.ts's `disabled: () => boolean` shape): `opts.enabled` is read inside the
|
|
39
|
+
// host's own `host.effect`, so an author-facing opt-in prop (e.g. ui-card-content's `scroll-fade`) can turn the
|
|
40
|
+
// whole mechanism on/off live. Omit it for an always-on internal viewport (a modal dialog, an overlay panel).
|
|
41
|
+
//
|
|
42
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only.
|
|
43
|
+
// Direct-child brackets, per the container box-model: a `header`/`footer` (or `[data-region]`) region on a
|
|
44
|
+
// `[data-box]` (modal / overlay panels), and `ui-card-header`/`ui-card-footer` on the ui-card that hosts a
|
|
45
|
+
// `ui-card-content` viewport.
|
|
46
|
+
const HEAD = ':scope > :is(header, [data-region="header"], ui-card-header)';
|
|
47
|
+
const FOOT = ':scope > :is(footer, [data-region="footer"], ui-card-footer)';
|
|
48
|
+
/** The rendered band a bracket occupies at its edge = its border-box block-size + the inset gutter (its margin
|
|
49
|
+
* to the frame). `'0px'` when that edge has NO bracket — the fade/padding then reaches the viewport edge. */
|
|
50
|
+
function bandOf(brackets, sel, top) {
|
|
51
|
+
const el = brackets.querySelector(sel);
|
|
52
|
+
if (!(el instanceof HTMLElement))
|
|
53
|
+
return '0px';
|
|
54
|
+
const cs = getComputedStyle(el);
|
|
55
|
+
return `${el.offsetHeight + (parseFloat(top ? cs.marginBlockStart : cs.marginBlockEnd) || 0)}px`;
|
|
56
|
+
}
|
|
57
|
+
/** Publish each edge's bracket band as `--ui-box-head`/`--ui-box-foot` on `viewport` — the offset a consumer's
|
|
58
|
+
* CSS extends the fade ramp (or its own block-padding) PAST, so content stays clear of / faded until it has
|
|
59
|
+
* cleared a present bracket (a bracketless edge, 0px, collapses to the plain depth). The bracket query itself
|
|
60
|
+
* always runs against `brackets` (its direct children — not necessarily `viewport`'s own). Re-run on resize (a
|
|
61
|
+
* growing bracket/viewport), not on the scroll path. */
|
|
62
|
+
function measure(brackets, viewport) {
|
|
63
|
+
viewport.style.setProperty('--ui-box-head', bandOf(brackets, HEAD, true));
|
|
64
|
+
viewport.style.setProperty('--ui-box-foot', bandOf(brackets, FOOT, false));
|
|
65
|
+
}
|
|
66
|
+
/** Zero out both fade flags + the published bracket offsets on `viewport` — the "not active" / "not scrollable" state. */
|
|
67
|
+
function clear(viewport) {
|
|
68
|
+
viewport.removeAttribute('data-fade-top');
|
|
69
|
+
viewport.removeAttribute('data-fade-bottom');
|
|
70
|
+
viewport.style.removeProperty('--ui-box-head');
|
|
71
|
+
viewport.style.removeProperty('--ui-box-foot');
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Toggle `data-fade-top`/`data-fade-bottom` on `viewport` from its own live scroll position, with the bracket
|
|
75
|
+
* band queried off `brackets` (default `viewport`). Invoke from the control's `connected()` (e.g.
|
|
76
|
+
* `scrollFade(this, { viewport: dialog })`, or ui-card's `scrollFade(this, { brackets: card })` when the
|
|
77
|
+
* viewport IS the host but the brackets live on a distinct ancestor). Returns `release()` for early teardown
|
|
78
|
+
* (idempotent); otherwise the effect (and the listener/observer it installs) is disposed when the host
|
|
79
|
+
* disconnects.
|
|
80
|
+
*/
|
|
81
|
+
export function scrollFade(host, opts = {}) {
|
|
82
|
+
const viewport = opts.viewport ?? host;
|
|
83
|
+
const brackets = opts.brackets ?? viewport;
|
|
84
|
+
const isEnabled = opts.enabled ?? (() => true);
|
|
85
|
+
const dispose = host.effect(() => {
|
|
86
|
+
if (!isEnabled()) {
|
|
87
|
+
clear(viewport);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const update = () => {
|
|
91
|
+
const hasOverflow = viewport.scrollHeight > viewport.clientHeight + 1;
|
|
92
|
+
const atTop = viewport.scrollTop <= 1;
|
|
93
|
+
const atBottom = viewport.scrollTop + viewport.clientHeight >= viewport.scrollHeight - 1;
|
|
94
|
+
viewport.toggleAttribute('data-fade-top', hasOverflow && !atTop);
|
|
95
|
+
viewport.toggleAttribute('data-fade-bottom', hasOverflow && !atBottom);
|
|
96
|
+
};
|
|
97
|
+
const remeasure = () => {
|
|
98
|
+
update(); // the edge decision
|
|
99
|
+
measure(brackets, viewport); // the bracket offsets (presence + depth the fade ramp reaches past)
|
|
100
|
+
};
|
|
101
|
+
remeasure(); // the at-mount state (no scroll event has fired yet)
|
|
102
|
+
viewport.addEventListener('scroll', update, { passive: true }); // removed explicitly below — no AbortController
|
|
103
|
+
// needed (this trait is not riding host.listen); a plain remove keeps the marginal size down.
|
|
104
|
+
// The viewport's own box can resize independently of scrolling (a max-block-size cap, a card growing) —
|
|
105
|
+
// re-run both the edge decision and the bracket measurement whenever EITHER does (a bracket resize that
|
|
106
|
+
// reflows the viewport rides this too; `brackets` is observed separately since it can be a distinct element
|
|
107
|
+
// from `viewport`, e.g. ui-card). Feature-detected: every evergreen engine has ResizeObserver, but jsdom
|
|
108
|
+
// (the unit-test environment) does not — the scroll listener above still covers the decision there.
|
|
109
|
+
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(remeasure) : null;
|
|
110
|
+
ro?.observe(viewport);
|
|
111
|
+
if (brackets !== viewport)
|
|
112
|
+
ro?.observe(brackets);
|
|
113
|
+
return () => {
|
|
114
|
+
viewport.removeEventListener('scroll', update);
|
|
115
|
+
ro?.disconnect();
|
|
116
|
+
clear(viewport);
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
return () => dispose();
|
|
120
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { UIElement } from '../dom/index.js';
|
|
2
|
+
export type SelectionMode = 'single' | 'multi';
|
|
3
|
+
export interface SelectionCommitOptions {
|
|
4
|
+
/**
|
|
5
|
+
* `'single'` — at most one selected key. `'multi'` — a Set with Shift/Ctrl range-toggle and
|
|
6
|
+
* Ctrl+Space per-item toggle. Default: `'single'`.
|
|
7
|
+
*/
|
|
8
|
+
mode?: SelectionMode;
|
|
9
|
+
/**
|
|
10
|
+
* Called when the committed selection changes.
|
|
11
|
+
* Single mode: a `string` key, or `''` when nothing is selected.
|
|
12
|
+
* Multi mode: a `ReadonlySet<string>` of selected keys (may be empty).
|
|
13
|
+
*/
|
|
14
|
+
onSelect?: (selection: string | ReadonlySet<string>) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Live accessor returning the ordered option elements (re-read on each event for Shift-range
|
|
17
|
+
* ordering). Default: `[...host.querySelectorAll('[role=option]')]`.
|
|
18
|
+
*/
|
|
19
|
+
items?: () => HTMLElement[];
|
|
20
|
+
/**
|
|
21
|
+
* Extract the string key from an option element. An empty string is treated as "no key" —
|
|
22
|
+
* items without a key are skipped. Default: `el.dataset['key'] ?? ''`.
|
|
23
|
+
*/
|
|
24
|
+
keyOf?: (el: HTMLElement) => string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Wire single/multi selection tracking on a `UIElement` host. Invoke from `connected()` so
|
|
28
|
+
* listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
29
|
+
*/
|
|
30
|
+
export declare function selectionCommit(host: UIElement, opts: SelectionCommitOptions): () => void;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// selection-commit.ts — the selection model + commit controller (listbox-roving LLD-C2). Tracks
|
|
2
|
+
// the selected key(s) for any "set of options" host: single or multi. Publishes `aria-selected`
|
|
3
|
+
// via internals element-reflection and emits `select` on commit. Value-codec-agnostic: the host
|
|
4
|
+
// maps its option elements to string keys.
|
|
5
|
+
//
|
|
6
|
+
// Consumer contract (trued by the TKT-0065 lateral review — the original header named hosts that
|
|
7
|
+
// lawfully cannot consume this trait): the selection marker is an `aria-selected` ATTRIBUTE on each
|
|
8
|
+
// item, so the trait fits attribute-reflected `[role=option]` hosts ONLY (ui-select is the shipped
|
|
9
|
+
// consumer). `role=menuitem` hosts (ui-menu) must not carry aria-selected (action semantics — menu.ts
|
|
10
|
+
// commits via its own click + Enter/Space path, which this trait also lacks a Space leg for), and
|
|
11
|
+
// ui-tab items drive selection through ElementInternals, never host attributes (the fleet ARIA law).
|
|
12
|
+
//
|
|
13
|
+
// `traits → dom` is the one allowed cross-layer direction; the host type only.
|
|
14
|
+
/**
|
|
15
|
+
* Wire single/multi selection tracking on a `UIElement` host. Invoke from `connected()` so
|
|
16
|
+
* listeners ride the connection AbortSignal (auto-removed on disconnect). Returns cleanup (idempotent).
|
|
17
|
+
*/
|
|
18
|
+
export function selectionCommit(host, opts) {
|
|
19
|
+
const mode = opts.mode ?? 'single';
|
|
20
|
+
const getItems = opts.items ??
|
|
21
|
+
(() => [...host.querySelectorAll('[role=option]')]);
|
|
22
|
+
const keyOf = opts.keyOf ??
|
|
23
|
+
((el) => el.dataset['key'] ?? '');
|
|
24
|
+
// A disabled option is non-committable — guards BOTH commit paths (click + Enter) so a disabled
|
|
25
|
+
// option cannot be selected even where CSS `pointer-events:none` is absent (jsdom) or bypassed
|
|
26
|
+
// (keyboard). Matches the rovingFocus/menu disabled convention: the HTML `[disabled]` attribute OR
|
|
27
|
+
// `aria-disabled="true"`. (Selection-follows-focus hosts already skip disabled at the roving layer;
|
|
28
|
+
// this is the commit-layer backstop.)
|
|
29
|
+
const isDisabled = (el) => el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
|
|
30
|
+
// Single-mode cursor; multi-mode Set; anchor key held across moves for Shift-range extension.
|
|
31
|
+
let singleKey = '';
|
|
32
|
+
let multiKeys = new Set();
|
|
33
|
+
let anchorKey = '';
|
|
34
|
+
let released = false;
|
|
35
|
+
// Reflect aria-selected on every option element in the host. The attribute is set on the ITEMS
|
|
36
|
+
// (NOT on the host) — aria-selected belongs to each option, not the listbox container.
|
|
37
|
+
const reflectAriaSelected = () => {
|
|
38
|
+
for (const item of getItems()) {
|
|
39
|
+
const k = keyOf(item);
|
|
40
|
+
const selected = mode === 'single'
|
|
41
|
+
? (k !== '' && k === singleKey)
|
|
42
|
+
: multiKeys.has(k);
|
|
43
|
+
item.setAttribute('aria-selected', String(selected));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
// Reflect ARIA, invoke `onSelect`, and emit `select` with the current committed selection.
|
|
47
|
+
const publish = () => {
|
|
48
|
+
reflectAriaSelected();
|
|
49
|
+
const selection = mode === 'single' ? singleKey : new Set(multiKeys);
|
|
50
|
+
opts.onSelect?.(selection);
|
|
51
|
+
host.emit('select', selection);
|
|
52
|
+
};
|
|
53
|
+
// Resolve the closest [role=option] ancestor of a click target, constrained within the host.
|
|
54
|
+
const optionFromTarget = (target) => {
|
|
55
|
+
if (!(target instanceof HTMLElement))
|
|
56
|
+
return null;
|
|
57
|
+
const item = target.closest('[role=option]');
|
|
58
|
+
return item && host.contains(item) ? item : null;
|
|
59
|
+
};
|
|
60
|
+
host.listen(host, 'click', (event) => {
|
|
61
|
+
if (released)
|
|
62
|
+
return;
|
|
63
|
+
const e = event;
|
|
64
|
+
const item = optionFromTarget(e.target);
|
|
65
|
+
if (!item || isDisabled(item))
|
|
66
|
+
return;
|
|
67
|
+
const key = keyOf(item);
|
|
68
|
+
if (key === '')
|
|
69
|
+
return;
|
|
70
|
+
if (mode === 'single') {
|
|
71
|
+
singleKey = key;
|
|
72
|
+
anchorKey = key;
|
|
73
|
+
publish();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Multi mode — modifier keys determine how the selection evolves.
|
|
77
|
+
if (e.shiftKey && anchorKey !== '') {
|
|
78
|
+
// Range-extend: replace selection with the range from anchor to the clicked item (inclusive).
|
|
79
|
+
// The anchor itself does not move on Shift — it stays at the last plain click/Enter.
|
|
80
|
+
const items = getItems();
|
|
81
|
+
const ai = items.findIndex((el) => keyOf(el) === anchorKey);
|
|
82
|
+
const ci = items.indexOf(item);
|
|
83
|
+
if (ai >= 0 && ci >= 0) {
|
|
84
|
+
const lo = Math.min(ai, ci);
|
|
85
|
+
const hi = Math.max(ai, ci);
|
|
86
|
+
multiKeys = new Set(items.slice(lo, hi + 1).map(keyOf).filter((k) => k !== ''));
|
|
87
|
+
publish();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else if (e.ctrlKey || e.metaKey) {
|
|
91
|
+
// Toggle: flip this item's membership without moving the anchor.
|
|
92
|
+
const next = new Set(multiKeys);
|
|
93
|
+
if (next.has(key))
|
|
94
|
+
next.delete(key);
|
|
95
|
+
else
|
|
96
|
+
next.add(key);
|
|
97
|
+
multiKeys = next;
|
|
98
|
+
publish();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Plain click: replace the selection and set a new anchor.
|
|
102
|
+
multiKeys = new Set([key]);
|
|
103
|
+
anchorKey = key;
|
|
104
|
+
publish();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
host.listen(host, 'keydown', (event) => {
|
|
108
|
+
if (released)
|
|
109
|
+
return;
|
|
110
|
+
const e = event;
|
|
111
|
+
if (e.key !== 'Enter')
|
|
112
|
+
return;
|
|
113
|
+
// Commit the currently focused option (set by rovingFocus or any active-descendant approach).
|
|
114
|
+
const active = document.activeElement;
|
|
115
|
+
if (!active || !host.contains(active))
|
|
116
|
+
return;
|
|
117
|
+
const item = active.closest('[role=option]');
|
|
118
|
+
if (!item || isDisabled(item))
|
|
119
|
+
return;
|
|
120
|
+
const key = keyOf(item);
|
|
121
|
+
if (key === '')
|
|
122
|
+
return;
|
|
123
|
+
// Enter COMMITS here — suppress its default action so a host that closes an overlay on commit and
|
|
124
|
+
// restores focus to a <button> trigger does NOT let the SAME Enter re-activate that button (which
|
|
125
|
+
// would re-toggle the popup open). Cross-engine bug caught by ui-select's commit-close smoke.
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
if (mode === 'single') {
|
|
128
|
+
singleKey = key;
|
|
129
|
+
anchorKey = key;
|
|
130
|
+
publish();
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Toggle the focused item; anchor stays.
|
|
134
|
+
const next = new Set(multiKeys);
|
|
135
|
+
if (next.has(key))
|
|
136
|
+
next.delete(key);
|
|
137
|
+
else
|
|
138
|
+
next.add(key);
|
|
139
|
+
multiKeys = next;
|
|
140
|
+
publish();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return () => {
|
|
144
|
+
released = true;
|
|
145
|
+
};
|
|
146
|
+
}
|