Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

list primitive #3383

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .yarn/versions/b270b59b.yml
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've chosen declined to workaround the pre-push hook, but not familiar with changeset

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declined:
- primitives
- "@radix-ui/react-list"
72 changes: 72 additions & 0 deletions packages/react/list/package.json
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i've just duplicated it from another one and adapted

Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"name": "@radix-ui/react-list",
"version": "1.0.0",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about the version you want

"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"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about the version you want (bis)

}
13 changes: 13 additions & 0 deletions packages/react/list/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';
export {
createListScope,
//
List,
ListGroup,
ListItem,
//
Root,
Group,
Item,
} from './list';
export type { ListProps, ListItemProps } from './list';
10 changes: 10 additions & 0 deletions packages/react/list/src/list.stories.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.item {
&[aria-selected] {
background-color: green;
}
&[role='group'] {
&[aria-selected] {
background-color: lime;
}
}
}
28 changes: 28 additions & 0 deletions packages/react/list/src/list.stories.tsx
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will add some more stories

Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200vh' }}
>
<List.Root>
<List.Item className={styles.item} id="A">
Option A
</List.Item>
<List.Item className={styles.item} id="B">
Option B
</List.Item>
<List.Item className={styles.item} id="C">
Option C
</List.Item>
<List.Item className={styles.item} id="D">
Option D
</List.Item>
</List.Root>
</div>
);
};
213 changes: 213 additions & 0 deletions packages/react/list/src/list.tsx
Original file line number Diff line number Diff line change
@@ -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> = P & { __scopeList?: Scope };

const [createListContext, createListScope] = createContextScope(LIST_NAME, [
createRovingFocusGroupScope,
]);
const useRovingFocusGroupScope = createRovingFocusGroupScope();

type RovingFocusGroupProps = React.ComponentPropsWithoutRef<typeof RovingFocusGroup.Root>;

type ListContextValue = {
orientation: RovingFocusGroupProps['orientation'];
dir: RovingFocusGroupProps['dir'];
multiselect: boolean;
selectedKeys: Set<string>;
onSelect(key: string): void;
};

const [ListProvider, useListContext] = createListContext<ListContextValue>(LIST_NAME);

type ListElement = React.ElementRef<typeof Primitive.div>;
type ListProps = React.ComponentPropsWithoutRef<typeof Primitive.div> & {
orientation?: RovingFocusGroupProps['orientation'];
loop?: RovingFocusGroupProps['loop'];
dir?: RovingFocusGroupProps['dir'];
multiselect?: boolean;

selectedKeys?: string[];
onSelectedKeysChange?: (selectedKeys: string[]) => void;
defaultSelectedKeys?: string[];
};

const List = React.forwardRef<ListElement, ScopedProps<ListProps>>((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<string[]>({
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 (
<ListProvider
scope={__scopeList}
orientation={orientation}
dir={direction}
multiselect={multiselect}
selectedKeys={selectedKeysSet}
onSelect={handleSelect}
>
<RovingFocusGroup.Root
asChild
{...rovingFocusScope}
orientation={orientation}
dir={direction}
loop={loop}
>
<Primitive.div
ref={forwardedRef}
role="listbox"
aria-multiselectable={multiselect || undefined}
data-orientation={orientation}
{...domProps}
/>
</RovingFocusGroup.Root>
</ListProvider>
);
});
List.displayName = LIST_NAME;

/* -------------------------------------------------------------------------------------------------
* ListItem
* ----------------------------------------------------------------------------------------------- */

type ListItemElement = React.ElementRef<typeof Primitive.div>;
type ListItemProps = React.ComponentPropsWithoutRef<typeof Primitive.div> & {
id: string;
};

const ListItem = React.forwardRef<ListItemElement, ScopedProps<ListItemProps>>(
(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 (
<RovingFocusGroup.Item asChild {...rovingFocusGroupScope}>
<Primitive.div
ref={forwardedRef}
role="option"
aria-selected={isSelected || undefined}
tabIndex={-1}
data-orientation={orientation}
onKeyDown={composeEventHandlers(props.onKeyDown, handleKeyDown)}
onClick={composeEventHandlers(props.onClick, () => onSelect(id))}
{...domProps}
/>
</RovingFocusGroup.Item>
);
}
);
ListItem.displayName = 'ListItem';

/* -------------------------------------------------------------------------------------------------
* ListGroup
* ----------------------------------------------------------------------------------------------- */

type ListGroupElement = React.ElementRef<typeof Primitive.div>;
type ListGroupProps = React.ComponentPropsWithoutRef<typeof Primitive.div>;

const ListGroup = React.forwardRef<ListGroupElement, ScopedProps<ListGroupProps>>(
(props, forwardedRef) => {
const { __scopeList, ...domProps } = props;

return <Primitive.div ref={forwardedRef} role="group" {...domProps} />;
}
);
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 };
31 changes: 31 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down