@comp0/core 0.1.0-next.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 +24 -0
- package/dist/collection.d.ts +25 -0
- package/dist/collection.js +65 -0
- package/dist/date.d.ts +37 -0
- package/dist/date.js +97 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/interactions.d.ts +30 -0
- package/dist/interactions.js +251 -0
- package/dist/roving-focus.d.ts +13 -0
- package/dist/roving-focus.js +28 -0
- package/dist/state.d.ts +10 -0
- package/dist/state.js +60 -0
- package/dist/typeahead.d.ts +21 -0
- package/dist/typeahead.js +67 -0
- package/dist/utils.d.ts +35 -0
- package/dist/utils.js +193 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mewhhaha
|
|
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
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @comp0/core
|
|
2
|
+
|
|
3
|
+
React 19 behavior hooks and DOM utilities for building accessible headless interfaces. It includes interaction hooks, controllable state, collection and focus utilities, prop merging, ref composition, and presence-based data attributes.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm add @comp0/core react@^19
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { dataAttr, usePress } from "@comp0/core";
|
|
11
|
+
|
|
12
|
+
function PressSurface() {
|
|
13
|
+
const { pressProps, isPressed } = usePress<HTMLButtonElement>();
|
|
14
|
+
return (
|
|
15
|
+
<button {...pressProps} data-pressed={dataAttr(isPressed)}>
|
|
16
|
+
Press
|
|
17
|
+
</button>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The package has one root entry point, and its published JavaScript is already transformed by the React Compiler. See the [documentation](https://comp0-docs.horrible.workers.dev) for API details.
|
|
23
|
+
|
|
24
|
+
MIT licensed.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A logical collection entry, independent from the element used to render it. */
|
|
2
|
+
export type CollectionNode<TValue = string, TKey = string> = {
|
|
3
|
+
/** Stable application identity used for selection, focus, and collection lookup. */
|
|
4
|
+
key: TKey;
|
|
5
|
+
/** The DOM id of the rendered element, when the collection participates in ARIA relationships. */
|
|
6
|
+
id: string;
|
|
7
|
+
value: TValue;
|
|
8
|
+
textValue: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
element: HTMLElement | null;
|
|
11
|
+
};
|
|
12
|
+
/** Returns a copy of collection entries in their current DOM order. */
|
|
13
|
+
export declare function sortByDocumentPosition<TValue, TKey>(items: CollectionNode<TValue, TKey>[]): CollectionNode<TValue, TKey>[];
|
|
14
|
+
/**
|
|
15
|
+
* Registers collection entries and reads them in DOM order.
|
|
16
|
+
*
|
|
17
|
+
* `key` identifies the logical item; `id` remains available for DOM and ARIA references.
|
|
18
|
+
*/
|
|
19
|
+
type CollectionRegistry<TValue, TKey> = {
|
|
20
|
+
register: (node: CollectionNode<TValue, TKey>) => () => void;
|
|
21
|
+
getItems: () => CollectionNode<TValue, TKey>[];
|
|
22
|
+
getEnabledItems: () => CollectionNode<TValue, TKey>[];
|
|
23
|
+
};
|
|
24
|
+
export declare function useCollectionRegistry<TValue = string, TKey = string>(): CollectionRegistry<TValue, TKey>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useCallback, useRef } from "react";
|
|
3
|
+
/** Returns a copy of collection entries in their current DOM order. */
|
|
4
|
+
export function sortByDocumentPosition(items) {
|
|
5
|
+
return [...items].sort((a, b) => {
|
|
6
|
+
if (!a.element || !b.element || a.element === b.element) return 0;
|
|
7
|
+
const position = a.element.compareDocumentPosition(b.element);
|
|
8
|
+
return position & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function useCollectionRegistry() {
|
|
12
|
+
const $ = _c(5);
|
|
13
|
+
let t0;
|
|
14
|
+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
|
15
|
+
t0 = new Map();
|
|
16
|
+
$[0] = t0;
|
|
17
|
+
} else {
|
|
18
|
+
t0 = $[0];
|
|
19
|
+
}
|
|
20
|
+
const itemsRef = useRef(t0);
|
|
21
|
+
let t1;
|
|
22
|
+
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
|
|
23
|
+
t1 = (node) => {
|
|
24
|
+
itemsRef.current.set(node.key, node);
|
|
25
|
+
return () => {
|
|
26
|
+
itemsRef.current.delete(node.key);
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
$[1] = t1;
|
|
30
|
+
} else {
|
|
31
|
+
t1 = $[1];
|
|
32
|
+
}
|
|
33
|
+
const register = t1;
|
|
34
|
+
let t2;
|
|
35
|
+
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
|
36
|
+
t2 = () => sortByDocumentPosition([...itemsRef.current.values()]);
|
|
37
|
+
$[2] = t2;
|
|
38
|
+
} else {
|
|
39
|
+
t2 = $[2];
|
|
40
|
+
}
|
|
41
|
+
const getItems = t2;
|
|
42
|
+
let t3;
|
|
43
|
+
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
|
|
44
|
+
t3 = () => getItems().filter(_temp);
|
|
45
|
+
$[3] = t3;
|
|
46
|
+
} else {
|
|
47
|
+
t3 = $[3];
|
|
48
|
+
}
|
|
49
|
+
const getEnabledItems = t3;
|
|
50
|
+
let t4;
|
|
51
|
+
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
|
|
52
|
+
t4 = {
|
|
53
|
+
register,
|
|
54
|
+
getItems,
|
|
55
|
+
getEnabledItems
|
|
56
|
+
};
|
|
57
|
+
$[4] = t4;
|
|
58
|
+
} else {
|
|
59
|
+
t4 = $[4];
|
|
60
|
+
}
|
|
61
|
+
return t4;
|
|
62
|
+
}
|
|
63
|
+
function _temp(item) {
|
|
64
|
+
return !item.disabled;
|
|
65
|
+
}
|
package/dist/date.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure ISO calendar-date helpers. Every public value is a "YYYY-MM-DD"
|
|
3
|
+
* string; Date objects only appear internally, pinned to UTC noon so
|
|
4
|
+
* daylight-saving transitions can never skip or repeat a calendar day.
|
|
5
|
+
*/
|
|
6
|
+
/** One cell of a month matrix: an ISO date plus whether it belongs to a neighboring month. */
|
|
7
|
+
export type MonthMatrixCell = {
|
|
8
|
+
iso: string;
|
|
9
|
+
outsideMonth: boolean;
|
|
10
|
+
};
|
|
11
|
+
/** The number of days in a one-based month of a given year. */
|
|
12
|
+
export declare function daysInMonth(year: number, month: number): number;
|
|
13
|
+
/** Parses "YYYY-MM-DD" into a UTC-noon Date, or null for malformed or impossible dates. */
|
|
14
|
+
export declare function parseISODate(value: string): Date | null;
|
|
15
|
+
/** Formats a Date's UTC calendar day as "YYYY-MM-DD". */
|
|
16
|
+
export declare function formatISODate(date: Date): string;
|
|
17
|
+
/** Whether a string is a real "YYYY-MM-DD" calendar date. */
|
|
18
|
+
export declare function isValidISODate(value: string): boolean;
|
|
19
|
+
/** Today's date in the runtime's local timezone as "YYYY-MM-DD". */
|
|
20
|
+
export declare function todayISODate(): string;
|
|
21
|
+
/** Adds days to an ISO date; UTC-noon arithmetic keeps DST from shifting the day. */
|
|
22
|
+
export declare function addDays(iso: string, amount: number): string;
|
|
23
|
+
/** Adds months to an ISO date, clamping the day to the target month's end (Jan 31 + 1 month is Feb 29 in a leap year). */
|
|
24
|
+
export declare function addMonths(iso: string, amount: number): string;
|
|
25
|
+
/** Whether ISO date a falls strictly before b. */
|
|
26
|
+
export declare function isBefore(a: string, b: string): boolean;
|
|
27
|
+
/** Whether ISO date a falls strictly after b. */
|
|
28
|
+
export declare function isAfter(a: string, b: string): boolean;
|
|
29
|
+
/** Clamps an ISO date into an inclusive [min, max] range; either bound may be omitted. */
|
|
30
|
+
export declare function clampISODate(iso: string, min: string | undefined, max: string | undefined): string;
|
|
31
|
+
/**
|
|
32
|
+
* Weeks covering an ISO month ("YYYY-MM"), each exactly seven cells wide.
|
|
33
|
+
* weekStart follows the Intl.Locale weekInfo convention: 1 = Monday through
|
|
34
|
+
* 7 = Sunday. Leading and trailing cells from neighboring months are marked
|
|
35
|
+
* outsideMonth.
|
|
36
|
+
*/
|
|
37
|
+
export declare function monthMatrix(isoMonth: string, weekStart: number): MonthMatrixCell[][];
|
package/dist/date.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2
|
+
const ISO_MONTH_PATTERN = /^(\d{4})-(\d{2})$/;
|
|
3
|
+
/** The number of days in a one-based month of a given year. */
|
|
4
|
+
export function daysInMonth(year, month) {
|
|
5
|
+
return new Date(Date.UTC(year, month, 0, 12)).getUTCDate();
|
|
6
|
+
}
|
|
7
|
+
/** Parses "YYYY-MM-DD" into a UTC-noon Date, or null for malformed or impossible dates. */
|
|
8
|
+
export function parseISODate(value) {
|
|
9
|
+
const match = ISO_DATE_PATTERN.exec(value);
|
|
10
|
+
if (!match) return null;
|
|
11
|
+
const year = Number(match[1]);
|
|
12
|
+
const month = Number(match[2]);
|
|
13
|
+
const day = Number(match[3]);
|
|
14
|
+
if (month < 1 || month > 12) return null;
|
|
15
|
+
if (day < 1 || day > daysInMonth(year, month)) return null;
|
|
16
|
+
return new Date(Date.UTC(year, month - 1, day, 12));
|
|
17
|
+
}
|
|
18
|
+
/** Formats a Date's UTC calendar day as "YYYY-MM-DD". */
|
|
19
|
+
export function formatISODate(date) {
|
|
20
|
+
const year = String(date.getUTCFullYear()).padStart(4, "0");
|
|
21
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
22
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
23
|
+
return `${year}-${month}-${day}`;
|
|
24
|
+
}
|
|
25
|
+
/** Whether a string is a real "YYYY-MM-DD" calendar date. */
|
|
26
|
+
export function isValidISODate(value) {
|
|
27
|
+
return parseISODate(value) !== null;
|
|
28
|
+
}
|
|
29
|
+
/** Today's date in the runtime's local timezone as "YYYY-MM-DD". */
|
|
30
|
+
export function todayISODate() {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
return formatISODate(new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 12)));
|
|
33
|
+
}
|
|
34
|
+
/** Adds days to an ISO date; UTC-noon arithmetic keeps DST from shifting the day. */
|
|
35
|
+
export function addDays(iso, amount) {
|
|
36
|
+
const date = parseISODate(iso);
|
|
37
|
+
if (!date) return iso;
|
|
38
|
+
date.setUTCDate(date.getUTCDate() + amount);
|
|
39
|
+
return formatISODate(date);
|
|
40
|
+
}
|
|
41
|
+
/** Adds months to an ISO date, clamping the day to the target month's end (Jan 31 + 1 month is Feb 29 in a leap year). */
|
|
42
|
+
export function addMonths(iso, amount) {
|
|
43
|
+
const date = parseISODate(iso);
|
|
44
|
+
if (!date) return iso;
|
|
45
|
+
const monthIndex = date.getUTCMonth() + amount;
|
|
46
|
+
const year = date.getUTCFullYear() + Math.floor(monthIndex / 12);
|
|
47
|
+
const month = (monthIndex % 12 + 12) % 12 + 1;
|
|
48
|
+
const day = Math.min(date.getUTCDate(), daysInMonth(year, month));
|
|
49
|
+
return formatISODate(new Date(Date.UTC(year, month - 1, day, 12)));
|
|
50
|
+
}
|
|
51
|
+
/** Whether ISO date a falls strictly before b. */
|
|
52
|
+
export function isBefore(a, b) {
|
|
53
|
+
return a < b;
|
|
54
|
+
}
|
|
55
|
+
/** Whether ISO date a falls strictly after b. */
|
|
56
|
+
export function isAfter(a, b) {
|
|
57
|
+
return a > b;
|
|
58
|
+
}
|
|
59
|
+
/** Clamps an ISO date into an inclusive [min, max] range; either bound may be omitted. */
|
|
60
|
+
export function clampISODate(iso, min, max) {
|
|
61
|
+
if (min && isBefore(iso, min)) return min;
|
|
62
|
+
if (max && isAfter(iso, max)) return max;
|
|
63
|
+
return iso;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Weeks covering an ISO month ("YYYY-MM"), each exactly seven cells wide.
|
|
67
|
+
* weekStart follows the Intl.Locale weekInfo convention: 1 = Monday through
|
|
68
|
+
* 7 = Sunday. Leading and trailing cells from neighboring months are marked
|
|
69
|
+
* outsideMonth.
|
|
70
|
+
*/
|
|
71
|
+
export function monthMatrix(isoMonth, weekStart) {
|
|
72
|
+
const match = ISO_MONTH_PATTERN.exec(isoMonth);
|
|
73
|
+
if (!match) return [];
|
|
74
|
+
const year = Number(match[1]);
|
|
75
|
+
const month = Number(match[2]);
|
|
76
|
+
if (month < 1 || month > 12) return [];
|
|
77
|
+
const first = new Date(Date.UTC(year, month - 1, 1, 12));
|
|
78
|
+
// getUTCDay counts 0 = Sunday through 6; weekInfo counts 1 = Monday through 7 = Sunday.
|
|
79
|
+
const firstWeekday = first.getUTCDay() === 0 ? 7 : first.getUTCDay();
|
|
80
|
+
const lead = (firstWeekday - weekStart + 7) % 7;
|
|
81
|
+
const total = daysInMonth(year, month);
|
|
82
|
+
const weekCount = Math.ceil((lead + total) / 7);
|
|
83
|
+
const weeks = [];
|
|
84
|
+
for (let weekIndex = 0; weekIndex < weekCount; weekIndex += 1) {
|
|
85
|
+
const week = [];
|
|
86
|
+
for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
|
|
87
|
+
const offset = weekIndex * 7 + dayIndex - lead;
|
|
88
|
+
const date = new Date(Date.UTC(year, month - 1, 1 + offset, 12));
|
|
89
|
+
week.push({
|
|
90
|
+
iso: formatISODate(date),
|
|
91
|
+
outsideMonth: date.getUTCMonth() !== month - 1
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
weeks.push(week);
|
|
95
|
+
}
|
|
96
|
+
return weeks;
|
|
97
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type HTMLAttributes } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Tracks focus and keyboard-visible focus for an element.
|
|
4
|
+
*
|
|
5
|
+
* Modality listeners are installed only after the element receives focus, on that
|
|
6
|
+
* element's `ownerDocument`, and are removed when no focus ring remains focused.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useFocusRing<TElement extends HTMLElement = HTMLElement>(options?: {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}): {
|
|
11
|
+
focusProps: HTMLAttributes<TElement>;
|
|
12
|
+
isFocused: boolean;
|
|
13
|
+
isFocusVisible: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** Tracks pointer hover while ignoring touch pointers and disabled elements. */
|
|
16
|
+
export declare function useHover<TElement extends HTMLElement = HTMLElement>(options?: {
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}): {
|
|
19
|
+
hoverProps: HTMLAttributes<TElement>;
|
|
20
|
+
isHovered: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** Tracks pointer and keyboard press state for an element. */
|
|
23
|
+
export declare function usePress<TElement extends HTMLElement = HTMLElement>(options?: {
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}): {
|
|
26
|
+
pressProps: HTMLAttributes<TElement>;
|
|
27
|
+
isPressed: boolean;
|
|
28
|
+
};
|
|
29
|
+
/** Merges interaction props, chaining event handlers in declaration order. */
|
|
30
|
+
export declare function mergeInteractionProps<T extends HTMLAttributes<HTMLElement>>(...propsList: T[]): T;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { chainHandlers } from "./utils.js";
|
|
4
|
+
const focusVisibleDocumentStates = new WeakMap();
|
|
5
|
+
function getFocusVisibleDocumentState(ownerDocument) {
|
|
6
|
+
let state = focusVisibleDocumentStates.get(ownerDocument);
|
|
7
|
+
if (!state) {
|
|
8
|
+
const subscribers = new Set();
|
|
9
|
+
const notify = () => {
|
|
10
|
+
for (const callback of subscribers) callback();
|
|
11
|
+
};
|
|
12
|
+
const nextState = {
|
|
13
|
+
hadKeyboardEvent: false,
|
|
14
|
+
subscribers,
|
|
15
|
+
onKeyDown(event) {
|
|
16
|
+
if (event.metaKey || event.altKey || event.ctrlKey) return;
|
|
17
|
+
nextState.hadKeyboardEvent = true;
|
|
18
|
+
notify();
|
|
19
|
+
},
|
|
20
|
+
onPointerDown() {
|
|
21
|
+
nextState.hadKeyboardEvent = false;
|
|
22
|
+
notify();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
state = nextState;
|
|
26
|
+
focusVisibleDocumentStates.set(ownerDocument, state);
|
|
27
|
+
}
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
function subscribeToFocusVisible(ownerDocument, subscriber) {
|
|
31
|
+
const state = getFocusVisibleDocumentState(ownerDocument);
|
|
32
|
+
if (state.subscribers.size === 0) {
|
|
33
|
+
ownerDocument.addEventListener("keydown", state.onKeyDown, true);
|
|
34
|
+
ownerDocument.addEventListener("pointerdown", state.onPointerDown, true);
|
|
35
|
+
}
|
|
36
|
+
state.subscribers.add(subscriber);
|
|
37
|
+
return () => {
|
|
38
|
+
state.subscribers.delete(subscriber);
|
|
39
|
+
if (state.subscribers.size !== 0) return;
|
|
40
|
+
ownerDocument.removeEventListener("keydown", state.onKeyDown, true);
|
|
41
|
+
ownerDocument.removeEventListener("pointerdown", state.onPointerDown, true);
|
|
42
|
+
focusVisibleDocumentStates.delete(ownerDocument);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Tracks focus and keyboard-visible focus for an element.
|
|
47
|
+
*
|
|
48
|
+
* Modality listeners are installed only after the element receives focus, on that
|
|
49
|
+
* element's `ownerDocument`, and are removed when no focus ring remains focused.
|
|
50
|
+
*/
|
|
51
|
+
export function useFocusRing(t0) {
|
|
52
|
+
const $ = _c(12);
|
|
53
|
+
let t1;
|
|
54
|
+
if ($[0] !== t0) {
|
|
55
|
+
t1 = t0 === undefined ? {} : t0;
|
|
56
|
+
$[0] = t0;
|
|
57
|
+
$[1] = t1;
|
|
58
|
+
} else {
|
|
59
|
+
t1 = $[1];
|
|
60
|
+
}
|
|
61
|
+
const options = t1;
|
|
62
|
+
const [isFocused, setFocused] = useState(false);
|
|
63
|
+
const [isFocusVisible, setFocusVisible] = useState(false);
|
|
64
|
+
const [ownerDocument, setOwnerDocument] = useState();
|
|
65
|
+
const isFocusedRef = useRef(false);
|
|
66
|
+
let t2;
|
|
67
|
+
let t3;
|
|
68
|
+
if ($[2] !== isFocused || $[3] !== ownerDocument) {
|
|
69
|
+
t2 = () => {
|
|
70
|
+
if (!ownerDocument || !isFocused) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
return subscribeToFocusVisible(ownerDocument, () => {
|
|
74
|
+
if (isFocusedRef.current) {
|
|
75
|
+
setFocusVisible(getFocusVisibleDocumentState(ownerDocument).hadKeyboardEvent);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
t3 = [isFocused, ownerDocument];
|
|
80
|
+
$[2] = isFocused;
|
|
81
|
+
$[3] = ownerDocument;
|
|
82
|
+
$[4] = t2;
|
|
83
|
+
$[5] = t3;
|
|
84
|
+
} else {
|
|
85
|
+
t2 = $[4];
|
|
86
|
+
t3 = $[5];
|
|
87
|
+
}
|
|
88
|
+
useEffect(t2, t3);
|
|
89
|
+
let t4;
|
|
90
|
+
if ($[6] !== options) {
|
|
91
|
+
t4 = {
|
|
92
|
+
onFocus(event) {
|
|
93
|
+
if (options.disabled) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const document = event.currentTarget.ownerDocument;
|
|
97
|
+
isFocusedRef.current = true;
|
|
98
|
+
setOwnerDocument(document);
|
|
99
|
+
setFocused(true);
|
|
100
|
+
setFocusVisible(getFocusVisibleDocumentState(document).hadKeyboardEvent || event.currentTarget.matches(":focus-visible"));
|
|
101
|
+
},
|
|
102
|
+
onBlur() {
|
|
103
|
+
isFocusedRef.current = false;
|
|
104
|
+
setFocused(false);
|
|
105
|
+
setFocusVisible(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
$[6] = options;
|
|
109
|
+
$[7] = t4;
|
|
110
|
+
} else {
|
|
111
|
+
t4 = $[7];
|
|
112
|
+
}
|
|
113
|
+
const focusProps = t4;
|
|
114
|
+
let t5;
|
|
115
|
+
if ($[8] !== focusProps || $[9] !== isFocusVisible || $[10] !== isFocused) {
|
|
116
|
+
t5 = {
|
|
117
|
+
focusProps,
|
|
118
|
+
isFocused,
|
|
119
|
+
isFocusVisible
|
|
120
|
+
};
|
|
121
|
+
$[8] = focusProps;
|
|
122
|
+
$[9] = isFocusVisible;
|
|
123
|
+
$[10] = isFocused;
|
|
124
|
+
$[11] = t5;
|
|
125
|
+
} else {
|
|
126
|
+
t5 = $[11];
|
|
127
|
+
}
|
|
128
|
+
return t5;
|
|
129
|
+
}
|
|
130
|
+
/** Tracks pointer hover while ignoring touch pointers and disabled elements. */
|
|
131
|
+
export function useHover(t0) {
|
|
132
|
+
const $ = _c(7);
|
|
133
|
+
let t1;
|
|
134
|
+
if ($[0] !== t0) {
|
|
135
|
+
t1 = t0 === undefined ? {} : t0;
|
|
136
|
+
$[0] = t0;
|
|
137
|
+
$[1] = t1;
|
|
138
|
+
} else {
|
|
139
|
+
t1 = $[1];
|
|
140
|
+
}
|
|
141
|
+
const options = t1;
|
|
142
|
+
const [isHovered, setHovered] = useState(false);
|
|
143
|
+
let t2;
|
|
144
|
+
if ($[2] !== options) {
|
|
145
|
+
t2 = {
|
|
146
|
+
onPointerEnter(event) {
|
|
147
|
+
if (options.disabled || event.pointerType === "touch") {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
setHovered(true);
|
|
151
|
+
},
|
|
152
|
+
onPointerLeave() {
|
|
153
|
+
setHovered(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
$[2] = options;
|
|
157
|
+
$[3] = t2;
|
|
158
|
+
} else {
|
|
159
|
+
t2 = $[3];
|
|
160
|
+
}
|
|
161
|
+
const hoverProps = t2;
|
|
162
|
+
let t3;
|
|
163
|
+
if ($[4] !== hoverProps || $[5] !== isHovered) {
|
|
164
|
+
t3 = {
|
|
165
|
+
hoverProps,
|
|
166
|
+
isHovered
|
|
167
|
+
};
|
|
168
|
+
$[4] = hoverProps;
|
|
169
|
+
$[5] = isHovered;
|
|
170
|
+
$[6] = t3;
|
|
171
|
+
} else {
|
|
172
|
+
t3 = $[6];
|
|
173
|
+
}
|
|
174
|
+
return t3;
|
|
175
|
+
}
|
|
176
|
+
/** Tracks pointer and keyboard press state for an element. */
|
|
177
|
+
export function usePress(t0) {
|
|
178
|
+
const $ = _c(7);
|
|
179
|
+
let t1;
|
|
180
|
+
if ($[0] !== t0) {
|
|
181
|
+
t1 = t0 === undefined ? {} : t0;
|
|
182
|
+
$[0] = t0;
|
|
183
|
+
$[1] = t1;
|
|
184
|
+
} else {
|
|
185
|
+
t1 = $[1];
|
|
186
|
+
}
|
|
187
|
+
const options = t1;
|
|
188
|
+
const [isPressed, setPressed] = useState(false);
|
|
189
|
+
let t2;
|
|
190
|
+
if ($[2] !== options) {
|
|
191
|
+
t2 = {
|
|
192
|
+
onPointerDown(event) {
|
|
193
|
+
if (options.disabled || event.button !== 0) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setPressed(true);
|
|
197
|
+
},
|
|
198
|
+
onPointerUp() {
|
|
199
|
+
setPressed(false);
|
|
200
|
+
},
|
|
201
|
+
onPointerCancel() {
|
|
202
|
+
setPressed(false);
|
|
203
|
+
},
|
|
204
|
+
onPointerLeave() {
|
|
205
|
+
setPressed(false);
|
|
206
|
+
},
|
|
207
|
+
onKeyDown(event_0) {
|
|
208
|
+
if (options.disabled) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (event_0.key === " " || event_0.key === "Enter") {
|
|
212
|
+
setPressed(true);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
onKeyUp() {
|
|
216
|
+
setPressed(false);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
$[2] = options;
|
|
220
|
+
$[3] = t2;
|
|
221
|
+
} else {
|
|
222
|
+
t2 = $[3];
|
|
223
|
+
}
|
|
224
|
+
const pressProps = t2;
|
|
225
|
+
let t3;
|
|
226
|
+
if ($[4] !== isPressed || $[5] !== pressProps) {
|
|
227
|
+
t3 = {
|
|
228
|
+
pressProps,
|
|
229
|
+
isPressed
|
|
230
|
+
};
|
|
231
|
+
$[4] = isPressed;
|
|
232
|
+
$[5] = pressProps;
|
|
233
|
+
$[6] = t3;
|
|
234
|
+
} else {
|
|
235
|
+
t3 = $[6];
|
|
236
|
+
}
|
|
237
|
+
return t3;
|
|
238
|
+
}
|
|
239
|
+
/** Merges interaction props, chaining event handlers in declaration order. */
|
|
240
|
+
export function mergeInteractionProps(...propsList) {
|
|
241
|
+
return propsList.reduce((acc, props) => {
|
|
242
|
+
for (const [key, value] of Object.entries(props)) {
|
|
243
|
+
if (/^on[A-Z]/.test(key) && typeof value === "function" && typeof acc[key] === "function") {
|
|
244
|
+
acc[key] = chainHandlers(acc[key], value);
|
|
245
|
+
} else {
|
|
246
|
+
acc[key] = value;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return acc;
|
|
250
|
+
}, {});
|
|
251
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The arrow-key directions supported by a roving-focus collection. */
|
|
2
|
+
export type RovingFocusOrientation = "horizontal" | "vertical" | "both";
|
|
3
|
+
/** The identity and enabled state needed for roving focus navigation. */
|
|
4
|
+
export interface RovingFocusItem {
|
|
5
|
+
key: string;
|
|
6
|
+
disabled?: boolean | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Resolves the next enabled item for an APG-style roving-focus key press. */
|
|
9
|
+
export declare function getRovingFocusTarget(items: RovingFocusItem[], currentKey: string | undefined, key: string, options?: {
|
|
10
|
+
orientation?: RovingFocusOrientation;
|
|
11
|
+
dir?: "ltr" | "rtl";
|
|
12
|
+
loop?: boolean;
|
|
13
|
+
}): string | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function isForwardKey(key, orientation, dir) {
|
|
2
|
+
if (orientation !== "vertical" && key === "ArrowRight") return dir === "ltr";
|
|
3
|
+
if (orientation !== "vertical" && key === "ArrowLeft") return dir === "rtl";
|
|
4
|
+
if (orientation !== "horizontal" && key === "ArrowDown") return true;
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
function isBackwardKey(key, orientation, dir) {
|
|
8
|
+
if (orientation !== "vertical" && key === "ArrowLeft") return dir === "ltr";
|
|
9
|
+
if (orientation !== "vertical" && key === "ArrowRight") return dir === "rtl";
|
|
10
|
+
if (orientation !== "horizontal" && key === "ArrowUp") return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
/** Resolves the next enabled item for an APG-style roving-focus key press. */
|
|
14
|
+
export function getRovingFocusTarget(items, currentKey, key, options = {}) {
|
|
15
|
+
const orientation = options.orientation ?? "both";
|
|
16
|
+
const dir = options.dir ?? "ltr";
|
|
17
|
+
const enabledItems = items.filter((item) => !item.disabled);
|
|
18
|
+
if (!enabledItems.length) return undefined;
|
|
19
|
+
if (key === "Home") return enabledItems[0]?.key;
|
|
20
|
+
if (key === "End") return enabledItems.at(-1)?.key;
|
|
21
|
+
const direction = isForwardKey(key, orientation, dir) ? 1 : isBackwardKey(key, orientation, dir) ? -1 : 0;
|
|
22
|
+
if (direction === 0) return undefined;
|
|
23
|
+
const currentIndex = Math.max(0, enabledItems.findIndex((item) => item.key === currentKey));
|
|
24
|
+
const nextIndex = currentIndex + direction;
|
|
25
|
+
if (nextIndex >= 0 && nextIndex < enabledItems.length) return enabledItems[nextIndex]?.key;
|
|
26
|
+
if (!options.loop) return enabledItems[currentIndex]?.key;
|
|
27
|
+
return direction > 0 ? enabledItems[0]?.key : enabledItems.at(-1)?.key;
|
|
28
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Options for a controlled or uncontrolled state value. */
|
|
2
|
+
export interface ControllableStateOptions<T> {
|
|
3
|
+
value?: T | undefined;
|
|
4
|
+
defaultValue: T;
|
|
5
|
+
onChange?: ((value: T) => void) | undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Manages a value that may be controlled by the caller or initialized internally.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useControllableState<T>(options: ControllableStateOptions<T>): readonly [T, (next: T | ((current: T) => T)) => void];
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
|
+
import { useEventCallback, useIsoLayoutEffect } from "./utils.js";
|
|
4
|
+
export function useControllableState(t0) {
|
|
5
|
+
const $ = _c(9);
|
|
6
|
+
const { value, defaultValue, onChange } = t0;
|
|
7
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
8
|
+
const isControlled = value !== undefined;
|
|
9
|
+
const currentValue = isControlled ? value : uncontrolledValue;
|
|
10
|
+
const onChangeStable = useEventCallback(onChange);
|
|
11
|
+
const currentValueRef = useRef(currentValue);
|
|
12
|
+
const isControlledRef = useRef(isControlled);
|
|
13
|
+
let t1;
|
|
14
|
+
let t2;
|
|
15
|
+
if ($[0] !== currentValue || $[1] !== isControlled) {
|
|
16
|
+
t1 = () => {
|
|
17
|
+
currentValueRef.current = currentValue;
|
|
18
|
+
isControlledRef.current = isControlled;
|
|
19
|
+
};
|
|
20
|
+
t2 = [currentValue, isControlled];
|
|
21
|
+
$[0] = currentValue;
|
|
22
|
+
$[1] = isControlled;
|
|
23
|
+
$[2] = t1;
|
|
24
|
+
$[3] = t2;
|
|
25
|
+
} else {
|
|
26
|
+
t1 = $[2];
|
|
27
|
+
t2 = $[3];
|
|
28
|
+
}
|
|
29
|
+
useIsoLayoutEffect(t1, t2);
|
|
30
|
+
let t3;
|
|
31
|
+
if ($[4] !== onChangeStable) {
|
|
32
|
+
t3 = (next) => {
|
|
33
|
+
const previousValue = currentValueRef.current;
|
|
34
|
+
const resolvedValue = typeof next === "function" ? next(previousValue) : next;
|
|
35
|
+
if (Object.is(resolvedValue, previousValue)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
currentValueRef.current = resolvedValue;
|
|
39
|
+
if (!isControlledRef.current) {
|
|
40
|
+
setUncontrolledValue(resolvedValue);
|
|
41
|
+
}
|
|
42
|
+
onChangeStable(resolvedValue);
|
|
43
|
+
};
|
|
44
|
+
$[4] = onChangeStable;
|
|
45
|
+
$[5] = t3;
|
|
46
|
+
} else {
|
|
47
|
+
t3 = $[5];
|
|
48
|
+
}
|
|
49
|
+
const setValue = t3;
|
|
50
|
+
let t4;
|
|
51
|
+
if ($[6] !== currentValue || $[7] !== setValue) {
|
|
52
|
+
t4 = [currentValue, setValue];
|
|
53
|
+
$[6] = currentValue;
|
|
54
|
+
$[7] = setValue;
|
|
55
|
+
$[8] = t4;
|
|
56
|
+
} else {
|
|
57
|
+
t4 = $[8];
|
|
58
|
+
}
|
|
59
|
+
return t4;
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The search fields needed to match a collection item by typed text. */
|
|
2
|
+
export interface TypeaheadItem {
|
|
3
|
+
key: string;
|
|
4
|
+
textValue: string;
|
|
5
|
+
disabled?: boolean | undefined;
|
|
6
|
+
}
|
|
7
|
+
/** Finds the next enabled item whose text begins with the search string. */
|
|
8
|
+
export declare function findTypeaheadMatch(items: TypeaheadItem[], search: string, currentKey?: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Returns an accumulator for typeahead searches: each printable key extends
|
|
11
|
+
* the search string until the timeout elapses, so typing "co" matches
|
|
12
|
+
* "Copy" instead of jumping between "c" and "o" items.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useTypeaheadSearch(timeout?: number): (key: string) => string;
|
|
15
|
+
/** Returns a keyboard handler that buffers printable characters for typeahead navigation. */
|
|
16
|
+
export declare function useTypeahead(options: {
|
|
17
|
+
items: TypeaheadItem[];
|
|
18
|
+
currentKey?: string | undefined;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
onMatch: (key: string) => void;
|
|
21
|
+
}): (event: Pick<KeyboardEvent, "key" | "preventDefault">) => void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
/** Finds the next enabled item whose text begins with the search string. */
|
|
4
|
+
export function findTypeaheadMatch(items, search, currentKey) {
|
|
5
|
+
const enabled = items.filter((item) => !item.disabled);
|
|
6
|
+
if (!search || !enabled.length) return undefined;
|
|
7
|
+
const startIndex = Math.max(0, enabled.findIndex((item) => item.key === currentKey) + 1);
|
|
8
|
+
const ordered = [...enabled.slice(startIndex), ...enabled.slice(0, startIndex)];
|
|
9
|
+
const normalizedSearch = search.toLocaleLowerCase();
|
|
10
|
+
return ordered.find((item) => item.textValue.toLocaleLowerCase().startsWith(normalizedSearch))?.key;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Returns an accumulator for typeahead searches: each printable key extends
|
|
14
|
+
* the search string until the timeout elapses, so typing "co" matches
|
|
15
|
+
* "Copy" instead of jumping between "c" and "o" items.
|
|
16
|
+
*/
|
|
17
|
+
export function useTypeaheadSearch(t0) {
|
|
18
|
+
const $ = _c(2);
|
|
19
|
+
const timeout = t0 === undefined ? 700 : t0;
|
|
20
|
+
const bufferRef = useRef("");
|
|
21
|
+
const timeoutRef = useRef(undefined);
|
|
22
|
+
let t1;
|
|
23
|
+
if ($[0] !== timeout) {
|
|
24
|
+
t1 = (key) => {
|
|
25
|
+
window.clearTimeout(timeoutRef.current);
|
|
26
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
27
|
+
bufferRef.current = "";
|
|
28
|
+
}, timeout);
|
|
29
|
+
bufferRef.current = bufferRef.current + key;
|
|
30
|
+
return bufferRef.current;
|
|
31
|
+
};
|
|
32
|
+
$[0] = timeout;
|
|
33
|
+
$[1] = t1;
|
|
34
|
+
} else {
|
|
35
|
+
t1 = $[1];
|
|
36
|
+
}
|
|
37
|
+
return t1;
|
|
38
|
+
}
|
|
39
|
+
/** Returns a keyboard handler that buffers printable characters for typeahead navigation. */
|
|
40
|
+
export function useTypeahead(options) {
|
|
41
|
+
const $ = _c(2);
|
|
42
|
+
const bufferRef = useRef("");
|
|
43
|
+
const timeoutRef = useRef(undefined);
|
|
44
|
+
let t0;
|
|
45
|
+
if ($[0] !== options) {
|
|
46
|
+
t0 = (event) => {
|
|
47
|
+
if (event.key.length !== 1 || event.key.trim() === "") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
window.clearTimeout(timeoutRef.current);
|
|
51
|
+
bufferRef.current = bufferRef.current + event.key;
|
|
52
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
53
|
+
bufferRef.current = "";
|
|
54
|
+
}, options.timeout ?? 700);
|
|
55
|
+
const match = findTypeaheadMatch(options.items, bufferRef.current, options.currentKey);
|
|
56
|
+
if (match) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
options.onMatch(match);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
$[0] = options;
|
|
62
|
+
$[1] = t0;
|
|
63
|
+
} else {
|
|
64
|
+
t0 = $[1];
|
|
65
|
+
}
|
|
66
|
+
return t0;
|
|
67
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Ref, type RefCallback, useLayoutEffect } from "react";
|
|
2
|
+
/** A minimal event handler shape used by prop-composition utilities. */
|
|
3
|
+
export type AnyEventHandler = (event: {
|
|
4
|
+
defaultPrevented?: boolean;
|
|
5
|
+
}) => void;
|
|
6
|
+
/** A callback ref, object ref, or omitted ref. */
|
|
7
|
+
export type PossibleRef<T> = Ref<T> | undefined;
|
|
8
|
+
type RefCleanup = () => void;
|
|
9
|
+
/** Whether DOM globals are available in the current runtime. */
|
|
10
|
+
export declare const isBrowser: boolean;
|
|
11
|
+
/** Uses a layout effect in the browser and an effect during server rendering. */
|
|
12
|
+
export declare const useIsoLayoutEffect: typeof useLayoutEffect;
|
|
13
|
+
/** Assigns a value to either form of React ref and returns its React 19 cleanup. */
|
|
14
|
+
export declare function assignRef<T>(ref: PossibleRef<T>, value: T | null): RefCleanup | undefined;
|
|
15
|
+
/** Combines refs into one callback ref that preserves every ref cleanup. */
|
|
16
|
+
export declare function composeRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T>;
|
|
17
|
+
/** Returns a stable callback ref that always writes to the latest supplied refs. */
|
|
18
|
+
export declare function useComposedRefs<T>(...refs: PossibleRef<T>[]): RefCallback<T>;
|
|
19
|
+
/** Returns a stable callback that invokes the latest supplied implementation. */
|
|
20
|
+
export declare function useEventCallback<T extends (...args: never[]) => unknown>(callback: T | undefined): (...args: Parameters<T>) => ReturnType<T> | undefined;
|
|
21
|
+
/** Chains handlers, allowing the first to prevent the second by default. */
|
|
22
|
+
export declare function chainHandlers<T extends AnyEventHandler>(theirs: T | undefined, ours: T | undefined, options?: {
|
|
23
|
+
checkDefaultPrevented?: boolean;
|
|
24
|
+
}): (event: Parameters<T>[0]) => void;
|
|
25
|
+
/** Merges DOM props, concatenating classes, merging styles, and chaining handlers. */
|
|
26
|
+
export declare function mergeProps<T extends Record<string, unknown>>(...propsList: (T | undefined)[]): T;
|
|
27
|
+
/** Returns an empty string for a present boolean data attribute. */
|
|
28
|
+
export declare function dataAttr(value: boolean | undefined): "" | undefined;
|
|
29
|
+
/** Converts named boolean states into presence-based `data-*` attributes. */
|
|
30
|
+
export declare function dataAttributes(states: Record<string, boolean | undefined>): {
|
|
31
|
+
[k: string]: string;
|
|
32
|
+
};
|
|
33
|
+
/** Invokes every supplied callback in declaration order. */
|
|
34
|
+
export declare function callAll<T extends (...args: never[]) => unknown>(...callbacks: (T | undefined)[]): (...args: Parameters<T>) => void;
|
|
35
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { c as _c } from "react/compiler-runtime";
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
|
3
|
+
/** Whether DOM globals are available in the current runtime. */
|
|
4
|
+
export const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
5
|
+
/** Uses a layout effect in the browser and an effect during server rendering. */
|
|
6
|
+
export const useIsoLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
|
|
7
|
+
/** Assigns a value to either form of React ref and returns its React 19 cleanup. */
|
|
8
|
+
export function assignRef(ref, value) {
|
|
9
|
+
if (typeof ref === "function") {
|
|
10
|
+
const cleanup = ref(value);
|
|
11
|
+
if (typeof cleanup === "function") return cleanup;
|
|
12
|
+
if (value !== null) return () => ref(null);
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (ref) {
|
|
16
|
+
ref.current = value;
|
|
17
|
+
if (value !== null) {
|
|
18
|
+
return () => {
|
|
19
|
+
if (ref.current === value) ref.current = null;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Combines refs into one callback ref that preserves every ref cleanup. */
|
|
26
|
+
export function composeRefs(...refs) {
|
|
27
|
+
return (value) => {
|
|
28
|
+
const cleanups = refs.flatMap((ref) => assignRef(ref, value) ?? []);
|
|
29
|
+
if (value === null || cleanups.length === 0) return undefined;
|
|
30
|
+
return () => {
|
|
31
|
+
for (const cleanup of cleanups) cleanup();
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function useComposedRefs(...t0) {
|
|
36
|
+
const $ = _c(4);
|
|
37
|
+
const refs = t0;
|
|
38
|
+
const refsRef = useRef(refs);
|
|
39
|
+
const valueRef = useRef(null);
|
|
40
|
+
let t1;
|
|
41
|
+
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
|
42
|
+
t1 = [];
|
|
43
|
+
$[0] = t1;
|
|
44
|
+
} else {
|
|
45
|
+
t1 = $[0];
|
|
46
|
+
}
|
|
47
|
+
const assignmentsRef = useRef(t1);
|
|
48
|
+
let t2;
|
|
49
|
+
if ($[1] !== refs) {
|
|
50
|
+
t2 = () => {
|
|
51
|
+
const value = valueRef.current;
|
|
52
|
+
refsRef.current = refs;
|
|
53
|
+
if (value === null) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const previousAssignments = [...assignmentsRef.current];
|
|
57
|
+
const nextAssignments = refs.map((ref) => {
|
|
58
|
+
const previousIndex = previousAssignments.findIndex((assignment) => assignment.ref === ref);
|
|
59
|
+
if (previousIndex === -1) {
|
|
60
|
+
return {
|
|
61
|
+
ref,
|
|
62
|
+
cleanup: undefined,
|
|
63
|
+
pending: true
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const [previous] = previousAssignments.splice(previousIndex, 1);
|
|
67
|
+
return {
|
|
68
|
+
...previous,
|
|
69
|
+
pending: false
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
for (const assignment_0 of previousAssignments) {
|
|
73
|
+
assignment_0.cleanup?.();
|
|
74
|
+
}
|
|
75
|
+
assignmentsRef.current = nextAssignments.map((t3) => {
|
|
76
|
+
const { ref: ref_0, cleanup, pending } = t3;
|
|
77
|
+
return {
|
|
78
|
+
ref: ref_0,
|
|
79
|
+
cleanup: pending ? assignRef(ref_0, value) : cleanup
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
$[1] = refs;
|
|
84
|
+
$[2] = t2;
|
|
85
|
+
} else {
|
|
86
|
+
t2 = $[2];
|
|
87
|
+
}
|
|
88
|
+
useIsoLayoutEffect(t2);
|
|
89
|
+
let t3;
|
|
90
|
+
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
|
|
91
|
+
t3 = (value_0) => {
|
|
92
|
+
for (const assignment_1 of assignmentsRef.current) {
|
|
93
|
+
assignment_1.cleanup?.();
|
|
94
|
+
}
|
|
95
|
+
assignmentsRef.current = [];
|
|
96
|
+
valueRef.current = value_0;
|
|
97
|
+
if (value_0 === null) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
assignmentsRef.current = refsRef.current.map((ref_1) => ({
|
|
101
|
+
ref: ref_1,
|
|
102
|
+
cleanup: assignRef(ref_1, value_0)
|
|
103
|
+
}));
|
|
104
|
+
return () => {
|
|
105
|
+
for (const assignment_2 of assignmentsRef.current) {
|
|
106
|
+
assignment_2.cleanup?.();
|
|
107
|
+
}
|
|
108
|
+
assignmentsRef.current = [];
|
|
109
|
+
valueRef.current = null;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
$[3] = t3;
|
|
113
|
+
} else {
|
|
114
|
+
t3 = $[3];
|
|
115
|
+
}
|
|
116
|
+
return t3;
|
|
117
|
+
}
|
|
118
|
+
export function useEventCallback(callback) {
|
|
119
|
+
const $ = _c(3);
|
|
120
|
+
const callbackRef = useRef(callback);
|
|
121
|
+
let t0;
|
|
122
|
+
if ($[0] !== callback) {
|
|
123
|
+
t0 = () => {
|
|
124
|
+
callbackRef.current = callback;
|
|
125
|
+
};
|
|
126
|
+
$[0] = callback;
|
|
127
|
+
$[1] = t0;
|
|
128
|
+
} else {
|
|
129
|
+
t0 = $[1];
|
|
130
|
+
}
|
|
131
|
+
useIsoLayoutEffect(t0);
|
|
132
|
+
let t1;
|
|
133
|
+
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
|
|
134
|
+
t1 = (...t2) => {
|
|
135
|
+
const args = t2;
|
|
136
|
+
return callbackRef.current?.(...args);
|
|
137
|
+
};
|
|
138
|
+
$[2] = t1;
|
|
139
|
+
} else {
|
|
140
|
+
t1 = $[2];
|
|
141
|
+
}
|
|
142
|
+
return t1;
|
|
143
|
+
}
|
|
144
|
+
function isEventHandler(key, value) {
|
|
145
|
+
return /^on[A-Z]/.test(key) && typeof value === "function";
|
|
146
|
+
}
|
|
147
|
+
/** Chains handlers, allowing the first to prevent the second by default. */
|
|
148
|
+
export function chainHandlers(theirs, ours, options = {}) {
|
|
149
|
+
return (event) => {
|
|
150
|
+
theirs?.(event);
|
|
151
|
+
if (options.checkDefaultPrevented !== false && event.defaultPrevented) return;
|
|
152
|
+
ours?.(event);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Merges DOM props, concatenating classes, merging styles, and chaining handlers. */
|
|
156
|
+
export function mergeProps(...propsList) {
|
|
157
|
+
const merged = {};
|
|
158
|
+
for (const props of propsList) {
|
|
159
|
+
if (!props) continue;
|
|
160
|
+
for (const [key, value] of Object.entries(props)) {
|
|
161
|
+
if (value === undefined) continue;
|
|
162
|
+
if (key === "className" && merged.className && value) {
|
|
163
|
+
merged.className = `${merged.className} ${value}`;
|
|
164
|
+
} else if (key === "style" && typeof value === "object" && value && typeof merged.style === "object") {
|
|
165
|
+
merged.style = {
|
|
166
|
+
...merged.style,
|
|
167
|
+
...value
|
|
168
|
+
};
|
|
169
|
+
} else if (isEventHandler(key, value) && isEventHandler(key, merged[key])) {
|
|
170
|
+
merged[key] = chainHandlers(merged[key], value);
|
|
171
|
+
} else {
|
|
172
|
+
merged[key] = value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return merged;
|
|
177
|
+
}
|
|
178
|
+
/** Returns an empty string for a present boolean data attribute. */
|
|
179
|
+
export function dataAttr(value) {
|
|
180
|
+
return value ? "" : undefined;
|
|
181
|
+
}
|
|
182
|
+
/** Converts named boolean states into presence-based `data-*` attributes. */
|
|
183
|
+
export function dataAttributes(states) {
|
|
184
|
+
return Object.fromEntries(Object.entries(states).filter(([, value]) => value).map(([key]) => [`data-${key}`, ""]));
|
|
185
|
+
}
|
|
186
|
+
/** Invokes every supplied callback in declaration order. */
|
|
187
|
+
export function callAll(...callbacks) {
|
|
188
|
+
return (...args) => {
|
|
189
|
+
for (const callback of callbacks) {
|
|
190
|
+
callback?.(...args);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@comp0/core",
|
|
3
|
+
"version": "0.1.0-next.0",
|
|
4
|
+
"description": "React behavior hooks and DOM utilities for accessible headless interfaces.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"accessibility",
|
|
7
|
+
"headless",
|
|
8
|
+
"hooks",
|
|
9
|
+
"interactions",
|
|
10
|
+
"react"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://comp0-docs.horrible.workers.dev",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/mewhhaha/comp0/issues"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mewhhaha/comp0.git",
|
|
20
|
+
"directory": "packages/core"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"react": "19.2.7",
|
|
40
|
+
"react-dom": "19.2.7"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^19.0.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rm -rf dist tsconfig.build.tsbuildinfo && node ../../scripts/build-package.mjs && tsgo -p tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsgo -p tsconfig.json"
|
|
48
|
+
}
|
|
49
|
+
}
|