@weasel-js/headless 0.0.0-stage → 1.13.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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/index.d.ts +85 -0
- package/dist/index.js +85 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orochi235
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weasel-js/headless
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Component behavior with no substrate: what a DOM component and a canvas-drawn widget both run,
|
|
4
|
+
each drawing it its own way. Today that is a list's roving highlight and typeahead, and keyboard
|
|
5
|
+
focus as one contract. No React, no DOM.
|
|
6
|
+
|
|
7
|
+
Part of [weasel](https://github.com/orochi235/weasel), a domain-agnostic 2D
|
|
8
|
+
scene-graph canvas kit for React. See the
|
|
9
|
+
[API reference](https://orochi235.github.io/weasel/#pkg/headless).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install @weasel-js/headless
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { EMPTY_TYPEAHEAD, rovingKey, typeaheadStep } from '@weasel-js/headless';
|
|
21
|
+
|
|
22
|
+
type Row = { name: string; heading?: boolean };
|
|
23
|
+
const shape = { canStop: (r: Row) => !r.heading, label: (r: Row) => r.name };
|
|
24
|
+
|
|
25
|
+
let at: number | null = null;
|
|
26
|
+
let typed = EMPTY_TYPEAHEAD;
|
|
27
|
+
|
|
28
|
+
function onKey(rows: readonly Row[], key: string): void {
|
|
29
|
+
const r = rovingKey(rows, at, key, shape); // arrows, Home, End
|
|
30
|
+
if (r.handled) { at = r.at; return; }
|
|
31
|
+
if (key.length === 1) {
|
|
32
|
+
const t = typeaheadStep(rows, at, typed, key, Date.now(), shape);
|
|
33
|
+
typed = t.buffer;
|
|
34
|
+
if (t.hit !== null) at = t.hit;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
A list's shape says which items the highlight can land on (`canStop`), which arrows move it
|
|
40
|
+
(`orientation`, vertical by default, or `'both'` for all four), whether Left and Right swap for a
|
|
41
|
+
right-to-left layout (`dir`), and whether it wraps at the ends (`wrap`, on by default).
|
|
42
|
+
`@weasel-js/menu` runs every menu level on these.
|
|
43
|
+
|
|
44
|
+
## Focus
|
|
45
|
+
|
|
46
|
+
`FocusScope<T>` is keyboard focus over a set of targets, whatever draws them: which one is
|
|
47
|
+
focused, whether its ring shows, `focus`, `moveFocus` through a `tabOrder`, and
|
|
48
|
+
`subscribeFocus`. A `@weasel-js/hud` HUD is one over its widgets, and `@weasel-js/ui`'s
|
|
49
|
+
`domFocusScope(root)` is one over the elements under `root`, so code that moves focus is
|
|
50
|
+
written once for both. `tabSort` and `tabStep` are the order and the step both use.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** How a list's items are read: which ones the highlight can land on, and which way its arrows run. */
|
|
2
|
+
interface ListShape<T> {
|
|
3
|
+
/** Whether the highlight can land on `item`. A separator or a heading can't. Default: every item. */
|
|
4
|
+
canStop?(item: T): boolean;
|
|
5
|
+
/** Which arrow keys move the highlight. `'both'` answers to all four, the way a radio group does. Default `'vertical'`. */
|
|
6
|
+
orientation?: 'vertical' | 'horizontal' | 'both';
|
|
7
|
+
/** The inline direction the items are laid out in. Under `'rtl'` Left steps forward and Right back. Default `'ltr'`. */
|
|
8
|
+
dir?: 'ltr' | 'rtl';
|
|
9
|
+
/** An arrow past either end comes round to the other. Default true. */
|
|
10
|
+
wrap?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Index of the first item the highlight can land on, or null for a list with none. */
|
|
13
|
+
declare function firstStop<T>(items: readonly T[], shape?: ListShape<T>): number | null;
|
|
14
|
+
/** The next item the highlight can land on from `from` in direction `dir`; from `null`, the first or last.
|
|
15
|
+
* Without wrap, an end stays where it is. Null for a list with no such item. */
|
|
16
|
+
declare function stepStop<T>(items: readonly T[], from: number | null, dir: 1 | -1, shape?: ListShape<T>): number | null;
|
|
17
|
+
/** What one key does to a list's highlight. `handled` false for a key that is not the list's to move. */
|
|
18
|
+
type RovingResult = {
|
|
19
|
+
handled: false;
|
|
20
|
+
} | {
|
|
21
|
+
handled: true;
|
|
22
|
+
at: number | null;
|
|
23
|
+
};
|
|
24
|
+
/** Move the highlight `at` by one key: the arrows along the list's orientation, Home and End. */
|
|
25
|
+
declare function rovingKey<T>(items: readonly T[], at: number | null, key: string, shape?: ListShape<T>): RovingResult;
|
|
26
|
+
|
|
27
|
+
/** How typeahead reads a list: its shape, and the text each item is found by. */
|
|
28
|
+
interface TypeaheadShape<T> extends Pick<ListShape<T>, 'canStop'> {
|
|
29
|
+
label(item: T): string;
|
|
30
|
+
}
|
|
31
|
+
/** How long typing can pause before the typeahead buffer starts over, in milliseconds. */
|
|
32
|
+
declare const TYPEAHEAD_RESET_MS = 500;
|
|
33
|
+
/** What has been typed so far, and when the last key came. */
|
|
34
|
+
interface TypeaheadBuffer {
|
|
35
|
+
text: string;
|
|
36
|
+
at: number;
|
|
37
|
+
}
|
|
38
|
+
/** A buffer nothing has been typed into. */
|
|
39
|
+
declare const EMPTY_TYPEAHEAD: TypeaheadBuffer;
|
|
40
|
+
/** The item a typed `query` lands on, searching forward from `from` with
|
|
41
|
+
* wrap. A one-letter query starts after the highlight so repeating a letter
|
|
42
|
+
* cycles; a longer one starts at it so typing on keeps the match. */
|
|
43
|
+
declare function typeaheadMatch<T>(items: readonly T[], from: number | null, query: string, shape: TypeaheadShape<T>): number | null;
|
|
44
|
+
/** One typed character: the new buffer and the item it lands on. A key that
|
|
45
|
+
* repeats the whole buffer searches that one letter, so it cycles. */
|
|
46
|
+
declare function typeaheadStep<T>(items: readonly T[], from: number | null, buffer: TypeaheadBuffer, key: string, now: number, shape: TypeaheadShape<T>, resetMs?: number): {
|
|
47
|
+
buffer: TypeaheadBuffer;
|
|
48
|
+
hit: number | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** How a focus change came about. */
|
|
52
|
+
interface FocusScopeOptions {
|
|
53
|
+
/** Whether the target shows its focus ring: the scope's `:focus-visible`. Keyboard moves show one, a press does
|
|
54
|
+
* not. Default `false` for {@link FocusScope.focus}, `true` for {@link FocusScope.moveFocus}. */
|
|
55
|
+
readonly visible?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Keyboard focus over a set of targets, whatever draws them: a HUD's widgets and a DOM subtree's elements both
|
|
59
|
+
* answer it, so code that moves or reads focus is written once for either.
|
|
60
|
+
*/
|
|
61
|
+
interface FocusScope<T> {
|
|
62
|
+
/** The target holding keyboard focus, or null. One per scope. */
|
|
63
|
+
readonly focused: T | null;
|
|
64
|
+
/** Whether the focused target shows its focus ring. */
|
|
65
|
+
readonly focusVisible: boolean;
|
|
66
|
+
/** Focus `target`, or blur with `null`. Returns whether `target` now holds focus: false when it is not in this
|
|
67
|
+
* scope or cannot take focus, and focus is then unchanged. */
|
|
68
|
+
focus(target: T | null, options?: FocusScopeOptions): boolean;
|
|
69
|
+
/** Move focus one step through {@link tabOrder}. From nothing, `'next'` lands on the first target and `'prev'` on
|
|
70
|
+
* the last; past either end the scope blurs and returns null, which is where focus leaves it. */
|
|
71
|
+
moveFocus(direction: 'next' | 'prev', options?: FocusScopeOptions): T | null;
|
|
72
|
+
/** The targets Tab visits, in order. */
|
|
73
|
+
tabOrder(): T[];
|
|
74
|
+
/** Run `fn` whenever the focused target changes. Returns an unsubscribe. */
|
|
75
|
+
subscribeFocus(fn: (focused: T | null) => void): () => void;
|
|
76
|
+
}
|
|
77
|
+
/** Tab order: targets that declare a place come first, ascending, then the rest in the order given. In the DOM a
|
|
78
|
+
* positive `tabindex` declares one and `0` does not. */
|
|
79
|
+
declare function tabSort<T>(targets: readonly T[], declared: (target: T) => number | undefined): T[];
|
|
80
|
+
/** The target one Tab step from `from` through `order`, or null past either end. From nothing, `'next'` is the
|
|
81
|
+
* first and `'prev'` the last. A `from` that is out of the order (hidden since it took focus, say) steps from where
|
|
82
|
+
* it sits, which `isBefore` says; without it, such a `from` steps as nothing does. */
|
|
83
|
+
declare function tabStep<T>(order: readonly T[], from: T | null, direction: 'next' | 'prev', isBefore?: (a: T, b: T) => boolean): T | null;
|
|
84
|
+
|
|
85
|
+
export { EMPTY_TYPEAHEAD, type FocusScope, type FocusScopeOptions, type ListShape, type RovingResult, TYPEAHEAD_RESET_MS, type TypeaheadBuffer, type TypeaheadShape, firstStop, rovingKey, stepStop, tabSort, tabStep, typeaheadMatch, typeaheadStep };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/list.ts
|
|
2
|
+
var always = () => true;
|
|
3
|
+
function firstStop(items, shape = {}) {
|
|
4
|
+
const i = items.findIndex(shape.canStop ?? always);
|
|
5
|
+
return i < 0 ? null : i;
|
|
6
|
+
}
|
|
7
|
+
function stepStop(items, from, dir, shape = {}) {
|
|
8
|
+
const canStop = shape.canStop ?? always;
|
|
9
|
+
const wrap = shape.wrap ?? true;
|
|
10
|
+
const n = items.length;
|
|
11
|
+
let i = from ?? (dir === 1 ? -1 : n);
|
|
12
|
+
for (let k = 0; k < n; k++) {
|
|
13
|
+
i += dir;
|
|
14
|
+
if (i < 0 || i >= n) {
|
|
15
|
+
if (!wrap) break;
|
|
16
|
+
i = (i + n) % n;
|
|
17
|
+
}
|
|
18
|
+
if (canStop(items[i])) return i;
|
|
19
|
+
}
|
|
20
|
+
return !wrap && from !== null && canStop(items[from]) ? from : null;
|
|
21
|
+
}
|
|
22
|
+
function arrowDir(key, shape) {
|
|
23
|
+
const orientation = shape.orientation ?? "vertical";
|
|
24
|
+
if (orientation !== "horizontal") {
|
|
25
|
+
if (key === "ArrowDown") return 1;
|
|
26
|
+
if (key === "ArrowUp") return -1;
|
|
27
|
+
}
|
|
28
|
+
if (orientation !== "vertical") {
|
|
29
|
+
const forward = shape.dir === "rtl" ? -1 : 1;
|
|
30
|
+
if (key === "ArrowRight") return forward;
|
|
31
|
+
if (key === "ArrowLeft") return -forward;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
function rovingKey(items, at, key, shape = {}) {
|
|
36
|
+
if (key === "Home") return { handled: true, at: stepStop(items, null, 1, shape) };
|
|
37
|
+
if (key === "End") return { handled: true, at: stepStop(items, null, -1, shape) };
|
|
38
|
+
const dir = arrowDir(key, shape);
|
|
39
|
+
return dir === 0 ? { handled: false } : { handled: true, at: stepStop(items, at, dir, shape) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/typeahead.ts
|
|
43
|
+
var TYPEAHEAD_RESET_MS = 500;
|
|
44
|
+
var EMPTY_TYPEAHEAD = { text: "", at: -Infinity };
|
|
45
|
+
function typeaheadMatch(items, from, query, shape) {
|
|
46
|
+
const q = query.toLowerCase();
|
|
47
|
+
const n = items.length;
|
|
48
|
+
const start = from == null ? 0 : q.length === 1 ? from + 1 : from;
|
|
49
|
+
for (let k = 0; k < n; k++) {
|
|
50
|
+
const i = (start + k) % n;
|
|
51
|
+
const item = items[i];
|
|
52
|
+
if ((shape.canStop?.(item) ?? true) && shape.label(item).toLowerCase().startsWith(q)) return i;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function typeaheadStep(items, from, buffer, key, now, shape, resetMs = TYPEAHEAD_RESET_MS) {
|
|
57
|
+
const prev = now - buffer.at < resetMs ? buffer.text : "";
|
|
58
|
+
const k = key.toLowerCase();
|
|
59
|
+
const cycling = prev !== "" && [...prev].every((c) => c.toLowerCase() === k);
|
|
60
|
+
const text = cycling ? key : prev + key;
|
|
61
|
+
return { buffer: { text, at: now }, hit: typeaheadMatch(items, from, text, shape) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/focus.ts
|
|
65
|
+
function tabSort(targets, declared) {
|
|
66
|
+
const key = (t) => declared(t) ?? Number.POSITIVE_INFINITY;
|
|
67
|
+
return [...targets].sort((a, b) => key(a) - key(b));
|
|
68
|
+
}
|
|
69
|
+
function tabStep(order, from, direction, isBefore) {
|
|
70
|
+
const forward = direction === "next";
|
|
71
|
+
const here = from === null ? -1 : order.indexOf(from);
|
|
72
|
+
let at;
|
|
73
|
+
if (here !== -1) at = here + (forward ? 1 : -1);
|
|
74
|
+
else if (from === null || !isBefore) at = forward ? 0 : order.length - 1;
|
|
75
|
+
else {
|
|
76
|
+
const after = order.findIndex((t) => isBefore(from, t));
|
|
77
|
+
const end = after === -1 ? order.length : after;
|
|
78
|
+
at = forward ? end : end - 1;
|
|
79
|
+
}
|
|
80
|
+
return order[at] ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { EMPTY_TYPEAHEAD, TYPEAHEAD_RESET_MS, firstStop, rovingKey, stepStop, tabSort, tabStep, typeaheadMatch, typeaheadStep };
|
|
84
|
+
//# sourceMappingURL=index.js.map
|
|
85
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/list.ts","../src/typeahead.ts","../src/focus.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/** How a list's items are read: which ones the highlight can land on, and which way its arrows run. */\nexport interface ListShape<T> {\n /** Whether the highlight can land on `item`. A separator or a heading can't. Default: every item. */\n canStop?(item: T): boolean;\n /** Which arrow keys move the highlight. `'both'` answers to all four, the way a radio group does. Default `'vertical'`. */\n orientation?: 'vertical' | 'horizontal' | 'both';\n /** The inline direction the items are laid out in. Under `'rtl'` Left steps forward and Right back. Default `'ltr'`. */\n dir?: 'ltr' | 'rtl';\n /** An arrow past either end comes round to the other. Default true. */\n wrap?: boolean;\n}\n\nconst always = () => true;\n\n/** Index of the first item the highlight can land on, or null for a list with none. */\nexport function firstStop<T>(items: readonly T[], shape: ListShape<T> = {}): number | null {\n const i = items.findIndex(shape.canStop ?? always);\n return i < 0 ? null : i;\n}\n\n/** The next item the highlight can land on from `from` in direction `dir`; from `null`, the first or last.\n * Without wrap, an end stays where it is. Null for a list with no such item. */\nexport function stepStop<T>(\n items: readonly T[], from: number | null, dir: 1 | -1, shape: ListShape<T> = {},\n): number | null {\n const canStop = shape.canStop ?? always;\n const wrap = shape.wrap ?? true;\n const n = items.length;\n let i = from ?? (dir === 1 ? -1 : n);\n for (let k = 0; k < n; k++) {\n i += dir;\n if (i < 0 || i >= n) {\n if (!wrap) break;\n i = (i + n) % n;\n }\n if (canStop(items[i]!)) return i;\n }\n return !wrap && from !== null && canStop(items[from]!) ? from : null;\n}\n\n/** What one key does to a list's highlight. `handled` false for a key that is not the list's to move. */\nexport type RovingResult = { handled: false } | { handled: true; at: number | null };\n\n/** Which way an arrow key steps a list of `shape`: 1, -1, or 0 for a key that is not one of its arrows. */\nfunction arrowDir(key: string, shape: ListShape<unknown>): 1 | -1 | 0 {\n const orientation = shape.orientation ?? 'vertical';\n if (orientation !== 'horizontal') {\n if (key === 'ArrowDown') return 1;\n if (key === 'ArrowUp') return -1;\n }\n if (orientation !== 'vertical') {\n const forward = shape.dir === 'rtl' ? -1 : 1;\n if (key === 'ArrowRight') return forward;\n if (key === 'ArrowLeft') return -forward as 1 | -1;\n }\n return 0;\n}\n\n/** Move the highlight `at` by one key: the arrows along the list's orientation, Home and End. */\nexport function rovingKey<T>(items: readonly T[], at: number | null, key: string, shape: ListShape<T> = {}): RovingResult {\n if (key === 'Home') return { handled: true, at: stepStop(items, null, 1, shape) };\n if (key === 'End') return { handled: true, at: stepStop(items, null, -1, shape) };\n const dir = arrowDir(key, shape as ListShape<unknown>);\n return dir === 0 ? { handled: false } : { handled: true, at: stepStop(items, at, dir, shape) };\n}\n","import type { ListShape } from './list';\n\n/** How typeahead reads a list: its shape, and the text each item is found by. */\nexport interface TypeaheadShape<T> extends Pick<ListShape<T>, 'canStop'> {\n label(item: T): string;\n}\n\n/** How long typing can pause before the typeahead buffer starts over, in milliseconds. */\nexport const TYPEAHEAD_RESET_MS = 500;\n\n/** What has been typed so far, and when the last key came. */\nexport interface TypeaheadBuffer {\n text: string;\n at: number;\n}\n\n/** A buffer nothing has been typed into. */\nexport const EMPTY_TYPEAHEAD: TypeaheadBuffer = { text: '', at: -Infinity };\n\n/** The item a typed `query` lands on, searching forward from `from` with\n * wrap. A one-letter query starts after the highlight so repeating a letter\n * cycles; a longer one starts at it so typing on keeps the match. */\nexport function typeaheadMatch<T>(\n items: readonly T[], from: number | null, query: string, shape: TypeaheadShape<T>,\n): number | null {\n const q = query.toLowerCase();\n const n = items.length;\n const start = from == null ? 0 : q.length === 1 ? from + 1 : from;\n for (let k = 0; k < n; k++) {\n const i = (start + k) % n;\n const item = items[i]!;\n if ((shape.canStop?.(item) ?? true) && shape.label(item).toLowerCase().startsWith(q)) return i;\n }\n return null;\n}\n\n/** One typed character: the new buffer and the item it lands on. A key that\n * repeats the whole buffer searches that one letter, so it cycles. */\nexport function typeaheadStep<T>(\n items: readonly T[],\n from: number | null,\n buffer: TypeaheadBuffer,\n key: string,\n now: number,\n shape: TypeaheadShape<T>,\n resetMs: number = TYPEAHEAD_RESET_MS,\n): { buffer: TypeaheadBuffer; hit: number | null } {\n const prev = now - buffer.at < resetMs ? buffer.text : '';\n const k = key.toLowerCase();\n const cycling = prev !== '' && [...prev].every((c) => c.toLowerCase() === k);\n const text = cycling ? key : prev + key;\n return { buffer: { text, at: now }, hit: typeaheadMatch(items, from, text, shape) };\n}\n","/** How a focus change came about. */\nexport interface FocusScopeOptions {\n /** Whether the target shows its focus ring: the scope's `:focus-visible`. Keyboard moves show one, a press does\n * not. Default `false` for {@link FocusScope.focus}, `true` for {@link FocusScope.moveFocus}. */\n readonly visible?: boolean;\n}\n\n/**\n * Keyboard focus over a set of targets, whatever draws them: a HUD's widgets and a DOM subtree's elements both\n * answer it, so code that moves or reads focus is written once for either.\n */\nexport interface FocusScope<T> {\n /** The target holding keyboard focus, or null. One per scope. */\n readonly focused: T | null;\n /** Whether the focused target shows its focus ring. */\n readonly focusVisible: boolean;\n /** Focus `target`, or blur with `null`. Returns whether `target` now holds focus: false when it is not in this\n * scope or cannot take focus, and focus is then unchanged. */\n focus(target: T | null, options?: FocusScopeOptions): boolean;\n /** Move focus one step through {@link tabOrder}. From nothing, `'next'` lands on the first target and `'prev'` on\n * the last; past either end the scope blurs and returns null, which is where focus leaves it. */\n moveFocus(direction: 'next' | 'prev', options?: FocusScopeOptions): T | null;\n /** The targets Tab visits, in order. */\n tabOrder(): T[];\n /** Run `fn` whenever the focused target changes. Returns an unsubscribe. */\n subscribeFocus(fn: (focused: T | null) => void): () => void;\n}\n\n/** Tab order: targets that declare a place come first, ascending, then the rest in the order given. In the DOM a\n * positive `tabindex` declares one and `0` does not. */\nexport function tabSort<T>(targets: readonly T[], declared: (target: T) => number | undefined): T[] {\n const key = (t: T) => declared(t) ?? Number.POSITIVE_INFINITY;\n // Array.prototype.sort is stable, so ties and the undeclared keep the given order.\n return [...targets].sort((a, b) => key(a) - key(b));\n}\n\n/** The target one Tab step from `from` through `order`, or null past either end. From nothing, `'next'` is the\n * first and `'prev'` the last. A `from` that is out of the order (hidden since it took focus, say) steps from where\n * it sits, which `isBefore` says; without it, such a `from` steps as nothing does. */\nexport function tabStep<T>(\n order: readonly T[], from: T | null, direction: 'next' | 'prev', isBefore?: (a: T, b: T) => boolean,\n): T | null {\n const forward = direction === 'next';\n const here = from === null ? -1 : order.indexOf(from);\n let at: number;\n if (here !== -1) at = here + (forward ? 1 : -1);\n else if (from === null || !isBefore) at = forward ? 0 : order.length - 1;\n else {\n const after = order.findIndex((t) => isBefore(from, t));\n const end = after === -1 ? order.length : after;\n at = forward ? end : end - 1;\n }\n return order[at] ?? null;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weasel-js/headless",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.13.0",
|
|
4
|
+
"description": "Headless components: list behavior (roving highlight, typeahead) that a DOM component and a canvas-drawn widget share. No React, no DOM.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"author": "orochi235",
|
|
19
|
+
"homepage": "https://orochi235.github.io/weasel/",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/orochi235/weasel.git",
|
|
23
|
+
"directory": "packages/headless"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/orochi235/weasel/issues"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
}
|
|
43
|
+
}
|