diff --git a/.yarn/versions/b270b59b.yml b/.yarn/versions/b270b59b.yml new file mode 100644 index 000000000..897e173fd --- /dev/null +++ b/.yarn/versions/b270b59b.yml @@ -0,0 +1,3 @@ +declined: + - primitives + - "@radix-ui/react-list" diff --git a/packages/react/list/package.json b/packages/react/list/package.json new file mode 100644 index 000000000..4b3338134 --- /dev/null +++ b/packages/react/list/package.json @@ -0,0 +1,72 @@ +{ + "name": "@radix-ui/react-list", + "version": "1.0.0", + "license": "MIT", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "source": "./src/index.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "sideEffects": false, + "scripts": { + "lint": "eslint --max-warnings 0 src", + "clean": "rm -rf dist", + "version": "yarn version" + }, + "dependencies": { + "@radix-ui/primitive": "workspace:*", + "@radix-ui/react-context": "workspace:*", + "@radix-ui/react-direction": "workspace:*", + "@radix-ui/react-roving-focus": "workspace:*", + "@radix-ui/react-use-controllable-state": "workspace:*" + }, + "devDependencies": { + "@repo/eslint-config": "workspace:*", + "@repo/test-data": "workspace:*", + "@repo/typescript-config": "workspace:*", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "eslint": "^9.18.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + }, + "homepage": "https://radix-ui.com/primitives", + "repository": { + "type": "git", + "url": "git+https://github.com/radix-ui/primitives.git" + }, + "bugs": { + "url": "https://github.com/radix-ui/primitives/issues" + }, + "stableVersion": "1.0.0" +} diff --git a/packages/react/list/src/index.ts b/packages/react/list/src/index.ts new file mode 100644 index 000000000..2e56e1887 --- /dev/null +++ b/packages/react/list/src/index.ts @@ -0,0 +1,13 @@ +'use client'; +export { + createListScope, + // + List, + ListGroup, + ListItem, + // + Root, + Group, + Item, +} from './list'; +export type { ListProps, ListItemProps } from './list'; diff --git a/packages/react/list/src/list.stories.module.css b/packages/react/list/src/list.stories.module.css new file mode 100644 index 000000000..99c4708b3 --- /dev/null +++ b/packages/react/list/src/list.stories.module.css @@ -0,0 +1,10 @@ +.item { + &[aria-selected] { + background-color: green; + } + &[role='group'] { + &[aria-selected] { + background-color: lime; + } + } +} diff --git a/packages/react/list/src/list.stories.tsx b/packages/react/list/src/list.stories.tsx new file mode 100644 index 000000000..6ac5cba48 --- /dev/null +++ b/packages/react/list/src/list.stories.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import * as List from '@radix-ui/react-list'; +import styles from './list.stories.module.css'; + +export default { title: 'Components/List' }; + +export const Styled = () => { + return ( +
+ + + Option A + + + Option B + + + Option C + + + Option D + + +
+ ); +}; diff --git a/packages/react/list/src/list.tsx b/packages/react/list/src/list.tsx new file mode 100644 index 000000000..916ec6073 --- /dev/null +++ b/packages/react/list/src/list.tsx @@ -0,0 +1,213 @@ +import * as React from 'react'; + +import { composeEventHandlers } from '@radix-ui/primitive'; +import { createContextScope, type Scope } from '@radix-ui/react-context'; +import { useDirection } from '@radix-ui/react-direction'; +import { Primitive } from '@radix-ui/react-primitive'; +import * as RovingFocusGroup from '@radix-ui/react-roving-focus'; +import { createRovingFocusGroupScope } from '@radix-ui/react-roving-focus'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; + +/* ------------------------------------------------------------------------------------------------- + * List + * ----------------------------------------------------------------------------------------------- */ + +const LIST_NAME = 'List'; + +type ScopedProps

