@nativedesktop/ui 0.3.1

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/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@nativedesktop/ui",
3
+ "version": "0.3.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
7
+ "bugs": "https://github.com/FormalSnake/NativeDesktop/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/FormalSnake/NativeDesktop.git",
11
+ "directory": "packages/ui"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "peerDependencies": {
25
+ "@nativedesktop/react": "^0.3.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "19.2.17",
29
+ "react": "19.2.7"
30
+ }
31
+ }
@@ -0,0 +1,68 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+ // The pragma is load-bearing: see packages/panes/src/PaneTree.tsx for why.
3
+
4
+ import type { ReactNode } from "react";
5
+ import { accordionDragPayload, nextExpandedIds, parseAccordionDrag, reorderedIds } from "./accordion.ts";
6
+
7
+ export interface AccordionItem {
8
+ id: string;
9
+ label: string;
10
+ content: ReactNode;
11
+ }
12
+
13
+ export interface AccordionProps {
14
+ items: AccordionItem[];
15
+ expandedIds: string[];
16
+ onExpandedChange: (ids: string[]) => void;
17
+ /** Default false: opening one section closes the others. */
18
+ allowMultiple?: boolean;
19
+ /** Supply it and the sections become reorderable by dragging one header
20
+ * onto another: the dragged section takes the target's place. It is handed
21
+ * the full id order, in the same shape `expandedIds` uses, so the app keeps
22
+ * owning `items`. Omit it and no header is a drag source. */
23
+ onReorder?: (ids: string[]) => void;
24
+ testID?: string;
25
+ }
26
+
27
+ export function Accordion(props: AccordionProps): ReactNode {
28
+ const { items, expandedIds, onExpandedChange, allowMultiple = false, onReorder, testID } = props;
29
+
30
+ // The <expander> IS the section header, so it is both the drag source and
31
+ // the drop target: there is no separate handle widget to attach either to.
32
+ function reorderProps(id: string): {
33
+ draggable: true;
34
+ dragPayload: string;
35
+ dropTarget: true;
36
+ onDropped: (e: { text: string }) => void;
37
+ } {
38
+ return {
39
+ draggable: true,
40
+ dragPayload: accordionDragPayload(id),
41
+ dropTarget: true,
42
+ onDropped: (e) => {
43
+ const moved = parseAccordionDrag(e.text);
44
+ if (moved === undefined) return;
45
+ const order = items.map((item) => item.id);
46
+ const next = reorderedIds(order, moved, id);
47
+ if (next !== order) onReorder?.(next);
48
+ },
49
+ };
50
+ }
51
+
52
+ return (
53
+ <box orientation="vertical" testID={testID}>
54
+ {items.map((item) => (
55
+ <expander
56
+ key={item.id}
57
+ label={item.label}
58
+ expanded={expandedIds.includes(item.id)}
59
+ testID={testID ? `${testID}-item-${item.id}` : undefined}
60
+ onToggled={(e) => onExpandedChange(nextExpandedIds(expandedIds, item.id, e.checked, allowMultiple))}
61
+ {...(onReorder ? reorderProps(item.id) : {})}
62
+ >
63
+ {item.content}
64
+ </expander>
65
+ ))}
66
+ </box>
67
+ );
68
+ }
@@ -0,0 +1,36 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface ButtonGroupItem {
6
+ id: string;
7
+ label: string;
8
+ iconName?: string;
9
+ }
10
+
11
+ export interface ButtonGroupProps {
12
+ items: ButtonGroupItem[];
13
+ onPress: (id: string) => void;
14
+ /** Toggle mode: the matching button renders prominent. Omit for a plain
15
+ * action group with no selection state. */
16
+ selectedId?: string;
17
+ testID?: string;
18
+ }
19
+
20
+ export function ButtonGroup(props: ButtonGroupProps): ReactNode {
21
+ const { items, onPress, selectedId, testID } = props;
22
+ return (
23
+ <box orientation="horizontal" cssClasses={["linked"]} testID={testID}>
24
+ {items.map((item) => (
25
+ <button
26
+ key={item.id}
27
+ label={item.label}
28
+ iconName={item.iconName}
29
+ prominent={item.id === selectedId}
30
+ onClick={() => onPress(item.id)}
31
+ testID={testID ? `${testID}-${item.id}` : undefined}
32
+ />
33
+ ))}
34
+ </box>
35
+ );
36
+ }
@@ -0,0 +1,31 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface DescriptionListItem {
6
+ label: string;
7
+ value: string;
8
+ }
9
+
10
+ export interface DescriptionListProps {
11
+ items: DescriptionListItem[];
12
+ title?: string;
13
+ testID?: string;
14
+ }
15
+
16
+ export function DescriptionList(props: DescriptionListProps): ReactNode {
17
+ const { items, title, testID } = props;
18
+ return (
19
+ <settingsgroup title={title} testID={testID}>
20
+ {items.map((item, i) => (
21
+ <row
22
+ key={`${item.label}-${i}`}
23
+ title={item.label}
24
+ subtitle={item.value}
25
+ cssClasses={["property"]}
26
+ testID={testID ? `${testID}-row-${i}` : undefined}
27
+ />
28
+ ))}
29
+ </settingsgroup>
30
+ );
31
+ }
package/src/Form.tsx ADDED
@@ -0,0 +1,38 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface FormProps {
6
+ title?: string;
7
+ description?: string;
8
+ children: ReactNode;
9
+ testID?: string;
10
+ }
11
+
12
+ export function Form(props: FormProps): ReactNode {
13
+ const { title, description, children, testID } = props;
14
+ return (
15
+ <settingsgroup title={title} description={description} testID={testID}>
16
+ {children}
17
+ </settingsgroup>
18
+ );
19
+ }
20
+
21
+ export interface FormFieldProps {
22
+ label: string;
23
+ error?: string;
24
+ hint?: string;
25
+ children: ReactNode;
26
+ testID?: string;
27
+ }
28
+
29
+ /** The control renders into the row's default (suffix) slot, matching the
30
+ * label-left/control-right shape every other settings row already uses. */
31
+ export function FormField(props: FormFieldProps): ReactNode {
32
+ const { label, error, hint, children, testID } = props;
33
+ return (
34
+ <row title={label} subtitle={error ?? hint} cssClasses={error ? ["error"] : undefined} testID={testID}>
35
+ {children}
36
+ </row>
37
+ );
38
+ }
@@ -0,0 +1,46 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import { useMountEffect, useRef, useState } from "@nativedesktop/react";
5
+
6
+ export interface HoverCardProps {
7
+ content: ReactNode;
8
+ children: ReactNode;
9
+ /** Ms of continuous hover before the card opens. */
10
+ openDelay?: number;
11
+ /** Ms after the pointer leaves before the card closes. */
12
+ closeDelay?: number;
13
+ testID?: string;
14
+ }
15
+
16
+ /** The anchor's `hoverChanged` is the only signal available (no separate
17
+ * enter/leave events), so open/close both key off that one boolean, each on
18
+ * its own timer so a quick pass-through never flashes the card open. */
19
+ export function HoverCard(props: HoverCardProps): ReactNode {
20
+ const { content, children, openDelay = 400, closeDelay = 200, testID } = props;
21
+ const [open, setOpen] = useState(false);
22
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
23
+
24
+ function clearPending(): void {
25
+ if (timerRef.current !== undefined) {
26
+ clearTimeout(timerRef.current);
27
+ timerRef.current = undefined;
28
+ }
29
+ }
30
+
31
+ useMountEffect(() => clearPending);
32
+
33
+ function handleHoverChanged(hovering: boolean): void {
34
+ clearPending();
35
+ timerRef.current = setTimeout(() => setOpen(hovering), hovering ? openDelay : closeDelay);
36
+ }
37
+
38
+ return (
39
+ <box orientation="vertical" onHoverChanged={(e) => handleHoverChanged(e.checked)} testID={testID}>
40
+ {children}
41
+ <popover open={open} onClosed={() => setOpen(false)} testID={testID ? `${testID}-popover` : undefined}>
42
+ {content}
43
+ </popover>
44
+ </box>
45
+ );
46
+ }
@@ -0,0 +1,46 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import type { NdNodeRef } from "@nativedesktop/react";
5
+ import { Spacing, sendCommand, useRef } from "@nativedesktop/react";
6
+ import { otpCellChanged, otpChars } from "./otp.ts";
7
+
8
+ export interface OtpInputProps {
9
+ length?: number;
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ onComplete?: (value: string) => void;
13
+ testID?: string;
14
+ }
15
+
16
+ /** N single-character <textinput>s. otp.ts computes the cell focus should move
17
+ * to, and the `focus` command moves the caret there, so typing, backspace and
18
+ * paste all advance without the user clicking between boxes. */
19
+ export function OtpInput(props: OtpInputProps): ReactNode {
20
+ const { length = 6, value, onChange, onComplete, testID } = props;
21
+ const chars = otpChars(value, length);
22
+ const cells = useRef<(NdNodeRef<"textinput"> | null)[]>([]);
23
+
24
+ return (
25
+ <box orientation="horizontal" spacing={Spacing.xs} testID={testID}>
26
+ {chars.map((ch, i) => (
27
+ <textinput
28
+ key={i}
29
+ ref={(node) => { cells.current[i] = node; }}
30
+ text={ch}
31
+ cssClasses={["numeric"]}
32
+ testID={testID ? `${testID}-cell-${i}` : undefined}
33
+ onChanged={(e) => {
34
+ const result = otpCellChanged(value, length, i, e.text);
35
+ onChange(result.value);
36
+ if (result.activeIndex !== i) {
37
+ const next = cells.current[result.activeIndex];
38
+ if (next) sendCommand(next, "focus");
39
+ }
40
+ if (onComplete && result.value.length === length) onComplete(result.value);
41
+ }}
42
+ />
43
+ ))}
44
+ </box>
45
+ );
46
+ }
@@ -0,0 +1,61 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Spacing } from "@nativedesktop/react";
5
+ import { computePaginationRange } from "./pagination.ts";
6
+
7
+ export interface PaginationProps {
8
+ page: number;
9
+ pageCount: number;
10
+ onPageChange: (page: number) => void;
11
+ siblingCount?: number;
12
+ testID?: string;
13
+ }
14
+
15
+ export function Pagination(props: PaginationProps): ReactNode {
16
+ const { page, pageCount, onPageChange, siblingCount = 1, testID } = props;
17
+ if (pageCount <= 0) return null;
18
+ const items = computePaginationRange(page, pageCount, siblingCount);
19
+
20
+ return (
21
+ <box orientation="horizontal" spacing={Spacing.xs} cssClasses={["linked"]} testID={testID}>
22
+ <button
23
+ label="First"
24
+ onClick={() => onPageChange(1)}
25
+ testID={testID ? `${testID}-first` : undefined}
26
+ cssClasses={page === 1 ? ["flat"] : undefined}
27
+ />
28
+ <button
29
+ label="Prev"
30
+ onClick={() => onPageChange(Math.max(page - 1, 1))}
31
+ testID={testID ? `${testID}-prev` : undefined}
32
+ cssClasses={page === 1 ? ["flat"] : undefined}
33
+ />
34
+ {items.map((item, i) =>
35
+ item === "dots-start" || item === "dots-end" ? (
36
+ <label key={item} text="…" style={{ valign: "center" }} />
37
+ ) : (
38
+ <button
39
+ key={item}
40
+ label={String(item)}
41
+ prominent={item === page}
42
+ onClick={() => onPageChange(item)}
43
+ testID={testID ? `${testID}-page-${item}` : undefined}
44
+ />
45
+ ),
46
+ )}
47
+ <button
48
+ label="Next"
49
+ onClick={() => onPageChange(Math.min(page + 1, pageCount))}
50
+ testID={testID ? `${testID}-next` : undefined}
51
+ cssClasses={page === pageCount ? ["flat"] : undefined}
52
+ />
53
+ <button
54
+ label="Last"
55
+ onClick={() => onPageChange(pageCount)}
56
+ testID={testID ? `${testID}-last` : undefined}
57
+ cssClasses={page === pageCount ? ["flat"] : undefined}
58
+ />
59
+ </box>
60
+ );
61
+ }
@@ -0,0 +1,46 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Spacing, useMemo, useState } from "@nativedesktop/react";
5
+ import { filterItems } from "./searchable-list.ts";
6
+ import type { SearchableListFilter, SearchableListItem } from "./searchable-list.ts";
7
+
8
+ export interface SearchableListProps {
9
+ items: SearchableListItem[];
10
+ onActivate: (item: SearchableListItem) => void;
11
+ filter?: SearchableListFilter;
12
+ placeholder?: string;
13
+ emptyIconName?: string;
14
+ emptyTitle?: string;
15
+ emptyDescription?: string;
16
+ testID?: string;
17
+ }
18
+
19
+ export function SearchableList(props: SearchableListProps): ReactNode {
20
+ const { items, onActivate, filter, placeholder, emptyIconName, emptyTitle, emptyDescription, testID } = props;
21
+ const [query, setQuery] = useState("");
22
+ const filtered = useMemo(() => filterItems(items, query, filter), [items, query, filter]);
23
+
24
+ return (
25
+ <box orientation="vertical" spacing={Spacing.sm} testID={testID}>
26
+ <searchinput
27
+ text={query}
28
+ placeholder={placeholder}
29
+ onChanged={(e) => setQuery(e.text)}
30
+ testID={testID ? `${testID}-search` : undefined}
31
+ />
32
+ <listview
33
+ items={filtered.map((item) => item.label)}
34
+ emptyIconName={emptyIconName}
35
+ emptyTitle={emptyTitle}
36
+ emptyDescription={emptyDescription}
37
+ style={{ vexpand: true }}
38
+ onRowActivated={(e) => {
39
+ const item = filtered[e.index];
40
+ if (item) onActivate(item);
41
+ }}
42
+ testID={testID ? `${testID}-list` : undefined}
43
+ />
44
+ </box>
45
+ );
46
+ }
@@ -0,0 +1,28 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Spacing } from "@nativedesktop/react";
5
+
6
+ export interface StatusBarProps {
7
+ left?: ReactNode;
8
+ center?: ReactNode;
9
+ right?: ReactNode;
10
+ testID?: string;
11
+ }
12
+
13
+ export function StatusBar(props: StatusBarProps): ReactNode {
14
+ const { left, center, right, testID } = props;
15
+ return (
16
+ <box orientation="horizontal" spacing={Spacing.sm} cssClasses={["toolbar"]} testID={testID}>
17
+ <box orientation="horizontal" spacing={Spacing.xs} style={{ halign: "start" }}>
18
+ {left}
19
+ </box>
20
+ <box orientation="horizontal" spacing={Spacing.xs} style={{ hexpand: true, halign: "center" }}>
21
+ {center}
22
+ </box>
23
+ <box orientation="horizontal" spacing={Spacing.xs} style={{ halign: "end" }}>
24
+ {right}
25
+ </box>
26
+ </box>
27
+ );
28
+ }
@@ -0,0 +1,60 @@
1
+ /** @jsxImportSource @nativedesktop/react */
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Spacing } from "@nativedesktop/react";
5
+ import { stepState } from "./stepper.ts";
6
+
7
+ export interface StepperStep {
8
+ id: string;
9
+ title: string;
10
+ description?: string;
11
+ }
12
+
13
+ export interface StepperProps {
14
+ steps: StepperStep[];
15
+ activeIndex: number;
16
+ onStepClick?: (index: number) => void;
17
+ testID?: string;
18
+ }
19
+
20
+ export function Stepper(props: StepperProps): ReactNode {
21
+ const { steps, activeIndex, onStepClick, testID } = props;
22
+ const nodes: ReactNode[] = [];
23
+
24
+ steps.forEach((step, i) => {
25
+ const state = stepState(i, activeIndex);
26
+ if (i > 0) {
27
+ nodes.push(
28
+ <separator
29
+ key={`sep-${step.id}`}
30
+ orientation="horizontal"
31
+ style={{ hexpand: true, valign: "center" }}
32
+ cssClasses={state === "pending" ? ["dimmed"] : ["accent"]}
33
+ />,
34
+ );
35
+ }
36
+ nodes.push(
37
+ <box
38
+ key={step.id}
39
+ orientation="vertical"
40
+ spacing={Spacing.xs}
41
+ testID={testID ? `${testID}-step-${step.id}` : undefined}
42
+ >
43
+ <button
44
+ label={state === "completed" ? undefined : String(i + 1)}
45
+ iconName={state === "completed" ? "emblem-ok" : undefined}
46
+ cssClasses={["circular", state === "active" ? "suggested-action" : state === "completed" ? "success" : "flat"]}
47
+ onClick={onStepClick ? () => onStepClick(i) : undefined}
48
+ />
49
+ <label text={step.title} cssClasses={state === "pending" ? ["dimmed", "caption"] : ["caption-heading"]} />
50
+ {step.description && <label text={step.description} cssClasses={["dimmed", "caption"]} />}
51
+ </box>,
52
+ );
53
+ });
54
+
55
+ return (
56
+ <box orientation="horizontal" spacing={Spacing.sm} testID={testID}>
57
+ {nodes}
58
+ </box>
59
+ );
60
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { accordionDragPayload, nextExpandedIds, parseAccordionDrag, reorderedIds } from "./accordion.ts";
3
+
4
+ describe("nextExpandedIds", () => {
5
+ test("single mode: opening one closes the others", () => {
6
+ expect(nextExpandedIds(["a"], "b", true, false)).toEqual(["b"]);
7
+ expect(nextExpandedIds([], "a", true, false)).toEqual(["a"]);
8
+ });
9
+
10
+ test("single mode: closing the open item empties the list", () => {
11
+ expect(nextExpandedIds(["a"], "a", false, false)).toEqual([]);
12
+ });
13
+
14
+ test("single mode: redundant toggles are a same-reference no-op", () => {
15
+ const open = ["a"];
16
+ expect(nextExpandedIds(open, "a", true, false)).toBe(open);
17
+ const closed: string[] = [];
18
+ expect(nextExpandedIds(closed, "a", false, false)).toBe(closed);
19
+ });
20
+
21
+ test("multiple mode: several items can stay open at once", () => {
22
+ expect(nextExpandedIds(["a"], "b", true, true)).toEqual(["a", "b"]);
23
+ expect(nextExpandedIds(["a", "b"], "a", false, true)).toEqual(["b"]);
24
+ });
25
+
26
+ test("multiple mode: redundant toggles are a same-reference no-op", () => {
27
+ const ids = ["a", "b"];
28
+ expect(nextExpandedIds(ids, "a", true, true)).toBe(ids);
29
+ expect(nextExpandedIds(ids, "z", false, true)).toBe(ids);
30
+ });
31
+ });
32
+
33
+ describe("accordion reordering", () => {
34
+ const ids = ["a", "b", "c"];
35
+
36
+ test("round-trip payloads, and anything foreign is not a section drag", () => {
37
+ expect(parseAccordionDrag(accordionDragPayload("a"))).toBe("a");
38
+ for (const foreign of ["", "a", "nd-accordion:item:", "nd-dock:tab:a"]) {
39
+ expect(parseAccordionDrag(foreign)).toBeUndefined();
40
+ }
41
+ });
42
+
43
+ test("the dragged section takes the target's place", () => {
44
+ expect(reorderedIds(ids, "c", "a")).toEqual(["c", "a", "b"]);
45
+ expect(reorderedIds(ids, "a", "c")).toEqual(["b", "c", "a"]);
46
+ expect(reorderedIds(ids, "b", "c")).toEqual(["a", "c", "b"]);
47
+ });
48
+
49
+ test("a drop that changes nothing is the same reference", () => {
50
+ expect(reorderedIds(ids, "b", "b")).toBe(ids);
51
+ expect(reorderedIds(ids, "gone", "a")).toBe(ids);
52
+ expect(reorderedIds(ids, "a", "gone")).toBe(ids);
53
+ });
54
+ });
@@ -0,0 +1,51 @@
1
+ // Pure expansion-state transition for Accordion. Each <expander> reports its
2
+ // own `checked` on toggle; this decides what the FULL expandedIds list
3
+ // becomes, including closing siblings in single mode. Returns the same
4
+ // reference when the toggle is a no-op (e.g. an echo of the already-applied
5
+ // state), matching the panes model's echo-guard convention.
6
+
7
+ export function nextExpandedIds(
8
+ expandedIds: readonly string[],
9
+ id: string,
10
+ checked: boolean,
11
+ allowMultiple: boolean,
12
+ ): string[] {
13
+ if (allowMultiple) {
14
+ const has = expandedIds.includes(id);
15
+ if (checked === has) return expandedIds as string[];
16
+ return checked ? [...expandedIds, id] : expandedIds.filter((x) => x !== id);
17
+ }
18
+ const isOnlyOpen = expandedIds.length === 1 && expandedIds[0] === id;
19
+ if (checked) return isOnlyOpen ? (expandedIds as string[]) : [id];
20
+ return isOnlyOpen ? [] : (expandedIds as string[]);
21
+ }
22
+
23
+ const DRAG_PREFIX = "nd-accordion:item:";
24
+
25
+ /** The `dragPayload` a reorderable section header carries. Namespaced because
26
+ * the drop side also receives plain text dragged in from other applications,
27
+ * and that must not be read as a section id. */
28
+ export function accordionDragPayload(id: string): string {
29
+ return `${DRAG_PREFIX}${id}`;
30
+ }
31
+
32
+ export function parseAccordionDrag(payload: string): string | undefined {
33
+ if (!payload.startsWith(DRAG_PREFIX)) return undefined;
34
+ const id = payload.slice(DRAG_PREFIX.length);
35
+ return id.length > 0 ? id : undefined;
36
+ }
37
+
38
+ /** Order after dropping section `movedId` on section `targetId`: the dragged
39
+ * section takes the target's place and the ones between it shift up. Returns
40
+ * the same reference when the drop changes nothing (a section dropped on
41
+ * itself, or an id from another accordion), so a drag that ends where it
42
+ * started cannot loop a render+persist cycle. */
43
+ export function reorderedIds(ids: readonly string[], movedId: string, targetId: string): string[] {
44
+ const from = ids.indexOf(movedId);
45
+ const to = ids.indexOf(targetId);
46
+ if (from < 0 || to < 0 || from === to) return ids as string[];
47
+ const next = ids.slice();
48
+ next.splice(from, 1);
49
+ next.splice(to, 0, movedId);
50
+ return next;
51
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export { accordionDragPayload, nextExpandedIds, parseAccordionDrag, reorderedIds } from "./accordion.ts";
2
+ export { Accordion } from "./Accordion.tsx";
3
+ export type { AccordionItem, AccordionProps } from "./Accordion.tsx";
4
+
5
+ export { DescriptionList } from "./DescriptionList.tsx";
6
+ export type { DescriptionListItem, DescriptionListProps } from "./DescriptionList.tsx";
7
+
8
+ export { computePaginationRange } from "./pagination.ts";
9
+ export type { PaginationItem } from "./pagination.ts";
10
+ export { Pagination } from "./Pagination.tsx";
11
+ export type { PaginationProps } from "./Pagination.tsx";
12
+
13
+ export { stepState } from "./stepper.ts";
14
+ export type { StepState } from "./stepper.ts";
15
+ export { Stepper } from "./Stepper.tsx";
16
+ export type { StepperProps, StepperStep } from "./Stepper.tsx";
17
+
18
+ export { HoverCard } from "./HoverCard.tsx";
19
+ export type { HoverCardProps } from "./HoverCard.tsx";
20
+
21
+ export { defaultFilter, filterItems } from "./searchable-list.ts";
22
+ export type { SearchableListFilter, SearchableListItem } from "./searchable-list.ts";
23
+ export { SearchableList } from "./SearchableList.tsx";
24
+ export type { SearchableListProps } from "./SearchableList.tsx";
25
+
26
+ export { Form, FormField } from "./Form.tsx";
27
+ export type { FormFieldProps, FormProps } from "./Form.tsx";
28
+
29
+ export { otpCellChanged, otpChars } from "./otp.ts";
30
+ export type { OtpEdit } from "./otp.ts";
31
+ export { OtpInput } from "./OtpInput.tsx";
32
+ export type { OtpInputProps } from "./OtpInput.tsx";
33
+
34
+ export { ButtonGroup } from "./ButtonGroup.tsx";
35
+ export type { ButtonGroupItem, ButtonGroupProps } from "./ButtonGroup.tsx";
36
+
37
+ export { StatusBar } from "./StatusBar.tsx";
38
+ export type { StatusBarProps } from "./StatusBar.tsx";
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { otpCellChanged, otpChars } from "./otp.ts";
3
+
4
+ describe("otpChars", () => {
5
+ test("pads and truncates to length", () => {
6
+ expect(otpChars("12", 6)).toEqual(["1", "2", "", "", "", ""]);
7
+ expect(otpChars("1234567", 6)).toEqual(["1", "2", "3", "4", "5", "6"]);
8
+ expect(otpChars("", 4)).toEqual(["", "", "", ""]);
9
+ });
10
+ });
11
+
12
+ describe("otpCellChanged", () => {
13
+ test("typing a digit fills the cell and advances", () => {
14
+ expect(otpCellChanged("", 6, 0, "1")).toEqual({ value: "1", activeIndex: 1 });
15
+ expect(otpCellChanged("1", 6, 1, "2")).toEqual({ value: "12", activeIndex: 2 });
16
+ });
17
+
18
+ test("advancing stops at the last cell", () => {
19
+ expect(otpCellChanged("12345", 6, 5, "6")).toEqual({ value: "123456", activeIndex: 5 });
20
+ });
21
+
22
+ test("a multi-character box (paste) distributes across the following cells", () => {
23
+ expect(otpCellChanged("", 6, 0, "123456")).toEqual({ value: "123456", activeIndex: 5 });
24
+ expect(otpCellChanged("ab", 6, 2, "cdef")).toEqual({ value: "abcdef", activeIndex: 5 });
25
+ });
26
+
27
+ test("a paste longer than the remaining cells is truncated", () => {
28
+ expect(otpCellChanged("", 4, 2, "999999")).toEqual({ value: "99", activeIndex: 3 });
29
+ });
30
+
31
+ test("clearing a filled cell (backspace) stays on that cell", () => {
32
+ expect(otpCellChanged("123456", 6, 5, "")).toEqual({ value: "12345", activeIndex: 5 });
33
+ });
34
+
35
+ test("clearing an already-empty cell steps back one", () => {
36
+ expect(otpCellChanged("12", 6, 2, "")).toEqual({ value: "12", activeIndex: 1 });
37
+ expect(otpCellChanged("", 6, 0, "")).toEqual({ value: "", activeIndex: 0 });
38
+ });
39
+
40
+ test("typing into a mid box that already had a character overwrites it", () => {
41
+ expect(otpCellChanged("123456", 6, 2, "9")).toEqual({ value: "129456", activeIndex: 3 });
42
+ });
43
+ });
package/src/otp.ts ADDED
@@ -0,0 +1,49 @@
1
+ // Pure per-cell edit resolution for OtpInput. `<textinput>` only reports
2
+ // `onChanged`/`text` (no per-key event on the widget yet), so a paste into
3
+ // one box arrives as that box's `text` suddenly holding every pasted
4
+ // character, which is what tells a paste apart from a normal keystroke.
5
+ //
6
+ // `activeIndex` is the cell focus moves to next. OtpInput sends the `focus`
7
+ // command to that cell, which is what makes typing, backspace and paste
8
+ // advance the caret without the user clicking between boxes.
9
+
10
+ export interface OtpEdit {
11
+ value: string;
12
+ activeIndex: number;
13
+ }
14
+
15
+ export function otpChars(value: string, length: number): string[] {
16
+ const chars = new Array<string>(length).fill("");
17
+ for (let i = 0; i < Math.min(value.length, length); i++) chars[i] = value[i]!;
18
+ return chars;
19
+ }
20
+
21
+ /** One cell's onChanged fired with `text`. */
22
+ export function otpCellChanged(value: string, length: number, index: number, text: string): OtpEdit {
23
+ const chars = otpChars(value, length);
24
+
25
+ if (text.length > 1) {
26
+ // A paste landed in this box: distribute it across this cell and the
27
+ // ones that follow.
28
+ let i = index;
29
+ for (const ch of text) {
30
+ if (i >= length) break;
31
+ chars[i] = ch;
32
+ i++;
33
+ }
34
+ return { value: chars.join(""), activeIndex: Math.min(i, length - 1) };
35
+ }
36
+
37
+ if (text.length === 0) {
38
+ // Backspace on a filled cell just clears it, staying put. Backspace on
39
+ // an already-empty cell would step back one, but that keystroke never
40
+ // changes this cell's text, so it never reaches onChanged; only a real
41
+ // key event could report it.
42
+ const wasFilled = chars[index] !== "";
43
+ chars[index] = "";
44
+ return { value: chars.join(""), activeIndex: wasFilled ? index : Math.max(index - 1, 0) };
45
+ }
46
+
47
+ chars[index] = text[text.length - 1]!;
48
+ return { value: chars.join(""), activeIndex: Math.min(index + 1, length - 1) };
49
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computePaginationRange } from "./pagination.ts";
3
+
4
+ describe("computePaginationRange", () => {
5
+ test("everything fits: no dots", () => {
6
+ expect(computePaginationRange(1, 5)).toEqual([1, 2, 3, 4, 5]);
7
+ expect(computePaginationRange(3, 7)).toEqual([1, 2, 3, 4, 5, 6, 7]);
8
+ });
9
+
10
+ test("near the start: dots only on the right", () => {
11
+ expect(computePaginationRange(1, 10)).toEqual([1, 2, 3, 4, 5, "dots-end", 10]);
12
+ expect(computePaginationRange(3, 10)).toEqual([1, 2, 3, 4, 5, "dots-end", 10]);
13
+ });
14
+
15
+ test("near the end: dots only on the left", () => {
16
+ expect(computePaginationRange(10, 10)).toEqual([1, "dots-start", 6, 7, 8, 9, 10]);
17
+ expect(computePaginationRange(8, 10)).toEqual([1, "dots-start", 6, 7, 8, 9, 10]);
18
+ });
19
+
20
+ test("in the middle: dots on both sides", () => {
21
+ expect(computePaginationRange(5, 10)).toEqual([1, "dots-start", 4, 5, 6, "dots-end", 10]);
22
+ });
23
+
24
+ test("siblingCount widens the visible window", () => {
25
+ expect(computePaginationRange(5, 10, 2)).toEqual([1, "dots-start", 3, 4, 5, 6, 7, "dots-end", 10]);
26
+ });
27
+
28
+ test("edges: pageCount 0, out-of-range page clamps into range", () => {
29
+ expect(computePaginationRange(1, 0)).toEqual([]);
30
+ expect(computePaginationRange(99, 10)).toEqual([1, "dots-start", 6, 7, 8, 9, 10]);
31
+ expect(computePaginationRange(-5, 10)).toEqual([1, 2, 3, 4, 5, "dots-end", 10]);
32
+ });
33
+ });
@@ -0,0 +1,35 @@
1
+ // Pure page-range computation for Pagination: which numbered buttons show,
2
+ // and where the ellipsis gaps sit. Mirrors the standard sibling-count
3
+ // windowing (first, last, current +/- siblings, collapse the rest to dots).
4
+
5
+ export type PaginationItem = number | "dots-start" | "dots-end";
6
+
7
+ function range(start: number, end: number): number[] {
8
+ if (end < start) return [];
9
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i);
10
+ }
11
+
12
+ export function computePaginationRange(page: number, pageCount: number, siblingCount = 1): PaginationItem[] {
13
+ if (pageCount <= 0) return [];
14
+ const clampedPage = Math.min(Math.max(page, 1), pageCount);
15
+ const clampedSiblings = Math.max(siblingCount, 0);
16
+
17
+ // first + last + current + two sibling runs + slack for the two dots.
18
+ const totalVisible = clampedSiblings * 2 + 5;
19
+ if (pageCount <= totalVisible) return range(1, pageCount);
20
+
21
+ const leftSibling = Math.max(clampedPage - clampedSiblings, 1);
22
+ const rightSibling = Math.min(clampedPage + clampedSiblings, pageCount);
23
+ const showLeftDots = leftSibling > 2;
24
+ const showRightDots = rightSibling < pageCount - 1;
25
+
26
+ if (!showLeftDots && showRightDots) {
27
+ const leftCount = 3 + clampedSiblings * 2;
28
+ return [...range(1, leftCount), "dots-end", pageCount];
29
+ }
30
+ if (showLeftDots && !showRightDots) {
31
+ const rightCount = 3 + clampedSiblings * 2;
32
+ return [1, "dots-start", ...range(pageCount - rightCount + 1, pageCount)];
33
+ }
34
+ return [1, "dots-start", ...range(leftSibling, rightSibling), "dots-end", pageCount];
35
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultFilter, filterItems } from "./searchable-list.ts";
3
+ import type { SearchableListItem } from "./searchable-list.ts";
4
+
5
+ const items: SearchableListItem[] = [
6
+ { id: "1", label: "Apple" },
7
+ { id: "2", label: "Banana" },
8
+ { id: "3", label: "Blueberry" },
9
+ { id: "4", label: "Cherry" },
10
+ ];
11
+
12
+ describe("filterItems", () => {
13
+ test("empty query returns every item, same reference", () => {
14
+ expect(filterItems(items, "")).toBe(items as SearchableListItem[]);
15
+ });
16
+
17
+ test("matches case-insensitively against the label", () => {
18
+ expect(filterItems(items, "b").map((i) => i.id)).toEqual(["2", "3"]);
19
+ expect(filterItems(items, "BLUE").map((i) => i.id)).toEqual(["3"]);
20
+ });
21
+
22
+ test("no matches returns an empty list", () => {
23
+ expect(filterItems(items, "zzz")).toEqual([]);
24
+ });
25
+
26
+ test("a custom predicate overrides the default label match", () => {
27
+ const byId: typeof defaultFilter = (item, query) => item.id === query;
28
+ expect(filterItems(items, "3", byId).map((i) => i.label)).toEqual(["Blueberry"]);
29
+ });
30
+ });
@@ -0,0 +1,23 @@
1
+ // Pure client-side filtering for SearchableList.
2
+
3
+ export interface SearchableListItem {
4
+ id: string;
5
+ label: string;
6
+ }
7
+
8
+ export type SearchableListFilter = (item: SearchableListItem, query: string) => boolean;
9
+
10
+ export function defaultFilter(item: SearchableListItem, query: string): boolean {
11
+ return item.label.toLowerCase().includes(query.toLowerCase());
12
+ }
13
+
14
+ /** Empty query is a same-reference no-op: every item passes, and skipping
15
+ * the filter call keeps an unrelated re-render from allocating a new array. */
16
+ export function filterItems(
17
+ items: readonly SearchableListItem[],
18
+ query: string,
19
+ filter: SearchableListFilter = defaultFilter,
20
+ ): SearchableListItem[] {
21
+ if (query.length === 0) return items as SearchableListItem[];
22
+ return items.filter((item) => filter(item, query));
23
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stepState } from "./stepper.ts";
3
+
4
+ describe("stepState", () => {
5
+ test("classifies steps relative to the active index", () => {
6
+ expect(stepState(0, 2)).toBe("completed");
7
+ expect(stepState(1, 2)).toBe("completed");
8
+ expect(stepState(2, 2)).toBe("active");
9
+ expect(stepState(3, 2)).toBe("pending");
10
+ });
11
+
12
+ test("activeIndex 0: only the first step is active, nothing completed", () => {
13
+ expect(stepState(0, 0)).toBe("active");
14
+ expect(stepState(1, 0)).toBe("pending");
15
+ });
16
+ });
package/src/stepper.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type StepState = "completed" | "active" | "pending";
2
+
3
+ export function stepState(index: number, activeIndex: number): StepState {
4
+ if (index < activeIndex) return "completed";
5
+ if (index === activeIndex) return "active";
6
+ return "pending";
7
+ }