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 +

+ +# @solid-primitives/list-state + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/list-state?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/list-state) +[![version](https://img.shields.io/npm/v/@solid-primitives/list-state?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/list-state) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](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 ( + + ); +} +``` + +### 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':