Skip to content
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
37 changes: 37 additions & 0 deletions packages/components/src/tailwind/DndListBox/DndListBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as React from 'react';
import DndListBoxWidget from './DndListBox';
import { BinIcon } from '../../components/icons/BinIcon';
import type { Meta, StoryObj } from '@storybook/react';

const meta = {
title: 'Tailwind/DndListBoxWidget',
component: DndListBoxWidget,
parameters: {
layout: 'centered',
backgrounds: { disable: true },
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['neutral', 'primary', 'destructive'],
},
},
args: {
isDisabled: false,
children: 'Button',
accent: false,
},
} satisfies Meta<typeof DndListBoxWidget>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Neutral: Story = {
render: (args) => (
<div className="flex gap-8">
<DndListBoxWidget {...args} />
</div>
),
args: {},
};
131 changes: 131 additions & 0 deletions packages/components/src/tailwind/DndListBox/DndListBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as React from 'react';
import { isTextDropItem, useDragAndDrop } from 'react-aria-components';
import { ListBox, ListBoxItem } from '../ListBox/ListBox';
import { useListData } from 'react-stately';

interface FileItem {
id: string;
name: string;
type: string;
}

interface DndListBoxProps {
initialItems: FileItem[];
'aria-label': string;
}

function DndListBox(props: DndListBoxProps) {
const list = useListData({
initialItems: props.initialItems,
});

const { dragAndDropHooks } = useDragAndDrop({
// Provide drag data in a custom format as well as plain text.
getItems(keys) {
return [...keys].map((key) => {
const item = list.getItem(key);
return {
'custom-app-type': JSON.stringify(item),
'text/plain': item.name,
};
});
},

// Accept drops with the custom format.
acceptedDragTypes: ['custom-app-type'],

// Ensure items are always moved rather than copied.
getDropOperation: () => 'move',

// Handle drops between items from other lists.
async onInsert(e) {
const processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText('custom-app-type')),
),
);
if (e.target.dropPosition === 'before') {
list.insertBefore(e.target.key, ...processedItems);
} else if (e.target.dropPosition === 'after') {
list.insertAfter(e.target.key, ...processedItems);
}
},

// Handle drops on the collection when empty.
async onRootDrop(e) {
const processedItems = await Promise.all(
e.items
.filter(isTextDropItem)
.map(async (item) =>
JSON.parse(await item.getText('custom-app-type')),
),
);
list.append(...processedItems);
},

// Handle reordering items within the same list.
onReorder(e) {
if (e.target.dropPosition === 'before') {
list.moveBefore(e.target.key, e.keys);
} else if (e.target.dropPosition === 'after') {
list.moveAfter(e.target.key, e.keys);
}
},

// Remove the items from the source list on drop
// if they were moved to a different list.
onDragEnd(e) {
if (e.dropOperation === 'move' && !e.isInternal) {
list.remove(...e.keys);
}
},
});

return (
<ListBox
aria-label={props['aria-label']}
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={list.selectedKeys}
onSelectionChange={list.setSelectedKeys}
items={list.items}
dragAndDropHooks={dragAndDropHooks}
renderEmptyState={() => 'Drop items here'}
>
{(item) => <ListBoxItem>{item.name}</ListBoxItem>}
</ListBox>
);
}

const DndListBoxWidget = () => {
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<DndListBox
initialItems={[
{ id: '1', type: 'file', name: 'Adobe Photoshop' },
{ id: '2', type: 'file', name: 'Adobe XD' },
{ id: '3', type: 'folder', name: 'Documents' },
{ id: '4', type: 'file', name: 'Adobe InDesign' },
{ id: '5', type: 'folder', name: 'Utilities' },
{ id: '6', type: 'file', name: 'Adobe AfterEffects' },
]}
aria-label="First ListBox"
/>
<DndListBox
initialItems={[
{ id: '7', type: 'folder', name: 'Pictures' },
{ id: '8', type: 'file', name: 'Adobe Fresco' },
{ id: '9', type: 'folder', name: 'Apps' },
{ id: '10', type: 'file', name: 'Adobe Illustrator' },
{ id: '11', type: 'file', name: 'Adobe Lightroom' },
{ id: '12', type: 'file', name: 'Adobe Dreamweaver' },
]}
aria-label="Second ListBox"
/>
</div>
);
};

export default DndListBoxWidget;
66 changes: 66 additions & 0 deletions packages/components/src/tailwind/ListBox/ListBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { ListBox, ListBoxItem } from './ListBox';
import { Text } from 'react-aria-components';

