@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 +31 -0
- package/src/Accordion.tsx +68 -0
- package/src/ButtonGroup.tsx +36 -0
- package/src/DescriptionList.tsx +31 -0
- package/src/Form.tsx +38 -0
- package/src/HoverCard.tsx +46 -0
- package/src/OtpInput.tsx +46 -0
- package/src/Pagination.tsx +61 -0
- package/src/SearchableList.tsx +46 -0
- package/src/StatusBar.tsx +28 -0
- package/src/Stepper.tsx +60 -0
- package/src/accordion.test.ts +54 -0
- package/src/accordion.ts +51 -0
- package/src/index.ts +38 -0
- package/src/otp.test.ts +43 -0
- package/src/otp.ts +49 -0
- package/src/pagination.test.ts +33 -0
- package/src/pagination.ts +35 -0
- package/src/searchable-list.test.ts +30 -0
- package/src/searchable-list.ts +23 -0
- package/src/stepper.test.ts +16 -0
- package/src/stepper.ts +7 -0
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
|
+
}
|
package/src/OtpInput.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Stepper.tsx
ADDED
|
@@ -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
|
+
});
|
package/src/accordion.ts
ADDED
|
@@ -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";
|
package/src/otp.test.ts
ADDED
|
@@ -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