= P & { __scopeList?: Scope }; + +const [createListContext, createListScope] = createContextScope(LIST_NAME, [ + createRovingFocusGroupScope, +]); +const useRovingFocusGroupScope = createRovingFocusGroupScope(); + +type RovingFocusGroupProps = React.ComponentPropsWithoutRef; + +type ListContextValue = { + orientation: RovingFocusGroupProps['orientation']; + dir: RovingFocusGroupProps['dir']; + multiselect: boolean; + selectedKeys: Set; + onSelect(key: string): void; +}; + +const [ListProvider, useListContext] = createListContext(LIST_NAME); + +type ListElement = React.ElementRef; +type ListProps = React.ComponentPropsWithoutRef & { + orientation?: RovingFocusGroupProps['orientation']; + loop?: RovingFocusGroupProps['loop']; + dir?: RovingFocusGroupProps['dir']; + multiselect?: boolean; + + selectedKeys?: string[]; + onSelectedKeysChange?: (selectedKeys: string[]) => void; + defaultSelectedKeys?: string[]; +}; + +const List = React.forwardRef>((props, forwardedRef) => { + const { + __scopeList, + orientation = 'vertical', + loop = true, + dir, + + multiselect = false, + + selectedKeys: selectedKeysProp, + onSelectedKeysChange, + defaultSelectedKeys = [], + + ...domProps + } = props; + + // RovingFocus scope for focus management + const rovingFocusScope = useRovingFocusGroupScope(__scopeList); + + // useControllableState for selected keys + const [selectedKeys, setSelectedKeys] = useControllableState({ + prop: selectedKeysProp, + onChange: onSelectedKeysChange, + defaultProp: defaultSelectedKeys, + }); + + const handleSelect = React.useCallback( + (key: string) => { + setSelectedKeys((prevValue) => { + const prevSet = new Set(prevValue ?? []); + if (!multiselect) { + // single-select + return [key]; + } else { + // multi-select + if (prevSet.has(key)) { + prevSet.delete(key); + } else { + prevSet.add(key); + } + return Array.from(prevSet); + } + }); + }, + [multiselect, setSelectedKeys] + ); + + // Convert direction + set up the context + const direction = useDirection(dir); + + // Convert arrays to sets for internal usage + const selectedKeysSet = React.useMemo(() => new Set(selectedKeys), [selectedKeys]); + + return ( + + + + + + ); +}); +List.displayName = LIST_NAME; + +/* ------------------------------------------------------------------------------------------------- + * ListItem + * ----------------------------------------------------------------------------------------------- */ + +type ListItemElement = React.ElementRef; +type ListItemProps = React.ComponentPropsWithoutRef & { + id: string; +}; + +const ListItem = React.forwardRef>( + (props, forwardedRef) => { + const { id, __scopeList, ...domProps } = props; + const { selectedKeys, onSelect, orientation } = useListContext(LIST_NAME, __scopeList); + + const isSelected = selectedKeys.has(id); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const { key } = event; + if (key === 'Enter') { + onSelect(id); + } + }, + [onSelect, id] + ); + + const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeList); + return ( + + onSelect(id))} + {...domProps} + /> + + ); + } +); +ListItem.displayName = 'ListItem'; + +/* ------------------------------------------------------------------------------------------------- + * ListGroup + * ----------------------------------------------------------------------------------------------- */ + +type ListGroupElement = React.ElementRef; +type ListGroupProps = React.ComponentPropsWithoutRef; + +const ListGroup = React.forwardRef>( + (props, forwardedRef) => { + const { __scopeList, ...domProps } = props; + + return ; + } +); +ListGroup.displayName = 'ListGroup'; + +/* ------------------------------------------------------------------------------------------------- + * Exports + * ----------------------------------------------------------------------------------------------- */ + +export const createListPrimitiveScope = createListScope(); + +const Root = List; +const Group = ListGroup; +const Item = ListItem; + +export { + createListScope, + // + List, + ListGroup, + ListItem, + // + Root, + Group, + Item, +}; + +export type { ListProps, ListItemProps }; diff --git a/yarn.lock b/yarn.lock index 252aa86f5..b933109f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2260,6 +2260,37 @@ __metadata: languageName: unknown linkType: soft +"@radix-ui/react-list@workspace:packages/react/list": + version: 0.0.0-use.local + resolution: "@radix-ui/react-list@workspace:packages/react/list" + dependencies: + "@radix-ui/primitive": "workspace:*" + "@radix-ui/react-context": "workspace:*" + "@radix-ui/react-direction": "workspace:*" + "@radix-ui/react-roving-focus": "workspace:*" + "@radix-ui/react-use-controllable-state": "workspace:*" + "@repo/eslint-config": "workspace:*" + "@repo/test-data": "workspace:*" + "@repo/typescript-config": "workspace:*" + "@types/react": "npm:^19.0.7" + "@types/react-dom": "npm:^19.0.3" + eslint: "npm:^9.18.0" + react: "npm:^19.0.0" + react-dom: "npm:^19.0.0" + typescript: "npm:^5.7.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + languageName: unknown + linkType: soft + "@radix-ui/react-menu@workspace:*, @radix-ui/react-menu@workspace:packages/react/menu": version: 0.0.0-use.local resolution: "@radix-ui/react-menu@workspace:packages/react/menu"