const meta = {
title: 'Tailwind/ListBox',
component: ListBox,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof ListBox>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Example: Story = {
render: (args: any) => (
<ListBox aria-label="Ice cream flavor" {...args}>
<ListBoxItem id="chocolate">Chocolate</ListBoxItem>
<ListBoxItem id="mint">Mint</ListBoxItem>
<ListBoxItem id="strawberry">Strawberry</ListBoxItem>
<ListBoxItem id="vanilla">Vanilla</ListBoxItem>
</ListBox>
),
args: {
onAction: null,
selectionMode: 'multiple',
selectionBehavior: 'replace',
},
};

export const Detailed: Story = {
...Example,
render: (args: any) => (
<ListBox aria-label="Ice cream flavor" {...args}>
<ListBoxItem textValue="Read">
<Text className="font-bold" slot="label">
Read
</Text>
<Text slot="description">Read only</Text>
</ListBoxItem>
<ListBoxItem textValue="Write">
<Text className="font-bold" slot="label">
Write
</Text>
<Text slot="description">Read and write only</Text>
</ListBoxItem>
<ListBoxItem textValue="Admin">
<Text className="font-bold" slot="label">
Admin
</Text>
<Text slot="description">Full access</Text>
</ListBoxItem>
</ListBox>
),
};

export const DisabledItems: Story = {
...Example,
args: {
...Example.args,
disabledKeys: ['mint'],
},
};
127 changes: 127 additions & 0 deletions packages/components/src/tailwind/ListBox/ListBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { CheckboxIcon } from '../../components/icons';
import {
ListBox as RACListBox,
ListBoxItem as RACListBoxItem,
type ListBoxProps as RACListBoxProps,
Collection,
Header,
type ListBoxItemProps,
ListBoxSection,
type SectionProps,
composeRenderProps,
} from 'react-aria-components';
import { tv } from 'tailwind-variants';
import { composeTailwindRenderProps, focusRing } from '../utils';

interface ListBoxProps<T>
extends Omit<RACListBoxProps<T>, 'layout' | 'orientation'> {}

export function ListBox<T extends object>({
children,
...props
}: ListBoxProps<T>) {
return (
<RACListBox
{...props}
className={composeTailwindRenderProps(
props.className,
'rounded-lg border border-gray-300 p-1 outline-0',
)}
>
{children}
</RACListBox>
);
}

export const itemStyles = tv({
extend: focusRing,
base: 'group relative flex cursor-default flex-col gap-1 rounded-md px-2.5 py-1.5 text-sm will-change-transform forced-color-adjust-none select-none',
variants: {
isSelected: {
false: 'text-slate-700 -outline-offset-2 hover:bg-slate-200',
true: 'bg-gray-600 text-white -outline-offset-4 outline-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] forced-colors:outline-[HighlightText] [&+[data-selected]]:rounded-t-none [&:has(+[data-selected])]:rounded-b-none',
},
isDisabled: {
true: 'text-slate-300 forced-colors:text-[GrayText]',
},
},
});

export function ListBoxItem(props: ListBoxItemProps) {
const textValue =
props.textValue ||
(typeof props.children === 'string' ? props.children : undefined);
return (
<RACListBoxItem {...props} textValue={textValue} className={itemStyles}>
{composeRenderProps(props.children, (children) => (
<>
{children}
<div className="absolute right-4 bottom-0 left-4 hidden h-px bg-white/20 forced-colors:bg-[HighlightText] [.group[data-selected]:has(+[data-selected])_&]:block" />
</>
))}
</RACListBoxItem>
);
}

export const dropdownItemStyles = tv({
base: 'group flex cursor-default items-center gap-4 rounded-lg py-2 pr-1 pl-3 text-sm outline outline-0 forced-color-adjust-none select-none',
variants: {
isDisabled: {
false: 'text-gray-900',
true: 'text-gray-300 forced-colors:text-[GrayText]',
},
isFocused: {
true: 'bg-blue-600 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]',
},
},
compoundVariants: [
{
isFocused: false,
isOpen: true,
className: 'bg-gray-100',
},
],
});

export function DropdownItem(props: ListBoxItemProps) {
const textValue =
props.textValue ||
(typeof props.children === 'string' ? props.children : undefined);
return (
<RACListBoxItem
{...props}
textValue={textValue}
className={dropdownItemStyles}
>
{composeRenderProps(props.children, (children, { isSelected }) => (
<>
<span className="group-selected:font-semibold flex flex-1 items-center gap-2 truncate font-normal">
{children}
</span>
<span className="flex w-5 items-center">
{isSelected && <CheckboxIcon className="h-4 w-4" />}
</span>
</>
))}
</RACListBoxItem>
);
}

export interface DropdownSectionProps<T> extends SectionProps<T> {
title?: string;
items?: any;
}

export function DropdownSection<T extends object>(
props: DropdownSectionProps<T>,
) {
return (
<ListBoxSection className="after:block after:h-[5px] after:content-[''] first:-mt-[5px]">
<Header className="sticky -top-[5px] z-10 -mx-1 -mt-px truncate border-y border-y-gray-200 bg-gray-100/60 px-4 py-1 text-sm font-semibold text-gray-500 backdrop-blur-md supports-[-moz-appearance:none]:bg-gray-100 dark:border-y-zinc-700 dark:bg-zinc-700/60 dark:text-zinc-300 [&+*]:mt-1">
{props.title}
</Header>
<Collection items={props.items}>{props.children}</Collection>
</ListBoxSection>
);
}