diff --git a/packages/list-state/CHANGELOG.md b/packages/list-state/CHANGELOG.md
new file mode 100644
index 000000000..e75d67e06
--- /dev/null
+++ b/packages/list-state/CHANGELOG.md
@@ -0,0 +1,20 @@
+# @solid-primitives/list-state
+
+## 0.1.0
+
+### Initial Release
+
+- Added `createListState` - Single-select list with keyboard navigation
+- Added `createMultiSelectListState` - Multi-select list with cursor-based navigation and range selection
+- Full keyboard support: Arrow keys, Vim bindings (hjkl), Home/End, Tab
+- Configurable orientation: Vertical or horizontal lists
+- Bidirectional text: RTL and LTR support
+- List looping: Optional looping at list boundaries
+- Range selection: Shift+Arrow for multi-select range expansion
+- Type-safe: Full TypeScript support with generic item types
+- SSR-safe: Works in both browser and server environments
+- Zero dependencies: Relies only on Solid.js primitives
+
+### Credits
+
+Adapted from [corvu's solid-list](https://github.com/corvudev/corvu/tree/main/packages/solid-list) by [Jasmin Noetzli](https://github.com/GiyoMoon) and migrated to Solid Primitives for Solid 2.0. Used under the MIT License.
diff --git a/packages/list-state/LICENSE b/packages/list-state/LICENSE
new file mode 100644
index 000000000..298f51a35
--- /dev/null
+++ b/packages/list-state/LICENSE
@@ -0,0 +1,28 @@
+MIT License
+
+Copyright (c) 2024 Solid Core Team
+Copyright (c) 2023-2025 Jasmin Noetzli
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+---
+
+This package includes code adapted from corvu's solid-list
+(https://github.com/corvudev/corvu/tree/main/packages/solid-list)
+which is also licensed under the MIT License.
diff --git a/packages/list-state/README.md b/packages/list-state/README.md
new file mode 100644
index 000000000..515fd0e57
--- /dev/null
+++ b/packages/list-state/README.md
@@ -0,0 +1,232 @@
+
+
+
+
+# @solid-primitives/list-state
+
+[](https://bundlephobia.com/package/@solid-primitives/list-state)
+[](https://www.npmjs.com/package/@solid-primitives/list-state)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+Primitives for managing keyboard-navigable list state. Provides accessible, fully-featured list state management with support for single-select and multi-select patterns.
+
+- [`createListState`](#createliststate) — Single-select list with keyboard navigation
+- [`createMultiSelectListState`](#createmultiselectliststate) — Multi-select list with cursor-based navigation and range selection
+
+## Installation
+
+```bash
+npm install @solid-primitives/list-state
+# or
+yarn add @solid-primitives/list-state
+# or
+pnpm add @solid-primitives/list-state
+```
+
+## Features
+
+- **Full keyboard support**: Arrow keys, Vim bindings (hjkl), Home/End, Tab
+- **Configurable orientation**: Vertical or horizontal lists
+- **Bidirectional text**: RTL and LTR support
+- **List looping**: Optional looping at list boundaries
+- **Range selection**: Shift+Arrow for multi-select range expansion
+- **Type-safe**: Full TypeScript support with generic item types
+- **SSR-safe**: Works in both browser and server environments
+- **Zero dependencies**: Relies only on Solid.js primitives
+
+## `createListState`
+
+A reactive primitive for single-select list navigation. Returns an `active` signal for the currently selected item and an `onKeyDown` handler for keyboard events.
+
+```ts
+import { createListState } from "@solid-primitives/list-state";
+import { createSignal } from "solid-js";
+
+export function MyList() {
+ const items = ["Apple", "Banana", "Cherry"];
+ const { active, setActive, onKeyDown } = createListState({
+ items,
+ initialActive: items[0],
+ });
+
+ return (
+
+ {items.map((item) => (
+ - setActive(item)}
+ class={{ selected: active() === item }}
+ >
+ {item}
+
+ ))}
+
+ );
+}
+```
+
+### Props
+
+```ts
+interface ListStateProps {
+ /** The items in the list. Should be in the same order as they appear in the DOM. */
+ items: MaybeAccessor;
+
+ /** The initially active item. @default null */
+ initialActive?: T | null;
+
+ /** The orientation of the list. @default "vertical" */
+ orientation?: MaybeAccessor<"vertical" | "horizontal">;
+
+ /** Whether the list should loop. @default true */
+ loop?: MaybeAccessor;
+
+ /** The text direction of the list. @default "ltr" */
+ textDirection?: MaybeAccessor<"ltr" | "rtl">;
+
+ /** Whether tab key presses should be handled. @default true */
+ handleTab?: MaybeAccessor;
+
+ /** Whether vim movement key bindings should be used. @default false */
+ vimMode?: MaybeAccessor;
+
+ /** The vim movement key bindings. @default { up: 'k', down: 'j', right: 'l', left: 'h' } */
+ vimKeys?: MaybeAccessor<{ up: string; down: string; right: string; left: string }>;
+
+ /** Callback fired when the active item changes. */
+ onActiveChange?: (active: T | null) => void;
+}
+```
+
+### Returns
+
+```ts
+interface ListStateReturn {
+ active: Accessor;
+ setActive: (value: T | null) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+}
+```
+
+### Keyboard Shortcuts
+
+| Key | Action |
+|-----|--------|
+| ↑ / ↓ | Navigate vertically (or ← / → for horizontal) |
+| Home | Jump to first item |
+| End | Jump to last item |
+| Tab | Navigate forward (if `handleTab` is true) |
+| Shift+Tab | Navigate backward (if `handleTab` is true) |
+| k / j | Vim navigation (if `vimMode` is true) |
+
+## `createMultiSelectListState`
+
+A reactive primitive for multi-select list navigation with range selection. Maintains three separate states: `cursor` (focused item), `active` (range of focused items), and `selected` (user-selected items).
+
+```ts
+import { createMultiSelectListState } from "@solid-primitives/list-state";
+
+export function MyMultiSelectList() {
+ const items = ["Apple", "Banana", "Cherry"];
+ const { cursor, active, selected, setCursorActive, toggleSelected, onKeyDown } =
+ createMultiSelectListState({
+ items,
+ });
+
+ return (
+
+ {items.map((item) => (
+ - setCursorActive(item)}
+ onDoubleClick={() => toggleSelected(item)}
+ class={{
+ cursor: cursor() === item,
+ selected: selected().includes(item),
+ }}
+ >
+ {item}
+
+ ))}
+
+ );
+}
+```
+
+### Props
+
+```ts
+interface MultiSelectListStateProps {
+ // Same as ListStateProps, plus:
+
+ /** The initially focused item (cursor). @default null */
+ initialCursor?: T | null;
+
+ /** The initially active items (range). @default [] */
+ initialActive?: T[];
+
+ /** The initially selected items. @default [] */
+ initialSelected?: T[];
+
+ /** Callback fired when the cursor changes. */
+ onCursorChange?: (cursor: T | null) => void;
+
+ /** Callback fired when the active items change. */
+ onActiveChange?: (active: T[]) => void;
+
+ /** Callback fired when the selected items change. */
+ onSelectedChange?: (selected: T[]) => void;
+}
+```
+
+### Returns
+
+```ts
+interface MultiSelectListStateReturn {
+ cursor: Accessor;
+ setCursor: (value: T | null) => void;
+ active: Accessor;
+ setActive: (value: T[]) => void;
+ setCursorActive: (item: T | null) => void;
+ selected: Accessor;
+ setSelected: (value: T[]) => void;
+ toggleSelected: (item: T) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+}
+```
+
+### Keyboard Shortcuts
+
+All shortcuts from `createListState` plus:
+
+| Key | Action |
+|-----|--------|
+| Shift+↑ / ↓ | Expand/contract selection range |
+
+## Types
+
+```ts
+export type Orientation = "vertical" | "horizontal";
+export type TextDirection = "ltr" | "rtl";
+
+export type VimKeys = {
+ up: string;
+ down: string;
+ right: string;
+ left: string;
+};
+```
+
+## Server-Side Rendering
+
+Both primitives are fully SSR-safe and will work correctly in both browser and server environments.
+
+## Credits
+
+This primitive was adapted from [corvu's solid-list](https://github.com/corvudev/corvu/tree/main/packages/solid-list) by [Jasmin Noetzli](https://github.com/GiyoMoon) and migrated to Solid Primitives for Solid 2.0. Used under the MIT License.
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/list-state/dev/index.tsx b/packages/list-state/dev/index.tsx
new file mode 100644
index 000000000..997b344fb
--- /dev/null
+++ b/packages/list-state/dev/index.tsx
@@ -0,0 +1,89 @@
+import { createListState, createMultiSelectListState } from "../src/index.js";
+
+const items = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
+
+export function SingleSelectDemo() {
+ const { active, onKeyDown } = createListState({
+ items: items,
+ initialActive: items[0],
+ });
+
+ return (
+
+
Single-Select List
+
Active: {active()}
+
+ {items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+
+ );
+}
+
+export function MultiSelectDemo() {
+ const { cursor, active, selected, setCursorActive, toggleSelected, onKeyDown } =
+ createMultiSelectListState({
+ items: items,
+ initialCursor: items[0],
+ });
+
+ return (
+
+
Multi-Select List
+
Cursor: {cursor()}
+
Selected: {selected().join(", ") || "None"}
+
+ {items.map((item) => (
+ - setCursorActive(item)}
+ onDoubleClick={() => toggleSelected(item)}
+ style={{
+ padding: "0.5rem",
+ background: cursor() === item ? "#0066cc" : selected().includes(item) ? "#cce5ff" : "transparent",
+ color: cursor() === item ? "white" : "black",
+ cursor: "pointer",
+ }}
+ >
+ {item}
+
+ ))}
+
+
+ Double-click to toggle selection
+
+
+ );
+}
diff --git a/packages/list-state/package.json b/packages/list-state/package.json
new file mode 100644
index 000000000..acd2a28b6
--- /dev/null
+++ b/packages/list-state/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "@solid-primitives/list-state",
+ "version": "0.1.0",
+ "description": "Keyboard navigable list state management primitives",
+ "author": {
+ "name": "Jasmin Noetzli",
+ "email": "code@jasi.dev",
+ "url": "https://github.com/GiyoMoon"
+ },
+ "contributors": [
+ {
+ "name": "David Di Biase",
+ "email": "dave@solidjs.com"
+ }
+ ],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/list-state",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "list-state",
+ "stage": 1,
+ "list": [
+ "createListState",
+ "createMultiSelectListState"
+ ],
+ "category": "Inputs"
+ },
+ "keywords": [
+ "solid",
+ "primitives",
+ "list",
+ "keyboard",
+ "navigation",
+ "state"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "typesVersions": {},
+ "scripts": {
+ "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
+ "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "^2.0.0-beta.12",
+ "solid-js": "^2.0.0-beta.12"
+ },
+ "dependencies": {
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/web": "2.0.0-beta.12",
+ "solid-js": "2.0.0-beta.12"
+ }
+}
diff --git a/packages/list-state/src/index.ts b/packages/list-state/src/index.ts
new file mode 100644
index 000000000..2aac4ff8b
--- /dev/null
+++ b/packages/list-state/src/index.ts
@@ -0,0 +1,11 @@
+export { createListState } from "./list-state.js";
+export { createMultiSelectListState } from "./multi-list-state.js";
+export type {
+ ListStateProps,
+ ListStateReturn,
+ MultiSelectListStateProps,
+ MultiSelectListStateReturn,
+ Orientation,
+ TextDirection,
+ VimKeys,
+} from "./types.js";
diff --git a/packages/list-state/src/list-state.ts b/packages/list-state/src/list-state.ts
new file mode 100644
index 000000000..766011a31
--- /dev/null
+++ b/packages/list-state/src/list-state.ts
@@ -0,0 +1,139 @@
+import { createSignal } from "solid-js";
+import { access } from "@solid-primitives/utils";
+import type { ListStateProps, ListStateReturn } from "./types.js";
+
+/**
+ * Creates a keyboard navigable single-select list.
+ *
+ * @param props - Configuration for the list state
+ * @param props.items - The items in the list. Should be in the same order as they appear in the DOM.
+ * @param props.initialActive - The initially active item. *Default = `null`*
+ * @param props.orientation - The orientation of the list. *Default = `'vertical'`*
+ * @param props.loop - Whether the list should loop. *Default = `true`*
+ * @param props.textDirection - The text direction of the list. *Default = `'ltr'`*
+ * @param props.handleTab - Whether tab key presses should be handled. *Default = `true`*
+ * @param props.vimMode - Whether vim movement key bindings should be used. *Default = `false`*
+ * @param props.vimKeys - The vim movement key bindings. *Default = `{ up: 'k', down: 'j', right: 'l', left: 'h' }`*
+ * @param props.onActiveChange - Callback fired when the active item changes.
+ * @returns Object with `active` accessor, `setActive` setter, and `onKeyDown` event handler
+ *
+ * @example
+ * ```tsx
+ * const { active, setActive, onKeyDown } = createListState({
+ * items: () => ["Item 1", "Item 2", "Item 3"],
+ * initialActive: "Item 1",
+ * onActiveChange: (item) => console.log("Active:", item),
+ * });
+ *
+ *
+ * {items().map((item) => (
+ * - {item}
+ * ))}
+ *
+ * ```
+ */
+export function createListState(props: ListStateProps): ListStateReturn {
+ const defaultedProps = {
+ initialActive: props.initialActive ?? (null as T | null),
+ orientation: props.orientation ?? ("vertical" as const),
+ loop: props.loop ?? true,
+ textDirection: props.textDirection ?? ("ltr" as const),
+ handleTab: props.handleTab ?? true,
+ vimMode: props.vimMode ?? false,
+ vimKeys: props.vimKeys ?? {
+ up: "k",
+ down: "j",
+ right: "l",
+ left: "h",
+ },
+ items: props.items,
+ onActiveChange: props.onActiveChange,
+ };
+
+ const [active, setActive] = createSignal(defaultedProps.initialActive as any);
+
+ const nextKeys = () => {
+ const vimKeys = access(defaultedProps.vimKeys);
+ let arrowKey: string;
+ let vimKey: string;
+ if (access(defaultedProps.orientation) === "vertical") {
+ arrowKey = "arrowdown";
+ vimKey = vimKeys.down;
+ } else if (access(defaultedProps.textDirection) === "ltr") {
+ arrowKey = "arrowright";
+ vimKey = vimKeys.right;
+ } else {
+ arrowKey = "arrowleft";
+ vimKey = vimKeys.left;
+ }
+ return access(defaultedProps.vimMode) ? [arrowKey, vimKey] : [arrowKey];
+ };
+
+ const previousKeys = () => {
+ const vimKeys = access(defaultedProps.vimKeys);
+ let arrowKey: string;
+ let vimKey: string;
+ if (access(defaultedProps.orientation) === "vertical") {
+ arrowKey = "arrowup";
+ vimKey = vimKeys.up;
+ } else if (access(defaultedProps.textDirection) === "ltr") {
+ arrowKey = "arrowleft";
+ vimKey = vimKeys.left;
+ } else {
+ arrowKey = "arrowright";
+ vimKey = vimKeys.right;
+ }
+ return access(defaultedProps.vimMode) ? [arrowKey, vimKey] : [arrowKey];
+ };
+
+ const updateActive = (newActive: T | null) => {
+ setActive(newActive as any);
+ defaultedProps.onActiveChange?.(newActive);
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ const eventKey = event.key.toLowerCase();
+ const _items = access(defaultedProps.items);
+ if (_items.length === 0) return;
+ const _itemCount = _items.length;
+ const _active = active();
+ const _activeIndex = _active !== null ? _items.indexOf(_active) : null;
+
+ if (nextKeys().includes(eventKey)) {
+ event.preventDefault();
+ if (_activeIndex === _itemCount - 1) {
+ if (access(defaultedProps.loop)) {
+ updateActive(_items[0]!);
+ }
+ } else {
+ updateActive(_items[_activeIndex !== null ? _activeIndex + 1 : 0]!);
+ }
+ } else if (previousKeys().includes(eventKey)) {
+ event.preventDefault();
+ if (_activeIndex === 0) {
+ if (access(defaultedProps.loop)) {
+ updateActive(_items[_itemCount - 1]!);
+ }
+ } else {
+ updateActive(_items[_activeIndex !== null ? _activeIndex - 1 : _itemCount - 1]!);
+ }
+ } else if (eventKey === "home") {
+ event.preventDefault();
+ updateActive(_items[0]!);
+ } else if (eventKey === "end") {
+ event.preventDefault();
+ updateActive(_items[_itemCount - 1]!);
+ } else if (access(defaultedProps.handleTab) && _activeIndex !== null) {
+ if (eventKey === "tab" && !event.shiftKey && _activeIndex < _itemCount - 1) {
+ event.preventDefault();
+ updateActive(_items[_activeIndex + 1]!);
+ }
+ if (eventKey === "tab" && event.shiftKey && _activeIndex > 0) {
+ event.preventDefault();
+ updateActive(_items[_activeIndex - 1]!);
+ }
+ }
+ };
+
+ return { active, setActive: (value: T | null) => updateActive(value), onKeyDown };
+}
diff --git a/packages/list-state/src/multi-list-state.ts b/packages/list-state/src/multi-list-state.ts
new file mode 100644
index 000000000..577769a66
--- /dev/null
+++ b/packages/list-state/src/multi-list-state.ts
@@ -0,0 +1,243 @@
+import { createSignal } from "solid-js";
+import { access } from "@solid-primitives/utils";
+import type { MultiSelectListStateProps, MultiSelectListStateReturn } from "./types.js";
+
+/**
+ * Creates a keyboard navigable multi-select list with cursor-based navigation.
+ *
+ * @param props - Configuration for the multi-select list state
+ * @param props.items - The items in the list. Should be in the same order as they appear in the DOM.
+ * @param props.initialCursor - The initially focused item (cursor). *Default = `null`*
+ * @param props.initialActive - The initially active items. *Default = `[]`*
+ * @param props.initialSelected - The initially selected items. *Default = `[]`*
+ * @param props.orientation - The orientation of the list. *Default = `'vertical'`*
+ * @param props.loop - Whether the list should loop. *Default = `true`*
+ * @param props.textDirection - The text direction of the list. *Default = `'ltr'`*
+ * @param props.handleTab - Whether tab key presses should be handled. *Default = `true`*
+ * @param props.vimMode - Whether vim movement key bindings should be used. *Default = `false`*
+ * @param props.vimKeys - The vim movement key bindings. *Default = `{ up: 'k', down: 'j', right: 'l', left: 'h' }`*
+ * @param props.onCursorChange - Callback fired when the cursor changes.
+ * @param props.onActiveChange - Callback fired when the active items change.
+ * @param props.onSelectedChange - Callback fired when the selected items change.
+ * @returns Object with cursor, active, and selected accessors/setters, plus utility methods
+ *
+ * @example
+ * ```tsx
+ * const { cursor, active, selected, setCursorActive, toggleSelected, onKeyDown } = createMultiSelectListState({
+ * items: () => ["Item 1", "Item 2", "Item 3"],
+ * initialCursor: "Item 1",
+ * });
+ *
+ *
+ * {items().map((item) => (
+ * - setCursorActive(item)}
+ * >
+ * {item}
+ *
+ * ))}
+ *
+ * ```
+ */
+export function createMultiSelectListState(
+ props: MultiSelectListStateProps,
+): MultiSelectListStateReturn {
+ const defaultedProps = {
+ initialCursor: props.initialCursor ?? (null as T | null),
+ initialActive: props.initialActive ?? ([] as T[]),
+ initialSelected: props.initialSelected ?? ([] as T[]),
+ orientation: props.orientation ?? ("vertical" as const),
+ loop: props.loop ?? true,
+ textDirection: props.textDirection ?? ("ltr" as const),
+ handleTab: props.handleTab ?? true,
+ vimMode: props.vimMode ?? false,
+ vimKeys: props.vimKeys ?? {
+ up: "k",
+ down: "j",
+ right: "l",
+ left: "h",
+ },
+ items: props.items,
+ onCursorChange: props.onCursorChange,
+ onActiveChange: props.onActiveChange,
+ onSelectedChange: props.onSelectedChange,
+ };
+
+ const [cursor, setCursor] = createSignal(defaultedProps.initialCursor as any);
+ const [active, setActive] = createSignal(defaultedProps.initialActive as any);
+ const [selected, setSelected] = createSignal(defaultedProps.initialSelected as any);
+
+ let direction: "next" | "previous" | null = null;
+
+ const updateCursor = (newCursor: T | null) => {
+ setCursor(newCursor as any);
+ defaultedProps.onCursorChange?.(newCursor);
+ };
+
+ const updateActive = (newActive: T[]) => {
+ setActive(newActive as any);
+ defaultedProps.onActiveChange?.(newActive);
+ };
+
+ const updateSelected = (newSelected: T[]) => {
+ setSelected(newSelected as any);
+ defaultedProps.onSelectedChange?.(newSelected);
+ };
+
+ const setCursorActive = (item: T | null) => {
+ updateCursor(item);
+ updateActive(item !== null ? [item] : []);
+ direction = null;
+ };
+
+ const toggleSelected = (item: T) => {
+ const currentSelected = selected();
+ updateSelected(
+ currentSelected.includes(item) ? currentSelected.filter((s) => s !== item) : [...currentSelected, item],
+ );
+ };
+
+ const nextKeys = () => {
+ const vimKeys = access(defaultedProps.vimKeys);
+ let arrowKey: string;
+ let vimKey: string;
+ if (access(defaultedProps.orientation) === "vertical") {
+ arrowKey = "arrowdown";
+ vimKey = vimKeys.down;
+ } else if (access(defaultedProps.textDirection) === "ltr") {
+ arrowKey = "arrowright";
+ vimKey = vimKeys.right;
+ } else {
+ arrowKey = "arrowleft";
+ vimKey = vimKeys.left;
+ }
+ return access(defaultedProps.vimMode) ? [arrowKey, vimKey] : [arrowKey];
+ };
+
+ const previousKeys = () => {
+ const vimKeys = access(defaultedProps.vimKeys);
+ let arrowKey: string;
+ let vimKey: string;
+ if (access(defaultedProps.orientation) === "vertical") {
+ arrowKey = "arrowup";
+ vimKey = vimKeys.up;
+ } else if (access(defaultedProps.textDirection) === "ltr") {
+ arrowKey = "arrowleft";
+ vimKey = vimKeys.left;
+ } else {
+ arrowKey = "arrowright";
+ vimKey = vimKeys.right;
+ }
+ return access(defaultedProps.vimMode) ? [arrowKey, vimKey] : [arrowKey];
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ const eventKey = event.key.toLowerCase();
+ const _items = access(defaultedProps.items);
+ if (_items.length === 0) return;
+ const _itemCount = _items.length;
+ const _cursor = cursor();
+ const _cursorIndex = _cursor !== null ? _items.indexOf(_cursor) : null;
+ const _active = active();
+
+ if (nextKeys().includes(eventKey)) {
+ event.preventDefault();
+ if (event.shiftKey) {
+ if (_cursorIndex === null) {
+ setCursorActive(_items[0]!);
+ updateSelected([_items[0]!]);
+ } else if (
+ _cursorIndex !== _itemCount - 1 ||
+ (_active.length === 1 && direction === "previous")
+ ) {
+ if (_active.length === 1 && direction !== "next") {
+ toggleSelected(_cursor!);
+ direction = direction === "previous" ? null : "next";
+ } else {
+ const newCursor = _items[_cursorIndex + 1]!;
+ updateCursor(newCursor);
+ if (_active.includes(newCursor)) {
+ updateActive(_active.filter((a) => a !== _cursor));
+ toggleSelected(_cursor!);
+ } else {
+ updateActive([..._active, newCursor]);
+ toggleSelected(newCursor);
+ }
+ }
+ }
+ } else {
+ if (_cursorIndex === _itemCount - 1) {
+ if (access(defaultedProps.loop)) {
+ setCursorActive(_items[0]!);
+ }
+ } else {
+ setCursorActive(_items[_cursorIndex !== null ? _cursorIndex + 1 : 0]!);
+ }
+ }
+ } else if (previousKeys().includes(eventKey)) {
+ event.preventDefault();
+ if (event.shiftKey) {
+ if (_cursorIndex === null) {
+ setCursorActive(_items[_itemCount - 1]!);
+ updateSelected([_items[_itemCount - 1]!]);
+ } else if (
+ _cursorIndex !== 0 ||
+ (_active.length === 1 && direction === "next")
+ ) {
+ if (_active.length === 1 && direction !== "previous") {
+ toggleSelected(_cursor!);
+ direction = direction === "next" ? null : "previous";
+ } else {
+ const newCursor = _items[_cursorIndex - 1]!;
+ updateCursor(newCursor);
+ if (_active.includes(newCursor)) {
+ updateActive(_active.filter((a) => a !== _cursor));
+ toggleSelected(_cursor!);
+ } else {
+ updateActive([..._active, newCursor]);
+ toggleSelected(newCursor);
+ }
+ }
+ }
+ } else {
+ if (_cursorIndex === 0) {
+ if (access(defaultedProps.loop)) {
+ setCursorActive(_items[_itemCount - 1]!);
+ }
+ } else {
+ setCursorActive(
+ _items[_cursorIndex !== null ? _cursorIndex - 1 : _itemCount - 1]!,
+ );
+ }
+ }
+ } else if (eventKey === "home") {
+ event.preventDefault();
+ setCursorActive(_items[0]!);
+ } else if (eventKey === "end") {
+ event.preventDefault();
+ setCursorActive(_items[_itemCount - 1]!);
+ } else if (access(defaultedProps.handleTab) && _cursorIndex !== null) {
+ if (eventKey === "tab" && !event.shiftKey && _cursorIndex < _itemCount - 1) {
+ event.preventDefault();
+ setCursorActive(_items[_cursorIndex + 1]!);
+ }
+ if (eventKey === "tab" && event.shiftKey && _cursorIndex > 0) {
+ event.preventDefault();
+ setCursorActive(_items[_cursorIndex - 1]!);
+ }
+ }
+ };
+
+ return {
+ cursor,
+ setCursor: (value: T | null) => updateCursor(value),
+ active,
+ setActive: (value: T[]) => updateActive(value),
+ setCursorActive,
+ selected,
+ setSelected: (value: T[]) => updateSelected(value),
+ toggleSelected,
+ onKeyDown,
+ };
+}
diff --git a/packages/list-state/src/types.ts b/packages/list-state/src/types.ts
new file mode 100644
index 000000000..793be6cc1
--- /dev/null
+++ b/packages/list-state/src/types.ts
@@ -0,0 +1,80 @@
+import type { Accessor } from "solid-js";
+import type { MaybeAccessor } from "@solid-primitives/utils";
+
+export type VimKeys = {
+ up: string;
+ down: string;
+ right: string;
+ left: string;
+};
+
+export type Orientation = "vertical" | "horizontal";
+export type TextDirection = "ltr" | "rtl";
+
+export interface ListStateProps {
+ /** The items in the list. Should be in the same order as they appear in the DOM. */
+ items: MaybeAccessor;
+ /** The initially active item. @default null */
+ initialActive?: T | null;
+ /** The orientation of the list. @default "vertical" */
+ orientation?: MaybeAccessor;
+ /** Whether the list should loop. @default true */
+ loop?: MaybeAccessor;
+ /** The text direction of the list. @default "ltr" */
+ textDirection?: MaybeAccessor;
+ /** Whether tab key presses should be handled. @default true */
+ handleTab?: MaybeAccessor;
+ /** Whether vim movement key bindings should be used additionally to arrow key navigation. @default false */
+ vimMode?: MaybeAccessor;
+ /** The vim movement key bindings to use. @default { up: 'k', down: 'j', right: 'l', left: 'h' } */
+ vimKeys?: MaybeAccessor;
+ /** Callback fired when the active item changes. */
+ onActiveChange?: (active: T | null) => void;
+}
+
+export interface ListStateReturn {
+ active: Accessor;
+ setActive: (value: T | null) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+}
+
+export interface MultiSelectListStateProps {
+ /** The items in the list. Should be in the same order as they appear in the DOM. */
+ items: MaybeAccessor;
+ /** The initially focused item (cursor). @default null */
+ initialCursor?: T | null;
+ /** The initially active items. @default [] */
+ initialActive?: T[];
+ /** The initially selected items. @default [] */
+ initialSelected?: T[];
+ /** The orientation of the list. @default "vertical" */
+ orientation?: MaybeAccessor;
+ /** Whether the list should loop. @default true */
+ loop?: MaybeAccessor;
+ /** The text direction of the list. @default "ltr" */
+ textDirection?: MaybeAccessor;
+ /** Whether tab key presses should be handled. @default true */
+ handleTab?: MaybeAccessor;
+ /** Whether vim movement key bindings should be used additionally to arrow key navigation. @default false */
+ vimMode?: MaybeAccessor;
+ /** The vim movement key bindings to use. @default { up: 'k', down: 'j', right: 'l', left: 'h' } */
+ vimKeys?: MaybeAccessor;
+ /** Callback fired when the cursor changes. */
+ onCursorChange?: (cursor: T | null) => void;
+ /** Callback fired when the active items change. */
+ onActiveChange?: (active: T[]) => void;
+ /** Callback fired when the selected items change. */
+ onSelectedChange?: (selected: T[]) => void;
+}
+
+export interface MultiSelectListStateReturn {
+ cursor: Accessor;
+ setCursor: (value: T | null) => void;
+ active: Accessor;
+ setActive: (value: T[]) => void;
+ setCursorActive: (item: T | null) => void;
+ selected: Accessor;
+ setSelected: (value: T[]) => void;
+ toggleSelected: (item: T) => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+}
diff --git a/packages/list-state/test/index.test.tsx b/packages/list-state/test/index.test.tsx
new file mode 100644
index 000000000..95f40f6e1
--- /dev/null
+++ b/packages/list-state/test/index.test.tsx
@@ -0,0 +1,402 @@
+import { describe, test, expect, vi } from "vitest";
+import { createListState, createMultiSelectListState } from "../src/index.js";
+
+describe("createListState", () => {
+ test("creates a list state with initial active item", () => {
+ const items = ["a", "b", "c"];
+ const { active, onKeyDown } = createListState({
+ items: items,
+ initialActive: "a",
+ });
+ expect(active()).toBe("a");
+ expect(typeof onKeyDown).toBe("function");
+ });
+
+ test("returns all required properties", () => {
+ const { active, setActive, onKeyDown } = createListState({
+ items: ["item1", "item2"],
+ });
+ expect(typeof active).toBe("function");
+ expect(typeof setActive).toBe("function");
+ expect(typeof onKeyDown).toBe("function");
+ });
+
+ test("initializes with null active when no initial value provided", () => {
+ const { active } = createListState({ items: ["a", "b", "c"] });
+ expect(active()).toBe(null);
+ });
+
+ test("accepts all config options", () => {
+ const { active } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ orientation: "horizontal",
+ loop: false,
+ textDirection: "rtl",
+ handleTab: false,
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ onActiveChange: () => {},
+ });
+ expect(active()).toBe("a");
+ });
+
+ test("handles accessor functions for items", () => {
+ const { active } = createListState({
+ items: () => ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(active()).toBe("a");
+ });
+
+ test("handles accessor functions for configuration", () => {
+ const { active } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ orientation: () => "horizontal",
+ loop: () => false,
+ });
+ expect(active()).toBe("a");
+ });
+
+ test("onKeyDown handler exists and is callable", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(typeof onKeyDown).toBe("function");
+ const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("handles empty items without error", () => {
+ const { onKeyDown } = createListState({
+ items: [],
+ });
+ const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("handles key events case insensitively", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ vimMode: true,
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "J" }))).not.toThrow();
+ });
+
+ test("handles horizontal orientation", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ orientation: "horizontal",
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "ArrowRight" }))).not.toThrow();
+ });
+
+ test("handles RTL text direction", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ orientation: "horizontal",
+ textDirection: "rtl",
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft" }))).not.toThrow();
+ });
+
+ test("handles vim mode", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ vimMode: true,
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "k" }))).not.toThrow();
+ });
+
+ test("handles custom vim keys", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "s" }))).not.toThrow();
+ });
+
+ test("handles Tab navigation when enabled", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ handleTab: true,
+ initialActive: "a",
+ });
+ const event = new KeyboardEvent("keydown", { key: "Tab" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("ignores Tab when disabled", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ handleTab: false,
+ initialActive: "a",
+ });
+ const event = new KeyboardEvent("keydown", { key: "Tab" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("handles Home key", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "c",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "Home" }))).not.toThrow();
+ });
+
+ test("handles End key", () => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "End" }))).not.toThrow();
+ });
+
+ test("handles looping configuration", () => {
+ const { onKeyDown: onKeyDownWithLoop } = createListState({
+ items: ["a", "b", "c"],
+ loop: true,
+ initialActive: "c",
+ });
+ expect(() => onKeyDownWithLoop(new KeyboardEvent("keydown", { key: "ArrowDown" }))).not.toThrow();
+
+ const { onKeyDown: onKeyDownNoLoop } = createListState({
+ items: ["a", "b", "c"],
+ loop: false,
+ initialActive: "c",
+ });
+ expect(() => onKeyDownNoLoop(new KeyboardEvent("keydown", { key: "ArrowDown" }))).not.toThrow();
+ });
+});
+
+describe("createMultiSelectListState", () => {
+ test("creates a multi-select list state", () => {
+ const items = ["a", "b", "c"];
+ const { cursor, active, selected, onKeyDown } = createMultiSelectListState({
+ items: items,
+ });
+ expect(typeof cursor).toBe("function");
+ expect(typeof active).toBe("function");
+ expect(typeof selected).toBe("function");
+ expect(typeof onKeyDown).toBe("function");
+ });
+
+ test("returns all required properties", () => {
+ const {
+ cursor,
+ setCursor,
+ active,
+ setActive,
+ selected,
+ setSelected,
+ setCursorActive,
+ toggleSelected,
+ onKeyDown,
+ } = createMultiSelectListState({
+ items: ["item1", "item2"],
+ });
+ expect(typeof cursor).toBe("function");
+ expect(typeof setCursor).toBe("function");
+ expect(typeof active).toBe("function");
+ expect(typeof setActive).toBe("function");
+ expect(typeof selected).toBe("function");
+ expect(typeof setSelected).toBe("function");
+ expect(typeof setCursorActive).toBe("function");
+ expect(typeof toggleSelected).toBe("function");
+ expect(typeof onKeyDown).toBe("function");
+ });
+
+ test("initializes with null cursor and empty active/selected", () => {
+ const { cursor, active, selected } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ });
+ expect(cursor()).toBe(null);
+ expect(active()).toEqual([]);
+ expect(selected()).toEqual([]);
+ });
+
+ test("initializes with provided initial values", () => {
+ const { cursor, active, selected } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "b",
+ initialActive: ["b"],
+ initialSelected: ["a"],
+ });
+ expect(cursor()).toBe("b");
+ expect(active()).toEqual(["b"]);
+ expect(selected()).toEqual(["a"]);
+ });
+
+ test("accepts all config options", () => {
+ const { cursor } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "a",
+ initialActive: ["a"],
+ initialSelected: [],
+ orientation: "horizontal",
+ loop: false,
+ textDirection: "rtl",
+ handleTab: false,
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ onCursorChange: () => {},
+ onActiveChange: () => {},
+ onSelectedChange: () => {},
+ });
+ expect(cursor()).toBe("a");
+ });
+
+ test("onKeyDown handler exists and is callable", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ });
+ expect(typeof onKeyDown).toBe("function");
+ const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("handles empty items without error", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: [],
+ });
+ const event = new KeyboardEvent("keydown", { key: "ArrowDown" });
+ expect(() => onKeyDown(event)).not.toThrow();
+ });
+
+ test("calls onCursorChange when setCursorActive is called", () => {
+ const onChange = vi.fn();
+ const { setCursorActive } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ onCursorChange: onChange,
+ });
+ setCursorActive("a");
+ expect(onChange).toHaveBeenCalledWith("a");
+ });
+
+ test("calls onActiveChange when setCursorActive is called", () => {
+ const onChange = vi.fn();
+ const { setCursorActive } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ onActiveChange: onChange,
+ });
+ setCursorActive("a");
+ expect(onChange).toHaveBeenCalledWith(["a"]);
+ });
+
+ test("calls onCursorChange with null when setCursorActive(null)", () => {
+ const onChange = vi.fn();
+ const { setCursorActive } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "a",
+ onCursorChange: onChange,
+ });
+ onChange.mockClear();
+ setCursorActive(null);
+ expect(onChange).toHaveBeenCalledWith(null);
+ });
+
+ test("handles vim mode", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ vimMode: true,
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "j" }))).not.toThrow();
+ });
+
+ test("handles custom vim keys", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "s" }))).not.toThrow();
+ });
+
+ test("handles horizontal orientation", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ orientation: "horizontal",
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "ArrowRight" }))).not.toThrow();
+ });
+
+ test("handles RTL text direction", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ orientation: "horizontal",
+ textDirection: "rtl",
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft" }))).not.toThrow();
+ });
+
+ test("handles Tab navigation when enabled", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ handleTab: true,
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "Tab" }))).not.toThrow();
+ });
+
+ test("ignores Tab when disabled", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ handleTab: false,
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "Tab" }))).not.toThrow();
+ });
+
+ test("handles Home key", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "c",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "Home" }))).not.toThrow();
+ });
+
+ test("handles End key", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "a",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "End" }))).not.toThrow();
+ });
+
+ test("handles looping configuration", () => {
+ const { onKeyDown: onKeyDownWithLoop } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ loop: true,
+ initialCursor: "c",
+ });
+ expect(() => onKeyDownWithLoop(new KeyboardEvent("keydown", { key: "ArrowDown" }))).not.toThrow();
+
+ const { onKeyDown: onKeyDownNoLoop } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ loop: false,
+ initialCursor: "c",
+ });
+ expect(() => onKeyDownNoLoop(new KeyboardEvent("keydown", { key: "ArrowDown" }))).not.toThrow();
+ });
+
+ test("handles shift key modifiers", () => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "b",
+ });
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "ArrowDown", shiftKey: true }))).not.toThrow();
+ expect(() => onKeyDown(new KeyboardEvent("keydown", { key: "Home", shiftKey: true }))).not.toThrow();
+ });
+});
diff --git a/packages/list-state/test/server.test.ts b/packages/list-state/test/server.test.ts
new file mode 100644
index 000000000..a94bd1696
--- /dev/null
+++ b/packages/list-state/test/server.test.ts
@@ -0,0 +1,178 @@
+import { describe, expect, it, vi } from "vitest";
+import { createRoot } from "solid-js";
+import { createListState, createMultiSelectListState } from "../src/index.js";
+
+describe("createListState - SSR safety", () => {
+ it("creates list state without errors", () => {
+ createRoot((dispose) => {
+ const { active } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(active()).toBe("a");
+ dispose();
+ });
+ });
+
+ it("supports all configuration options in SSR", () => {
+ createRoot((dispose) => {
+ const { active } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ orientation: "horizontal",
+ loop: false,
+ textDirection: "rtl",
+ handleTab: true,
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ });
+ expect(active()).toBe("a");
+ dispose();
+ });
+ });
+
+ it("handles accessor functions in SSR", () => {
+ createRoot((dispose) => {
+ const { active } = createListState({
+ items: () => ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(active()).toBe("a");
+ dispose();
+ });
+ });
+
+ it("onKeyDown handler exists in SSR", () => {
+ createRoot((dispose) => {
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ });
+ expect(typeof onKeyDown).toBe("function");
+ dispose();
+ });
+ });
+
+ it("handles empty items in SSR", () => {
+ createRoot((dispose) => {
+ const { active } = createListState({
+ items: [],
+ });
+ expect(active()).toBe(null);
+ dispose();
+ });
+ });
+
+ it("supports callback in SSR", () => {
+ createRoot((dispose) => {
+ const onChange = vi.fn();
+ const { onKeyDown } = createListState({
+ items: ["a", "b", "c"],
+ initialActive: "a",
+ onActiveChange: onChange,
+ });
+ expect(typeof onKeyDown).toBe("function");
+ dispose();
+ });
+ });
+});
+
+describe("createMultiSelectListState - SSR safety", () => {
+ it("creates multi-select list state without errors", () => {
+ createRoot((dispose) => {
+ const { cursor, active, selected } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "a",
+ initialActive: ["a"],
+ initialSelected: ["a"],
+ });
+ expect(cursor()).toBe("a");
+ expect(active()).toEqual(["a"]);
+ expect(selected()).toEqual(["a"]);
+ dispose();
+ });
+ });
+
+ it("handles cursor and selection state in SSR", () => {
+ createRoot((dispose) => {
+ const { cursor, active, selected, setCursorActive } =
+ createMultiSelectListState({
+ items: ["a", "b", "c"],
+ });
+ setCursorActive("b");
+ expect(cursor()).toBe("b");
+ expect(active()).toEqual(["b"]);
+ expect(selected()).toEqual([]);
+ dispose();
+ });
+ });
+
+ it("supports all configuration options in SSR", () => {
+ createRoot((dispose) => {
+ const { cursor } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ initialCursor: "a",
+ initialActive: ["a"],
+ initialSelected: ["a"],
+ orientation: "horizontal",
+ loop: false,
+ textDirection: "rtl",
+ handleTab: true,
+ vimMode: true,
+ vimKeys: { up: "w", down: "s", left: "a", right: "d" },
+ onCursorChange: () => {},
+ onActiveChange: () => {},
+ onSelectedChange: () => {},
+ });
+ expect(cursor()).toBe("a");
+ dispose();
+ });
+ });
+
+ it("handles accessor functions in SSR", () => {
+ createRoot((dispose) => {
+ const { cursor } = createMultiSelectListState({
+ items: () => ["a", "b", "c"],
+ initialCursor: "a",
+ });
+ expect(cursor()).toBe("a");
+ dispose();
+ });
+ });
+
+ it("onKeyDown handler exists in SSR", () => {
+ createRoot((dispose) => {
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ });
+ expect(typeof onKeyDown).toBe("function");
+ dispose();
+ });
+ });
+
+ it("handles empty items in SSR", () => {
+ createRoot((dispose) => {
+ const { cursor } = createMultiSelectListState({
+ items: [],
+ });
+ expect(cursor()).toBe(null);
+ dispose();
+ });
+ });
+
+ it("supports callbacks in SSR", () => {
+ createRoot((dispose) => {
+ const onCursorChange = vi.fn();
+ const onActiveChange = vi.fn();
+ const onSelectedChange = vi.fn();
+ const { onKeyDown } = createMultiSelectListState({
+ items: ["a", "b", "c"],
+ onCursorChange,
+ onActiveChange,
+ onSelectedChange,
+ });
+ expect(typeof onKeyDown).toBe("function");
+ dispose();
+ });
+ });
+});
diff --git a/packages/list-state/tsconfig.json b/packages/list-state/tsconfig.json
new file mode 100644
index 000000000..dc1970e16
--- /dev/null
+++ b/packages/list-state/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "references": [
+ {
+ "path": "../utils"
+ }
+ ],
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b33d0debf..a438ac4f3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -548,6 +548,19 @@ importers:
specifier: ^1.9.7
version: 1.9.7
+ packages/list-state:
+ dependencies:
+ '@solid-primitives/utils':
+ specifier: workspace:^
+ version: link:../utils
+ devDependencies:
+ '@solidjs/web':
+ specifier: 2.0.0-beta.12
+ version: 2.0.0-beta.12(solid-js@2.0.0-beta.12)
+ solid-js:
+ specifier: 2.0.0-beta.12
+ version: 2.0.0-beta.12
+
packages/map:
dependencies:
'@solid-primitives/trigger':