From 94bb83e730d9e1aef31d0a50ffe04c50494c00b3 Mon Sep 17 00:00:00 2001 From: Victor Fernandez de Alba Date: Thu, 22 May 2025 11:03:57 +0200 Subject: [PATCH] WIP listbox --- .../DndListBox/DndListBox.stories.tsx | 37 +++++ .../src/tailwind/DndListBox/DndListBox.tsx | 131 ++++++++++++++++++ .../src/tailwind/ListBox/ListBox.stories.tsx | 66 +++++++++ .../src/tailwind/ListBox/ListBox.tsx | 127 +++++++++++++++++ .../SelectableCardsWidget.tsx | 0 5 files changed, 361 insertions(+) create mode 100644 packages/components/src/tailwind/DndListBox/DndListBox.stories.tsx create mode 100644 packages/components/src/tailwind/DndListBox/DndListBox.tsx create mode 100644 packages/components/src/tailwind/ListBox/ListBox.stories.tsx create mode 100644 packages/components/src/tailwind/ListBox/ListBox.tsx create mode 100644 packages/components/src/tailwind/SelectableCardsWidget/SelectableCardsWidget.tsx diff --git a/packages/components/src/tailwind/DndListBox/DndListBox.stories.tsx b/packages/components/src/tailwind/DndListBox/DndListBox.stories.tsx new file mode 100644 index 00000000000..20daac9d44c --- /dev/null +++ b/packages/components/src/tailwind/DndListBox/DndListBox.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Neutral: Story = { + render: (args) => ( +
+ +
+ ), + args: {}, +}; diff --git a/packages/components/src/tailwind/DndListBox/DndListBox.tsx b/packages/components/src/tailwind/DndListBox/DndListBox.tsx new file mode 100644 index 00000000000..5554edfb592 --- /dev/null +++ b/packages/components/src/tailwind/DndListBox/DndListBox.tsx @@ -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 ( + 'Drop items here'} + > + {(item) => {item.name}} + + ); +} + +const DndListBoxWidget = () => { + return ( +
+ + +
+ ); +}; + +export default DndListBoxWidget; diff --git a/packages/components/src/tailwind/ListBox/ListBox.stories.tsx b/packages/components/src/tailwind/ListBox/ListBox.stories.tsx new file mode 100644 index 00000000000..f45895cd6e7 --- /dev/null +++ b/packages/components/src/tailwind/ListBox/ListBox.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + render: (args: any) => ( + + Chocolate + Mint + Strawberry + Vanilla + + ), + args: { + onAction: null, + selectionMode: 'multiple', + selectionBehavior: 'replace', + }, +}; + +export const Detailed: Story = { + ...Example, + render: (args: any) => ( + + + + Read + + Read only + + + + Write + + Read and write only + + + + Admin + + Full access + + + ), +}; + +export const DisabledItems: Story = { + ...Example, + args: { + ...Example.args, + disabledKeys: ['mint'], + }, +}; diff --git a/packages/components/src/tailwind/ListBox/ListBox.tsx b/packages/components/src/tailwind/ListBox/ListBox.tsx new file mode 100644 index 00000000000..14d2373de09 --- /dev/null +++ b/packages/components/src/tailwind/ListBox/ListBox.tsx @@ -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 + extends Omit, 'layout' | 'orientation'> {} + +export function ListBox({ + children, + ...props +}: ListBoxProps) { + return ( + + {children} + + ); +} + +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 ( + + {composeRenderProps(props.children, (children) => ( + <> + {children} +
+ + ))} + + ); +} + +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 ( + + {composeRenderProps(props.children, (children, { isSelected }) => ( + <> + + {children} + + + {isSelected && } + + + ))} + + ); +} + +export interface DropdownSectionProps extends SectionProps { + title?: string; + items?: any; +} + +export function DropdownSection( + props: DropdownSectionProps, +) { + return ( + +
+ {props.title} +
+ {props.children} +
+ ); +} diff --git a/packages/components/src/tailwind/SelectableCardsWidget/SelectableCardsWidget.tsx b/packages/components/src/tailwind/SelectableCardsWidget/SelectableCardsWidget.tsx new file mode 100644 index 00000000000..e69de29bb2d