@fusion.dev/helix-core 0.209.0 → 0.210.0
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/custom-elements.json +1 -1
- package/dist/controllers/toolbar-controller/toolbar-controller.d.ts +199 -0
- package/dist/controllers/toolbar-controller/toolbar-controller.js +334 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mixins/mixin-viewport/mixin-viewport.js +12 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
2
|
+
/** What a toolbar tells the controller about itself. */
|
|
3
|
+
export interface ToolbarControllerOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The toolbar's members, in order, read fresh on each pass: the `Toolbar`
|
|
6
|
+
* element's children, or the controls a component draws in its own shadow
|
|
7
|
+
* root. A `<slot>` among them is read as the elements assigned to it.
|
|
8
|
+
*/
|
|
9
|
+
members: () => Element[];
|
|
10
|
+
/** Whether another toolbar around this one owns the keys and the tab stop. Unset, none does. */
|
|
11
|
+
nested?: () => boolean;
|
|
12
|
+
/**
|
|
13
|
+
* How the arrow keys move between the controls. Unset, horizontal.
|
|
14
|
+
*
|
|
15
|
+
* - `horizontal`: Left and Right, in order, the left moving forward on a
|
|
16
|
+
* right-to-left page. Up and Down are left to the control.
|
|
17
|
+
* - `vertical`: Up and Down, in order. Left and Right are left to the control.
|
|
18
|
+
* - `both`: Right and Down forward, Left and Up back, in order, for a row
|
|
19
|
+
* that wraps onto more lines, as a legend's does.
|
|
20
|
+
* - `position`: each arrow to the nearest control that way on screen, for
|
|
21
|
+
* a cluster laid out in two dimensions, such as a pan pad. It stops at
|
|
22
|
+
* the edge rather than wrapping, and where nothing is laid out it moves
|
|
23
|
+
* in order, as `both` does.
|
|
24
|
+
*/
|
|
25
|
+
arrows?: () => 'both' | 'horizontal' | 'position' | 'vertical' | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the controls are the host's own, drawn in its shadow root, rather
|
|
28
|
+
* than markup a page wrote. Its own controls take the tab stop as soon as
|
|
29
|
+
* they render and keep it across renders. A page's are left as they are
|
|
30
|
+
* until focus first arrives, since a `tabindex` written while React hydrates
|
|
31
|
+
* them is one it reports and does not patch up.
|
|
32
|
+
*/
|
|
33
|
+
own?: boolean;
|
|
34
|
+
/** Whether the arrow keys stop at the first and the last control. Unset, they wrap. */
|
|
35
|
+
stopAtEnds?: () => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The words a control is found by when a reader types its first letters,
|
|
38
|
+
* the way a native `<select>` finds an option. Unset, typing moves nothing.
|
|
39
|
+
*/
|
|
40
|
+
words?: (control: HTMLElement) => string;
|
|
41
|
+
/**
|
|
42
|
+
* Where a change to `disabled` or `aria-hidden` is watched for, which is
|
|
43
|
+
* what moves the stop off a control that leaves the set. The host by
|
|
44
|
+
* default, whose light DOM a page's toolbar holds; the render root for
|
|
45
|
+
* controls the host draws itself.
|
|
46
|
+
*/
|
|
47
|
+
watch?: () => Node | null | undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Makes a row of controls one tab stop that the arrow keys move along, the
|
|
51
|
+
* APG toolbar pattern: what `Toolbar` does for its children, for any host.
|
|
52
|
+
*
|
|
53
|
+
* **One implementation for every toolbar in the library.** `Toolbar` runs on
|
|
54
|
+
* it over the controls a page writes, and a component that draws its own row
|
|
55
|
+
* of buttons, such as a canvas's pan and zoom pad, runs on it over those, so
|
|
56
|
+
* the two agree on every key, on how the stop moves through a control's
|
|
57
|
+
* `focusable` or its `tabindex`, and on what happens when the control holding
|
|
58
|
+
* the stop is disabled or removed. Before it, two families rebuilt the
|
|
59
|
+
* pattern by hand and three drew a tab stop per button.
|
|
60
|
+
*
|
|
61
|
+
* The host names the role and the label on the element that holds the
|
|
62
|
+
* controls, `role="toolbar"` with `aria-orientation`, since only it knows
|
|
63
|
+
* which element that is:
|
|
64
|
+
*
|
|
65
|
+
* protected readonly controlKeys = new ToolbarController(this, {
|
|
66
|
+
* members: () => [...(this.partNamed('controls')?.children ?? [])],
|
|
67
|
+
* arrows: () => 'position',
|
|
68
|
+
* own: true,
|
|
69
|
+
* watch: () => this.renderRoot,
|
|
70
|
+
* })
|
|
71
|
+
*
|
|
72
|
+
* **Keys and focus are heard on the host**, where a press on a control in its
|
|
73
|
+
* shadow root or in its light DOM arrives either way, and only a press that
|
|
74
|
+
* starts on a control is taken: a host's own arrow keys on anything else, such
|
|
75
|
+
* as a board's panning, are left alone.
|
|
76
|
+
*
|
|
77
|
+
* A page's toolbar claims its first stop once every custom control in it has
|
|
78
|
+
* upgraded, by `claim()`, which `Toolbar` calls after its first render and on
|
|
79
|
+
* every `slotchange`. A host's own controls are kept after every update: the
|
|
80
|
+
* control that held the stop keeps it, and one that left hands it to the
|
|
81
|
+
* first.
|
|
82
|
+
*/
|
|
83
|
+
export declare class ToolbarController implements ReactiveController {
|
|
84
|
+
private readonly host;
|
|
85
|
+
private readonly options;
|
|
86
|
+
/** The member that last held the stop, which a host's own controls keep across renders. */
|
|
87
|
+
private active;
|
|
88
|
+
/**
|
|
89
|
+
* Whether the stop has been written onto controls this package does not
|
|
90
|
+
* know, which happens on the first focus. From then on, every move of the
|
|
91
|
+
* stop has to write them too, or the one at `0` can be removed or disabled
|
|
92
|
+
* and leave every other one at `-1`.
|
|
93
|
+
*/
|
|
94
|
+
private wroteForeign;
|
|
95
|
+
private watcher;
|
|
96
|
+
constructor(host: ReactiveControllerHost & HTMLElement, options: ToolbarControllerOptions);
|
|
97
|
+
/** The members, a slot among them read as the elements assigned to it. */
|
|
98
|
+
get members(): Element[];
|
|
99
|
+
private get nested();
|
|
100
|
+
private get arrows();
|
|
101
|
+
/** The letters a reader has typed toward a control, where `words` is set. */
|
|
102
|
+
private readonly search;
|
|
103
|
+
/**
|
|
104
|
+
* The members that take part, in order, each with the elements in it that
|
|
105
|
+
* take focus.
|
|
106
|
+
*
|
|
107
|
+
* A test on the element rather than a list of tag names: a toolbar holds
|
|
108
|
+
* anything, including elements this package has never heard of. A member
|
|
109
|
+
* that takes no focus and holds nothing that does is skipped. Client only,
|
|
110
|
+
* there being no tree to walk on the server.
|
|
111
|
+
*/
|
|
112
|
+
private get controls();
|
|
113
|
+
/** The elements in a member's light DOM that take focus. */
|
|
114
|
+
private within;
|
|
115
|
+
/**
|
|
116
|
+
* The stops of a member that takes focus itself: the member, or the members
|
|
117
|
+
* of a composite that owns its tab stop, such as a toggle group, which the
|
|
118
|
+
* toolbar enters at a member and moves the stop of through `focusable`.
|
|
119
|
+
*/
|
|
120
|
+
private inside;
|
|
121
|
+
/**
|
|
122
|
+
* Where focus lands on arriving at a control. A nested composite is entered
|
|
123
|
+
* at the stop it holds, which for a toggle group is the chosen toggle.
|
|
124
|
+
*/
|
|
125
|
+
private entry;
|
|
126
|
+
/** The control a press or a focus came from, by its path. */
|
|
127
|
+
private origin;
|
|
128
|
+
/**
|
|
129
|
+
* Moves the single tab stop to one control.
|
|
130
|
+
*
|
|
131
|
+
* Prefers the control's own `focusable` property to writing `tabindex` on it.
|
|
132
|
+
* The attribute is a change to markup the consumer rendered, which a React
|
|
133
|
+
* consumer's hydration reports and does not patch up; a control that offers
|
|
134
|
+
* the property moves the stop inside its own shadow root instead.
|
|
135
|
+
*
|
|
136
|
+
* `tabindex` stays as the fallback, because a toolbar has to hold controls
|
|
137
|
+
* this package has never heard of.
|
|
138
|
+
*/
|
|
139
|
+
private setTabStop;
|
|
140
|
+
/**
|
|
141
|
+
* Sets the first tab stop once every custom control has upgraded.
|
|
142
|
+
*
|
|
143
|
+
* A custom element has no properties at all before it upgrades, so a pass
|
|
144
|
+
* that ran first would find no `focusable`, take the `tabindex` fallback, and
|
|
145
|
+
* write the very attribute this exists to avoid.
|
|
146
|
+
*/
|
|
147
|
+
claim(): Promise<void>;
|
|
148
|
+
/** Waits for every custom element among the members to be defined. */
|
|
149
|
+
private upgraded;
|
|
150
|
+
/**
|
|
151
|
+
* Keeps the stop on a host's own controls after a render: on the control
|
|
152
|
+
* that held it, or on the first where that one left. Idempotent, so a render
|
|
153
|
+
* that changed nothing writes nothing.
|
|
154
|
+
*/
|
|
155
|
+
private keep;
|
|
156
|
+
private readonly handleFocusIn;
|
|
157
|
+
/**
|
|
158
|
+
* Gives the tab stop back to a control that still exists.
|
|
159
|
+
*
|
|
160
|
+
* `controls` excludes anything disabled or hidden, so disabling the control
|
|
161
|
+
* holding the stop drops it out of the list while every other control is
|
|
162
|
+
* already at `-1`, leaving a toolbar the keyboard cannot reach at all. A Save
|
|
163
|
+
* button disabling itself once the form is clean is enough to do it.
|
|
164
|
+
*
|
|
165
|
+
* `foreign` stays false, the same as the first claim, until a focus has
|
|
166
|
+
* written `tabindex="-1"` onto foreign controls. From then on `setTabStop`
|
|
167
|
+
* writes them as well, so removing or disabling the native button that held
|
|
168
|
+
* the stop hands it to the first one left.
|
|
169
|
+
*/
|
|
170
|
+
restore(): void;
|
|
171
|
+
/**
|
|
172
|
+
* The toolbar's own keys, from inside a nested composite.
|
|
173
|
+
*
|
|
174
|
+
* In capture, ahead of the composite, which answers every arrow itself and
|
|
175
|
+
* would otherwise hold the reader inside it. Only the keys along the
|
|
176
|
+
* toolbar's axis and Home and End are taken, so the arrows across it still
|
|
177
|
+
* move inside the composite.
|
|
178
|
+
*/
|
|
179
|
+
private readonly handleCompositeKeydown;
|
|
180
|
+
/**
|
|
181
|
+
* The control a typed letter moves to, by the words `words` reads, or
|
|
182
|
+
* undefined where nothing matches or the press is no letter. A Space typed
|
|
183
|
+
* before any letter is left to the control, which it presses.
|
|
184
|
+
*/
|
|
185
|
+
private typed;
|
|
186
|
+
/**
|
|
187
|
+
* The nearest control in an arrow's direction on screen, from the centers of
|
|
188
|
+
* the boxes: the distance along the arrow, with twice the distance across it
|
|
189
|
+
* added, so a control in line wins over a nearer one off to the side. `-1`
|
|
190
|
+
* where nothing lies that way, and undefined where nothing is laid out.
|
|
191
|
+
*/
|
|
192
|
+
private toward;
|
|
193
|
+
/** Where an arrow key moves in order, forward or back, or undefined for one this toolbar leaves alone. */
|
|
194
|
+
private stepped;
|
|
195
|
+
private readonly handleKeydown;
|
|
196
|
+
hostConnected(): void;
|
|
197
|
+
hostDisconnected(): void;
|
|
198
|
+
hostUpdated(): void;
|
|
199
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { inlineStep } from '../../utils/inline-step/inline-step.js';
|
|
2
|
+
import { keyClaimed } from '../../utils/key-claimed/key-claimed.js';
|
|
3
|
+
import { Typeahead, typesCharacter } from '../../utils/typeahead/typeahead.js';
|
|
4
|
+
const KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);
|
|
5
|
+
const ownsTabStop = (node) => 'focusable' in node;
|
|
6
|
+
const NATIVE = 'a[href], button, input, select, textarea, [tabindex], [contenteditable=""], [contenteditable="true"]';
|
|
7
|
+
const TABBABLE = NATIVE.replace('[tabindex]', '[tabindex]:not([tabindex="-1"])');
|
|
8
|
+
const innerStops = new WeakMap();
|
|
9
|
+
const takesFocus = (node) => {
|
|
10
|
+
if (!(node instanceof HTMLElement) ||
|
|
11
|
+
node.hasAttribute('disabled') ||
|
|
12
|
+
node.getAttribute('aria-hidden') === 'true') {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return (node.matches(NATIVE) ||
|
|
16
|
+
ownsTabStop(node) ||
|
|
17
|
+
node.shadowRoot?.delegatesFocus === true ||
|
|
18
|
+
node.shadowRoot?.querySelector(TABBABLE) != null ||
|
|
19
|
+
innerStops.get(node)?.isConnected === true);
|
|
20
|
+
};
|
|
21
|
+
const stopOf = (node) => {
|
|
22
|
+
if (node.hasAttribute('tabindex')) {
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
const held = innerStops.get(node);
|
|
26
|
+
if (held?.isConnected && held.getRootNode() === node.shadowRoot) {
|
|
27
|
+
return held;
|
|
28
|
+
}
|
|
29
|
+
const inner = node.localName.includes('-')
|
|
30
|
+
? node.shadowRoot?.querySelector(TABBABLE)
|
|
31
|
+
: null;
|
|
32
|
+
if (!inner) {
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
innerStops.set(node, inner);
|
|
36
|
+
return inner;
|
|
37
|
+
};
|
|
38
|
+
const writeStop = (element, stop) => {
|
|
39
|
+
const value = stop ? '0' : '-1';
|
|
40
|
+
if (element.getAttribute('tabindex') !== value) {
|
|
41
|
+
element.setAttribute('tabindex', value);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const flattens = (node) => ownsTabStop(node) &&
|
|
45
|
+
node.selectionMode === 'multiple';
|
|
46
|
+
export class ToolbarController {
|
|
47
|
+
constructor(host, options) {
|
|
48
|
+
this.wroteForeign = false;
|
|
49
|
+
this.search = new Typeahead();
|
|
50
|
+
this.handleFocusIn = (event) => {
|
|
51
|
+
if (this.nested) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const control = this.origin(event);
|
|
55
|
+
if (control) {
|
|
56
|
+
this.setTabStop(control);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
this.handleCompositeKeydown = (event) => {
|
|
60
|
+
if (this.nested) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const control = this.origin(event);
|
|
64
|
+
if (!control || (control.stops.length === 1 && !control.group)) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.handleKeydown(event);
|
|
68
|
+
if (event.defaultPrevented) {
|
|
69
|
+
event.stopPropagation();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
this.handleKeydown = (event) => {
|
|
73
|
+
if (this.nested || keyClaimed(event)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const controls = this.controls;
|
|
77
|
+
const origin = this.origin(event);
|
|
78
|
+
const current = origin ? controls.findIndex((control) => control.child === origin.child) : -1;
|
|
79
|
+
if (current === -1) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (KEYS.has(event.key) && (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
let next;
|
|
86
|
+
if (!KEYS.has(event.key)) {
|
|
87
|
+
next = this.typed(event, controls, current);
|
|
88
|
+
}
|
|
89
|
+
else if (event.key === 'Home') {
|
|
90
|
+
next = 0;
|
|
91
|
+
}
|
|
92
|
+
else if (event.key === 'End') {
|
|
93
|
+
next = controls.length - 1;
|
|
94
|
+
}
|
|
95
|
+
else if (this.arrows === 'position') {
|
|
96
|
+
const toward = this.toward(event.key, controls, current);
|
|
97
|
+
next = toward === undefined ? this.stepped(event, controls, current) : toward;
|
|
98
|
+
if (next === -1) {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
next = this.stepped(event, controls, current);
|
|
105
|
+
}
|
|
106
|
+
if (next === undefined) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
const target = controls[next];
|
|
111
|
+
this.setTabStop(target);
|
|
112
|
+
const element = target ? this.entry(target) : undefined;
|
|
113
|
+
if (element && 'focusItem' in element && typeof element.focusItem === 'function') {
|
|
114
|
+
element.focusItem();
|
|
115
|
+
}
|
|
116
|
+
else if (element) {
|
|
117
|
+
;
|
|
118
|
+
(ownsTabStop(element) ? element : stopOf(element)).focus();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
this.host = host;
|
|
122
|
+
this.options = options;
|
|
123
|
+
this.wroteForeign = options.own === true;
|
|
124
|
+
host.addController(this);
|
|
125
|
+
}
|
|
126
|
+
get members() {
|
|
127
|
+
return this.options
|
|
128
|
+
.members()
|
|
129
|
+
.flatMap((child) => child instanceof HTMLSlotElement ? child.assignedElements({ flatten: true }) : [child]);
|
|
130
|
+
}
|
|
131
|
+
get nested() {
|
|
132
|
+
return this.options.nested?.() ?? false;
|
|
133
|
+
}
|
|
134
|
+
get arrows() {
|
|
135
|
+
return this.options.arrows?.() ?? 'horizontal';
|
|
136
|
+
}
|
|
137
|
+
get controls() {
|
|
138
|
+
const controls = [];
|
|
139
|
+
for (const child of this.members) {
|
|
140
|
+
if (!(child instanceof HTMLElement) ||
|
|
141
|
+
child.hasAttribute('disabled') ||
|
|
142
|
+
child.getAttribute('aria-hidden') === 'true') {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (takesFocus(child)) {
|
|
146
|
+
const stops = this.inside(child);
|
|
147
|
+
if (stops.length > 1 && flattens(child)) {
|
|
148
|
+
for (const stop of stops) {
|
|
149
|
+
controls.push({ child: stop, group: child, stops: [stop] });
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
controls.push({ child, stops });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const stop of this.within(child)) {
|
|
157
|
+
controls.push({ child: stop, stops: [stop] });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return controls;
|
|
161
|
+
}
|
|
162
|
+
within(child) {
|
|
163
|
+
return [...child.querySelectorAll('*')].filter(takesFocus);
|
|
164
|
+
}
|
|
165
|
+
inside(child) {
|
|
166
|
+
if (!ownsTabStop(child)) {
|
|
167
|
+
return [child];
|
|
168
|
+
}
|
|
169
|
+
const members = this.within(child);
|
|
170
|
+
return members.length > 1 ? members : [child];
|
|
171
|
+
}
|
|
172
|
+
entry(control) {
|
|
173
|
+
const { stops } = control;
|
|
174
|
+
if (stops.length === 1) {
|
|
175
|
+
return stops[0];
|
|
176
|
+
}
|
|
177
|
+
return (stops.find((stop) => (ownsTabStop(stop) && stop.focusable) ||
|
|
178
|
+
stop.selected === true) ?? stops[0]);
|
|
179
|
+
}
|
|
180
|
+
origin(event) {
|
|
181
|
+
const path = event.composedPath();
|
|
182
|
+
return this.controls.find((control) => control.stops.some((stop) => path.includes(stop)));
|
|
183
|
+
}
|
|
184
|
+
setTabStop(active, foreign = true) {
|
|
185
|
+
if (foreign) {
|
|
186
|
+
this.wroteForeign = true;
|
|
187
|
+
}
|
|
188
|
+
this.active = active?.group ?? active?.child;
|
|
189
|
+
for (const control of this.controls) {
|
|
190
|
+
if (control.group) {
|
|
191
|
+
control.group.focusable = control.group === active?.group;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (control.stops.length !== 1) {
|
|
195
|
+
if (ownsTabStop(control.child)) {
|
|
196
|
+
control.child.focusable = control.child === active?.child;
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const element = control.stops[0];
|
|
201
|
+
const stop = control.child === active?.child;
|
|
202
|
+
if (ownsTabStop(element)) {
|
|
203
|
+
if (element.focusable !== stop) {
|
|
204
|
+
element.focusable = stop;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (foreign || this.wroteForeign) {
|
|
208
|
+
writeStop(stopOf(element), stop);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async claim() {
|
|
213
|
+
if (this.nested) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await this.upgraded();
|
|
217
|
+
this.setTabStop(this.controls[0], false);
|
|
218
|
+
}
|
|
219
|
+
async upgraded() {
|
|
220
|
+
const pending = [...new Set(this.members.map((child) => child.localName))].filter((name) => name.includes('-') && customElements.get(name) === undefined);
|
|
221
|
+
await Promise.all(pending.map((name) => customElements.whenDefined(name)));
|
|
222
|
+
}
|
|
223
|
+
async keep() {
|
|
224
|
+
if (this.nested) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
await this.upgraded();
|
|
228
|
+
const controls = this.controls;
|
|
229
|
+
const held = controls.find((control) => control.child === this.active || control.group === this.active);
|
|
230
|
+
this.setTabStop(held ?? controls[0]);
|
|
231
|
+
}
|
|
232
|
+
restore() {
|
|
233
|
+
if (this.nested) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const controls = this.controls;
|
|
237
|
+
const held = controls.some((control) => {
|
|
238
|
+
if (control.group) {
|
|
239
|
+
return control.group.focusable;
|
|
240
|
+
}
|
|
241
|
+
const composite = control.stops.length !== 1;
|
|
242
|
+
const element = composite ? control.child : control.stops[0];
|
|
243
|
+
if (ownsTabStop(element)) {
|
|
244
|
+
return element.focusable;
|
|
245
|
+
}
|
|
246
|
+
return composite || stopOf(element).tabIndex === 0;
|
|
247
|
+
});
|
|
248
|
+
if (controls.length > 0 && !held) {
|
|
249
|
+
this.setTabStop(controls[0], false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
typed(event, controls, current) {
|
|
253
|
+
const words = this.options.words;
|
|
254
|
+
if (!words || !typesCharacter(event) || (event.key === ' ' && !this.search.typing())) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const found = this.search.match(event.key, controls.map((control) => words(control.child)), current);
|
|
258
|
+
return found === -1 ? undefined : found;
|
|
259
|
+
}
|
|
260
|
+
toward(key, controls, current) {
|
|
261
|
+
const boxes = controls.map((control) => control.child.getBoundingClientRect());
|
|
262
|
+
if (boxes.every((box) => box.width === 0 && box.height === 0)) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const from = boxes[current];
|
|
266
|
+
const [x, y] = [from.left + from.width / 2, from.top + from.height / 2];
|
|
267
|
+
const [dx, dy] = {
|
|
268
|
+
ArrowDown: [0, 1],
|
|
269
|
+
ArrowLeft: [-1, 0],
|
|
270
|
+
ArrowRight: [1, 0],
|
|
271
|
+
ArrowUp: [0, -1],
|
|
272
|
+
}[key];
|
|
273
|
+
let best = -1;
|
|
274
|
+
let score = Number.POSITIVE_INFINITY;
|
|
275
|
+
boxes.forEach((box, index) => {
|
|
276
|
+
if (index === current) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const along = (box.left + box.width / 2 - x) * dx + (box.top + box.height / 2 - y) * dy;
|
|
280
|
+
const across = Math.abs((box.left + box.width / 2 - x) * dy + (box.top + box.height / 2 - y) * dx);
|
|
281
|
+
if (along <= 1) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (along + across * 2 < score) {
|
|
285
|
+
score = along + across * 2;
|
|
286
|
+
best = index;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
return best;
|
|
290
|
+
}
|
|
291
|
+
stepped(event, controls, current) {
|
|
292
|
+
const arrows = this.arrows;
|
|
293
|
+
const along = inlineStep(this.host, event.key);
|
|
294
|
+
const down = event.key === 'ArrowDown';
|
|
295
|
+
const up = event.key === 'ArrowUp';
|
|
296
|
+
const forward = arrows === 'vertical' ? down : arrows === 'horizontal' ? along === 1 : along === 1 || down;
|
|
297
|
+
const back = arrows === 'vertical' ? up : arrows === 'horizontal' ? along === -1 : along === -1 || up;
|
|
298
|
+
const last = controls.length - 1;
|
|
299
|
+
const stopAtEnds = this.options.stopAtEnds?.() ?? false;
|
|
300
|
+
if (forward) {
|
|
301
|
+
return stopAtEnds ? Math.min(current + 1, last) : (current + 1) % controls.length;
|
|
302
|
+
}
|
|
303
|
+
if (back) {
|
|
304
|
+
return stopAtEnds
|
|
305
|
+
? Math.max(current - 1, 0)
|
|
306
|
+
: (current - 1 + controls.length) % controls.length;
|
|
307
|
+
}
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
hostConnected() {
|
|
311
|
+
this.host.addEventListener('keydown', this.handleCompositeKeydown, true);
|
|
312
|
+
this.host.addEventListener('keydown', this.handleKeydown);
|
|
313
|
+
this.host.addEventListener('focusin', this.handleFocusIn);
|
|
314
|
+
const watched = this.options.watch?.() ?? this.host;
|
|
315
|
+
this.watcher = new MutationObserver(() => this.restore());
|
|
316
|
+
this.watcher.observe(watched, {
|
|
317
|
+
attributeFilter: ['disabled', 'aria-hidden'],
|
|
318
|
+
attributes: true,
|
|
319
|
+
subtree: true,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
hostDisconnected() {
|
|
323
|
+
this.host.removeEventListener('keydown', this.handleCompositeKeydown, true);
|
|
324
|
+
this.host.removeEventListener('keydown', this.handleKeydown);
|
|
325
|
+
this.host.removeEventListener('focusin', this.handleFocusIn);
|
|
326
|
+
this.watcher?.disconnect();
|
|
327
|
+
this.watcher = undefined;
|
|
328
|
+
}
|
|
329
|
+
hostUpdated() {
|
|
330
|
+
if (this.options.own) {
|
|
331
|
+
void this.keep();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { SourceController, type SourceHost, } from './controllers/source-control
|
|
|
35
35
|
export { drawStatus, type SayOptions, StatusController, } from './controllers/status-controller/status-controller.js';
|
|
36
36
|
export { SwipeController, type SwipeOptions, } from './controllers/swipe-controller/swipe-controller.js';
|
|
37
37
|
export { type TokenColor, TokenColorController, } from './controllers/token-color-controller/token-color-controller.js';
|
|
38
|
+
export { ToolbarController, type ToolbarControllerOptions, } from './controllers/toolbar-controller/toolbar-controller.js';
|
|
38
39
|
export { UpgradeController, type UpgradeOptions, } from './controllers/upgrade-controller/upgrade-controller.js';
|
|
39
40
|
export { type Rendered, VirtualizeController, type VirtualizeOptions, type Window as VirtualizeWindow, } from './controllers/virtualize-controller/virtualize-controller.js';
|
|
40
41
|
export { Waterfall } from './controllers/virtualize-controller/waterfall.js';
|
package/dist/index.js
CHANGED
|
@@ -35,6 +35,7 @@ export { SourceController, } from './controllers/source-controller/source-contro
|
|
|
35
35
|
export { drawStatus, StatusController, } from './controllers/status-controller/status-controller.js';
|
|
36
36
|
export { SwipeController, } from './controllers/swipe-controller/swipe-controller.js';
|
|
37
37
|
export { TokenColorController, } from './controllers/token-color-controller/token-color-controller.js';
|
|
38
|
+
export { ToolbarController, } from './controllers/toolbar-controller/toolbar-controller.js';
|
|
38
39
|
export { UpgradeController, } from './controllers/upgrade-controller/upgrade-controller.js';
|
|
39
40
|
export { VirtualizeController, } from './controllers/virtualize-controller/virtualize-controller.js';
|
|
40
41
|
export { Waterfall } from './controllers/virtualize-controller/waterfall.js';
|
|
@@ -3,6 +3,7 @@ import { html, nothing } from 'lit';
|
|
|
3
3
|
import { property, state } from 'lit/decorators.js';
|
|
4
4
|
import { FullscreenController } from '../../controllers/fullscreen-controller/fullscreen-controller.js';
|
|
5
5
|
import { IdleController } from '../../controllers/idle-controller/idle-controller.js';
|
|
6
|
+
import { ToolbarController } from '../../controllers/toolbar-controller/toolbar-controller.js';
|
|
6
7
|
import { inlineStep } from '../../utils/inline-step/inline-step.js';
|
|
7
8
|
import { isRtl } from '../../utils/is-rtl/is-rtl.js';
|
|
8
9
|
import { numberConverter, timeConverter } from '../../utils/number-converter/number-converter.js';
|
|
@@ -27,6 +28,12 @@ export const MixinViewport = (Base) => {
|
|
|
27
28
|
this.fullscreen = new FullscreenController(this, (full) => {
|
|
28
29
|
this.filled = full;
|
|
29
30
|
});
|
|
31
|
+
this.controlKeys = new ToolbarController(this, {
|
|
32
|
+
arrows: () => 'position',
|
|
33
|
+
members: () => [...(this.controlsElement?.children ?? [])],
|
|
34
|
+
own: true,
|
|
35
|
+
watch: () => this.renderRoot,
|
|
36
|
+
});
|
|
30
37
|
this.idle = new IdleController(this, () => this.idleDelay, (resting) => {
|
|
31
38
|
this.resting = resting;
|
|
32
39
|
});
|
|
@@ -94,6 +101,10 @@ export const MixinViewport = (Base) => {
|
|
|
94
101
|
if (target !== own && !own.matches('[part~="controls"]')) {
|
|
95
102
|
return;
|
|
96
103
|
}
|
|
104
|
+
const modified = event.shiftKey || event.ctrlKey || event.metaKey || event.altKey;
|
|
105
|
+
if (own.matches('[part~="controls"]') && !modified && /^(Arrow|Home$|End$)/.test(event.key)) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
97
108
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
@@ -272,7 +283,7 @@ export const MixinViewport = (Base) => {
|
|
|
272
283
|
return nothing;
|
|
273
284
|
}
|
|
274
285
|
const [up, start, end, down] = ['up', 'start', 'end', 'down'].map((way) => this.panGlyph(way));
|
|
275
|
-
return html `<div part="controls" role="
|
|
286
|
+
return html `<div part="controls" role="toolbar" aria-label=${this.controlsLabel}>
|
|
276
287
|
${pan
|
|
277
288
|
? html `<button part="pan-up" aria-label=${this.panLabel('up')} type="button" @click=${() => this.pan('up')}>
|
|
278
289
|
<slot name="pan-up-icon">${up}</slot>
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.210.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-core",
|
|
3
3
|
"description": "Helix element primitives: base classes, mixins, decorators, utilities.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.210.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -81,12 +81,12 @@
|
|
|
81
81
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@floating-ui/dom": "^1.8.0",
|
|
84
|
-
"@fusion.dev/helix-icons": "0.
|
|
84
|
+
"@fusion.dev/helix-icons": "0.210.0",
|
|
85
85
|
"tslib": "^2.8.1"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@fusion.dev/helix-cem": "0.
|
|
89
|
-
"@fusion.dev/helix-testing": "0.
|
|
88
|
+
"@fusion.dev/helix-cem": "0.210.0",
|
|
89
|
+
"@fusion.dev/helix-testing": "0.210.0",
|
|
90
90
|
"@lit-labs/ssr": "^4.1.0",
|
|
91
91
|
"lit": "^3.2.1"
|
|
92
92
|
},
|