@primitiv-ui/core 0.0.0-stage → 0.1.43

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simon Revill
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,34 @@
1
- # Temporary Holding Version
1
+ # @primitiv-ui/core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The framework-free behaviour core shared by every Primitiv surface. Pure
4
+ TypeScript functions with **no DOM, no framework and no dependencies**: keyboard
5
+ maps, roving-focus targets, id derivation and, as components move over, each
6
+ component's keyboard and state policy.
7
+
8
+ You do not normally install this package yourself. It arrives as a dependency
9
+ of the surface you chose: [`@primitiv-ui/react`](../react/README.md) today, and
10
+ `@primitiv-ui/web` (standard custom elements) as it lands. Both surfaces import
11
+ the same functions, so they cannot disagree about how a component behaves. See
12
+ [RFC 0033](../../docs/rfcs/0033-web-components.md) for the design.
13
+
14
+ ## Exports
15
+
16
+ | Export | What it does |
17
+ | --- | --- |
18
+ | `getKeyToActionMap(options)` | Builds the key → roving action map (`"next"`, `"prev"`, `"first"`, `"last"`, `"activate"`) for an orientation, reading direction, Home/End and Enter/Space. |
19
+ | `RovingKeyAction` | The action vocabulary those maps return. |
20
+ | `resolveRovingTarget({ action, navigable, currentKey })` | Resolves which item a roving action lands on (wrapping, Home/End, Enter/Space), as `{ target }` or `null`. |
21
+ | `deriveId(rootId, suffix, value)` | Builds the paired ids (`aria-controls`, `aria-labelledby`) a compound component wires between its parts. |
22
+ | `tabsKeydown(state, focused, key)` | Tabs' keyboard contract: which tab to focus and which, if any, to activate. |
23
+ | `tabsClick(state, value)` | The tab a click activates, or `null` when it is disabled or already active. |
24
+ | `tabsTabStop(state)` | The tab that takes part in the page's Tab order. |
25
+ | `TabsState`, `TabsKeyOutcome`, `TabsOrientation`, `TabsReadingDirection`, `TabsActivationMode` | The plain data those functions read and return. |
26
+
27
+ ## Rules for this package
28
+
29
+ - **No DOM.** `tsconfig.json` leaves `DOM` out of `lib`, so a DOM type in a
30
+ signature is a compile error.
31
+ - **100% coverage and 100% mutation score**, both gated: `pnpm qa:units` and
32
+ `pnpm mutate`.
33
+ - **Versioned in lockstep** with every other Primitiv package
34
+ (`scripts/bump-version.mjs`).
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Builds a deterministic sub-id from a stable root id (typically the
3
+ * value returned by React's `useId()`), a fixed role suffix, and a
4
+ * per-item value. Used to wire `aria-controls`, `aria-labelledby`, and
5
+ * matching DOM `id` attributes between paired sub-components such as
6
+ * Tabs.Trigger ↔ Tabs.Content and Accordion.Trigger ↔ Accordion.Content
7
+ * without each compound component re-deriving the same template literal.
8
+ *
9
+ * @example Inside a Tabs.Trigger
10
+ * ```ts
11
+ * const triggerId = deriveId(tabsId, "trigger", value);
12
+ * const panelId = deriveId(tabsId, "panel", value);
13
+ * ```
14
+ *
15
+ * @param rootId - The shared id for the compound component, normally
16
+ * from `useId()`. Any string is accepted, including React 18's
17
+ * colon-bracketed ids (`":r1:"`).
18
+ * @param suffix - The role of the derived id within the compound,
19
+ * e.g. `"trigger"`, `"panel"`, `"heading"`.
20
+ * @param value - The per-item discriminator (the value prop on the
21
+ * trigger / panel pair).
22
+ *
23
+ * @returns The concatenation `${rootId}-${suffix}-${value}`.
24
+ */
25
+ export declare function deriveId(rootId: string, suffix: string, value: string): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Builds a deterministic sub-id from a stable root id (typically the
3
+ * value returned by React's `useId()`), a fixed role suffix, and a
4
+ * per-item value. Used to wire `aria-controls`, `aria-labelledby`, and
5
+ * matching DOM `id` attributes between paired sub-components such as
6
+ * Tabs.Trigger ↔ Tabs.Content and Accordion.Trigger ↔ Accordion.Content
7
+ * without each compound component re-deriving the same template literal.
8
+ *
9
+ * @example Inside a Tabs.Trigger
10
+ * ```ts
11
+ * const triggerId = deriveId(tabsId, "trigger", value);
12
+ * const panelId = deriveId(tabsId, "panel", value);
13
+ * ```
14
+ *
15
+ * @param rootId - The shared id for the compound component, normally
16
+ * from `useId()`. Any string is accepted, including React 18's
17
+ * colon-bracketed ids (`":r1:"`).
18
+ * @param suffix - The role of the derived id within the compound,
19
+ * e.g. `"trigger"`, `"panel"`, `"heading"`.
20
+ * @param value - The per-item discriminator (the value prop on the
21
+ * trigger / panel pair).
22
+ *
23
+ * @returns The concatenation `${rootId}-${suffix}-${value}`.
24
+ */
25
+ export function deriveId(rootId, suffix, value) {
26
+ return `${rootId}-${suffix}-${value}`;
27
+ }
@@ -0,0 +1,4 @@
1
+ export { deriveId } from "./ids/deriveId.js";
2
+ export { getKeyToActionMap, type RovingKeyAction } from "./keyboard/getKeyToActionMap.js";
3
+ export { resolveRovingTarget, type ResolveRovingTargetOptions, } from "./keyboard/resolveRovingTarget.js";
4
+ export { tabsClick, tabsKeydown, tabsTabStop, type TabsActivationMode, type TabsKeyOutcome, type TabsOrientation, type TabsReadingDirection, type TabsState, } from "./tabs/policy.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { deriveId } from "./ids/deriveId.js";
2
+ export { getKeyToActionMap } from "./keyboard/getKeyToActionMap.js";
3
+ export { resolveRovingTarget, } from "./keyboard/resolveRovingTarget.js";
4
+ export { tabsClick, tabsKeydown, tabsTabStop, } from "./tabs/policy.js";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The abstract roving-tabindex action that a keyboard event maps to.
3
+ *
4
+ * Consumers translate the action into whatever "go to" semantics suit
5
+ * their pattern (focus only, focus + activate, focus + select, etc.).
6
+ */
7
+ export type RovingKeyAction = "next" | "prev" | "first" | "last" | "activate";
8
+ type GetKeyToActionMapOptions = {
9
+ /**
10
+ * Which arrow-key axis is active. `"horizontal"` enables Arrow Left/Right,
11
+ * `"vertical"` enables Arrow Up/Down, `"both"` enables all four. RTL
12
+ * inversion (see {@link GetKeyToActionMapOptions.dir | `dir`}) swaps the
13
+ * horizontal pair for both `"horizontal"` and `"both"`; the vertical pair
14
+ * is never swapped, since vertical reading order does not depend on
15
+ * direction.
16
+ */
17
+ orientation: "horizontal" | "vertical" | "both";
18
+ /**
19
+ * Reading direction. When `"rtl"`, swaps the meaning of Arrow Left and
20
+ * Arrow Right — applied for both `"horizontal"` and `"both"` orientation
21
+ * (never the vertical pair). Defaults to `"ltr"`.
22
+ */
23
+ dir?: "ltr" | "rtl";
24
+ /**
25
+ * When `true`, Home maps to `"first"` and End to `"last"`. Use for
26
+ * patterns where Home/End jumping is part of the keyboard contract
27
+ * (Tabs, Accordion). Defaults to `false`.
28
+ */
29
+ homeEnd?: boolean;
30
+ /**
31
+ * When `true`, Enter and Space map to `"activate"`. Use for patterns
32
+ * where the focused item can be confirmed via the keyboard (Tabs in
33
+ * `"manual"` activation mode). Defaults to `false`.
34
+ */
35
+ activate?: boolean;
36
+ };
37
+ /**
38
+ * Builds the keyboard-key → roving-tabindex-action map for a single
39
+ * focusable item in a compound widget. Each consumer shares the same
40
+ * RTL / orientation / Home-End / Enter-Space conventions and just needs
41
+ * to translate the abstract action ("next", "prev", "first", "last",
42
+ * "activate") into its own movement / activation logic.
43
+ *
44
+ * Returns a plain `Record<string, RovingKeyAction>` so call sites can do
45
+ * `const action = map[event.key]; if (action === "next") ...`.
46
+ *
47
+ * @example Tabs trigger (horizontal, RTL, all features)
48
+ * ```ts
49
+ * const map = getKeyToActionMap({
50
+ * orientation: "horizontal",
51
+ * dir,
52
+ * homeEnd: true,
53
+ * activate: true,
54
+ * });
55
+ * const action = map[event.key];
56
+ * ```
57
+ *
58
+ * @example Accordion trigger (vertical, no Enter/Space — handled separately)
59
+ * ```ts
60
+ * const map = getKeyToActionMap({ orientation: "vertical", homeEnd: true });
61
+ * ```
62
+ */
63
+ export declare function getKeyToActionMap({ orientation, dir, homeEnd, activate, }: GetKeyToActionMapOptions): Record<string, RovingKeyAction>;
64
+ export {};
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Builds the keyboard-key → roving-tabindex-action map for a single
3
+ * focusable item in a compound widget. Each consumer shares the same
4
+ * RTL / orientation / Home-End / Enter-Space conventions and just needs
5
+ * to translate the abstract action ("next", "prev", "first", "last",
6
+ * "activate") into its own movement / activation logic.
7
+ *
8
+ * Returns a plain `Record<string, RovingKeyAction>` so call sites can do
9
+ * `const action = map[event.key]; if (action === "next") ...`.
10
+ *
11
+ * @example Tabs trigger (horizontal, RTL, all features)
12
+ * ```ts
13
+ * const map = getKeyToActionMap({
14
+ * orientation: "horizontal",
15
+ * dir,
16
+ * homeEnd: true,
17
+ * activate: true,
18
+ * });
19
+ * const action = map[event.key];
20
+ * ```
21
+ *
22
+ * @example Accordion trigger (vertical, no Enter/Space — handled separately)
23
+ * ```ts
24
+ * const map = getKeyToActionMap({ orientation: "vertical", homeEnd: true });
25
+ * ```
26
+ */
27
+ export function getKeyToActionMap({ orientation,
28
+ // No `= "ltr"` default: only `"rtl"` is ever tested, so an omitted `dir`
29
+ // already reads as LTR, and a default here is an equivalent mutant.
30
+ dir, homeEnd = false, activate = false, }) {
31
+ const map = {};
32
+ if (orientation === "horizontal" || orientation === "both") {
33
+ const forward = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
34
+ const backward = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
35
+ map[forward] = "next";
36
+ map[backward] = "prev";
37
+ }
38
+ if (orientation === "vertical" || orientation === "both") {
39
+ map.ArrowDown = "next";
40
+ map.ArrowUp = "prev";
41
+ }
42
+ if (homeEnd) {
43
+ map.Home = "first";
44
+ map.End = "last";
45
+ }
46
+ if (activate) {
47
+ map.Enter = "activate";
48
+ map[" "] = "activate";
49
+ }
50
+ return map;
51
+ }
@@ -0,0 +1,42 @@
1
+ import type { RovingKeyAction } from "./getKeyToActionMap.js";
2
+ /** Input to {@link resolveRovingTarget}. */
3
+ export type ResolveRovingTargetOptions<K> = {
4
+ /**
5
+ * The abstract action a key press mapped to (see `getKeyToActionMap`), or
6
+ * `undefined` for a key the map does not cover, which resolves nothing. So a
7
+ * caller can pass `map[event.key]` straight through.
8
+ */
9
+ action: RovingKeyAction | undefined;
10
+ /**
11
+ * The ordered keys navigation walks through. The caller filters it: RadioGroup
12
+ * and Accordion leave disabled items out so arrows skip them, while Tabs keeps
13
+ * them in so arrows land on a disabled tab without activating it.
14
+ */
15
+ navigable: readonly K[];
16
+ /** The key of the item that owns the key press: the pivot for next / prev. */
17
+ currentKey: K;
18
+ };
19
+ /**
20
+ * Resolves which item a roving-tabindex action lands on: the index arithmetic
21
+ * of the WAI-ARIA roving-tabindex pattern, with no DOM and no framework.
22
+ *
23
+ * - `"next"` / `"prev"` move one step and wrap at either end. They resolve
24
+ * nothing when `currentKey` is not in `navigable`, which is RadioGroup's
25
+ * contract: arrows do nothing while focus sits on a disabled radio.
26
+ * - `"first"` / `"last"` jump to either end, whatever the current key.
27
+ * - `"activate"` targets the current key, so the caller confirms it.
28
+ * - An empty `navigable`, or no action at all, resolves nothing.
29
+ *
30
+ * The result is wrapped as `{ target }`, with `null` for "nothing", because a
31
+ * key can itself be `null` or `undefined` (Combobox navigates `string | null`).
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const action = getKeyToActionMap({ orientation: "horizontal" })[event.key];
36
+ * const result = resolveRovingTarget({ action, navigable, currentKey });
37
+ * if (result) focusItem(result.target);
38
+ * ```
39
+ */
40
+ export declare function resolveRovingTarget<K>({ action, navigable, currentKey, }: ResolveRovingTargetOptions<K>): {
41
+ target: K;
42
+ } | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolves which item a roving-tabindex action lands on: the index arithmetic
3
+ * of the WAI-ARIA roving-tabindex pattern, with no DOM and no framework.
4
+ *
5
+ * - `"next"` / `"prev"` move one step and wrap at either end. They resolve
6
+ * nothing when `currentKey` is not in `navigable`, which is RadioGroup's
7
+ * contract: arrows do nothing while focus sits on a disabled radio.
8
+ * - `"first"` / `"last"` jump to either end, whatever the current key.
9
+ * - `"activate"` targets the current key, so the caller confirms it.
10
+ * - An empty `navigable`, or no action at all, resolves nothing.
11
+ *
12
+ * The result is wrapped as `{ target }`, with `null` for "nothing", because a
13
+ * key can itself be `null` or `undefined` (Combobox navigates `string | null`).
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * const action = getKeyToActionMap({ orientation: "horizontal" })[event.key];
18
+ * const result = resolveRovingTarget({ action, navigable, currentKey });
19
+ * if (result) focusItem(result.target);
20
+ * ```
21
+ */
22
+ export function resolveRovingTarget({ action, navigable, currentKey, }) {
23
+ const total = navigable.length;
24
+ if (total === 0)
25
+ return null;
26
+ switch (action) {
27
+ case "activate":
28
+ return { target: currentKey };
29
+ case "first":
30
+ return { target: navigable[0] };
31
+ case "last":
32
+ return { target: navigable[total - 1] };
33
+ case undefined:
34
+ return null;
35
+ case "next":
36
+ case "prev": {
37
+ const currentIndex = navigable.indexOf(currentKey);
38
+ if (currentIndex === -1)
39
+ return null;
40
+ const step = action === "next" ? 1 : total - 1;
41
+ return { target: navigable[(currentIndex + step) % total] };
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,54 @@
1
+ /** Layout axis of the tab list: which arrow pair moves between tabs. */
2
+ export type TabsOrientation = "horizontal" | "vertical";
3
+ /** Reading direction. Under `"rtl"` the horizontal arrows are mirrored. */
4
+ export type TabsReadingDirection = "ltr" | "rtl";
5
+ /** When a focused tab becomes active: on arrival (`"automatic"`), or only on
6
+ * Enter / Space (`"manual"`). */
7
+ export type TabsActivationMode = "automatic" | "manual";
8
+ /**
9
+ * Everything Tabs' keyboard and activation rules depend on, as plain data. Each
10
+ * surface builds it from wherever it keeps state — React state, or a custom
11
+ * element's attributes — and applies the outcome back the same way.
12
+ */
13
+ export type TabsState = {
14
+ /** Every tab's value, in DOM order, disabled ones included. */
15
+ values: readonly string[];
16
+ /** The values of the disabled tabs. */
17
+ disabled: ReadonlySet<string>;
18
+ /** The active tab's value, or `undefined` when no tab is active. */
19
+ active: string | undefined;
20
+ orientation: TabsOrientation;
21
+ dir: TabsReadingDirection;
22
+ activationMode: TabsActivationMode;
23
+ };
24
+ /** What a key press on a tab asks for: always a tab to focus, and a tab to
25
+ * activate when the press should change the selection. */
26
+ export type TabsKeyOutcome = {
27
+ focus: string;
28
+ activate: string | null;
29
+ };
30
+ /**
31
+ * The tab that takes part in the page's Tab order (the roving tab stop): the
32
+ * active tab, or the first tab while none is active so the tablist is still
33
+ * reachable by keyboard. An active value that matches no tab is returned as it
34
+ * is, which leaves no tab as the stop — the behaviour Tabs has always had.
35
+ */
36
+ export declare function tabsTabStop(state: Pick<TabsState, "values" | "active">): string | undefined;
37
+ /** The tab a click on `value` activates: `null` when it is disabled or already
38
+ * active. */
39
+ export declare function tabsClick(state: Pick<TabsState, "disabled" | "active">, value: string): string | null;
40
+ /**
41
+ * Tabs' keyboard contract for a key press on the tab `focused`:
42
+ *
43
+ * - arrows follow `orientation`, mirrored under RTL; Home / End jump to the ends;
44
+ * Enter / Space confirm;
45
+ * - arrows **land** on a disabled tab but never activate it — unlike RadioGroup
46
+ * and Accordion, which skip disabled items;
47
+ * - in `"automatic"` mode arriving at a tab activates it; in `"manual"` mode only
48
+ * Enter / Space do;
49
+ * - the already-active tab is never re-activated, so no change is reported.
50
+ *
51
+ * Returns `null` for a key Tabs does not handle, which tells the caller to leave
52
+ * the event alone (no `preventDefault`).
53
+ */
54
+ export declare function tabsKeydown(state: TabsState, focused: string, key: string): TabsKeyOutcome | null;
@@ -0,0 +1,44 @@
1
+ import { getKeyToActionMap } from "../keyboard/getKeyToActionMap.js";
2
+ import { resolveRovingTarget } from "../keyboard/resolveRovingTarget.js";
3
+ /**
4
+ * The tab that takes part in the page's Tab order (the roving tab stop): the
5
+ * active tab, or the first tab while none is active so the tablist is still
6
+ * reachable by keyboard. An active value that matches no tab is returned as it
7
+ * is, which leaves no tab as the stop — the behaviour Tabs has always had.
8
+ */
9
+ export function tabsTabStop(state) {
10
+ return state.active ?? state.values[0];
11
+ }
12
+ /** The tab a click on `value` activates: `null` when it is disabled or already
13
+ * active. */
14
+ export function tabsClick(state, value) {
15
+ return state.disabled.has(value) || state.active === value ? null : value;
16
+ }
17
+ /**
18
+ * Tabs' keyboard contract for a key press on the tab `focused`:
19
+ *
20
+ * - arrows follow `orientation`, mirrored under RTL; Home / End jump to the ends;
21
+ * Enter / Space confirm;
22
+ * - arrows **land** on a disabled tab but never activate it — unlike RadioGroup
23
+ * and Accordion, which skip disabled items;
24
+ * - in `"automatic"` mode arriving at a tab activates it; in `"manual"` mode only
25
+ * Enter / Space do;
26
+ * - the already-active tab is never re-activated, so no change is reported.
27
+ *
28
+ * Returns `null` for a key Tabs does not handle, which tells the caller to leave
29
+ * the event alone (no `preventDefault`).
30
+ */
31
+ export function tabsKeydown(state, focused, key) {
32
+ const action = getKeyToActionMap({
33
+ orientation: state.orientation,
34
+ dir: state.dir,
35
+ homeEnd: true,
36
+ activate: true,
37
+ })[key];
38
+ const result = resolveRovingTarget({ action, navigable: state.values, currentKey: focused });
39
+ if (!result)
40
+ return null;
41
+ const target = result.target;
42
+ const requested = state.activationMode === "automatic" || action === "activate";
43
+ return { focus: target, activate: requested ? tabsClick(state, target) : null };
44
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@primitiv-ui/core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.43",
4
+ "description": "The framework-free behaviour core shared by every Primitiv surface — keyboard maps, roving-focus targets, id derivation and per-component policy, as pure functions with no DOM and no dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "sideEffects": false,
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/primitiv-ui/primitiv.git",
21
+ "directory": "packages/core"
22
+ },
23
+ "devDependencies": {
24
+ "@stryker-mutator/core": "9.6.1",
25
+ "@stryker-mutator/vitest-runner": "9.6.1",
26
+ "@vitest/coverage-v8": "^4.1.4",
27
+ "typescript": "^6.0.2",
28
+ "vitest": "^4.1.4"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/finalize-dist.mjs dist",
35
+ "qa": "pnpm qa:units",
36
+ "qa:units": "vitest run --coverage",
37
+ "qa:units:watch": "vitest --coverage",
38
+ "mutate": "stryker run",
39
+ "typecheck": "tsc -p tsconfig.json --noEmit"
40
+ }
6
41
  }