From 49f2c6eb9f7a23d484783bc07ab9dce38989a0c6 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 26 Sep 2026 15:57:32 +0200 Subject: [PATCH 1/5] Add `` --- src/components/Button/Button.module.scss | 4 + src/components/Button/Button.module.scss.d.ts | 1 + src/components/Button/Button.tsx | 2 +- .../TransferList/TransferList.module.scss | 95 ++++++++++ .../TransferList.module.scss.d.ts | 9 + .../TransferList/TransferList.stories.tsx | 172 ++++++++++++++++++ src/components/TransferList/TransferList.tsx | 162 +++++++++++++++++ .../TransferList/TransferList.types.ts | 19 ++ .../TransferList/TransferList.utils.ts | 15 ++ .../TransferListButtonItem.module.scss | 12 ++ .../TransferListButtonItem.module.scss.d.ts | 1 + .../TransferListButtonItem.tsx | 26 +++ .../TransferListButtonItem/index.ts | 1 + .../TransferListCheckboxItem.module.scss | 12 ++ .../TransferListCheckboxItem.module.scss.d.ts | 1 + .../TransferListCheckboxItem.tsx | 44 +++++ .../TransferListCheckboxItem/index.ts | 1 + .../TransferListPane.module.scss | 69 +++++++ .../TransferListPane.module.scss.d.ts | 7 + .../TransferListPane/TransferListPane.tsx | 121 ++++++++++++ .../components/TransferListPane/index.ts | 1 + src/components/TransferList/index.ts | 2 + src/index.ts | 1 + src/style/sizes.module.scss | 4 +- src/style/variants.module.scss | 2 +- src/utils/stories/ControlledStory.tsx | 57 ++++++ 26 files changed, 837 insertions(+), 4 deletions(-) create mode 100644 src/components/TransferList/TransferList.module.scss create mode 100644 src/components/TransferList/TransferList.module.scss.d.ts create mode 100644 src/components/TransferList/TransferList.stories.tsx create mode 100644 src/components/TransferList/TransferList.tsx create mode 100644 src/components/TransferList/TransferList.types.ts create mode 100644 src/components/TransferList/TransferList.utils.ts create mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss create mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts create mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx create mode 100644 src/components/TransferList/components/TransferListButtonItem/index.ts create mode 100644 src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss create mode 100644 src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts create mode 100644 src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx create mode 100644 src/components/TransferList/components/TransferListCheckboxItem/index.ts create mode 100644 src/components/TransferList/components/TransferListPane/TransferListPane.module.scss create mode 100644 src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts create mode 100644 src/components/TransferList/components/TransferListPane/TransferListPane.tsx create mode 100644 src/components/TransferList/components/TransferListPane/index.ts create mode 100644 src/components/TransferList/index.ts create mode 100644 src/utils/stories/ControlledStory.tsx diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss index 243bf2f..188adc4 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -15,6 +15,10 @@ line-height: 1rem; white-space: nowrap; + &-text { + padding: 0 0.125rem; + } + &[data-align="start"] { justify-content: start; } diff --git a/src/components/Button/Button.module.scss.d.ts b/src/components/Button/Button.module.scss.d.ts index b51db87..6cdb63b 100644 --- a/src/components/Button/Button.module.scss.d.ts +++ b/src/components/Button/Button.module.scss.d.ts @@ -1 +1,2 @@ export declare const button: string; +export declare const buttonText: string; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 7e3546b..4c3cb39 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -80,7 +80,7 @@ export const Button = forwardRef )} {text && ( - {text} + {text} )} ); diff --git a/src/components/TransferList/TransferList.module.scss b/src/components/TransferList/TransferList.module.scss new file mode 100644 index 0000000..3789e45 --- /dev/null +++ b/src/components/TransferList/TransferList.module.scss @@ -0,0 +1,95 @@ +@use "../../style/layers"; +@use "../../style/flex"; +@use "../../style/text"; + +@layer components { + .transfer-list { + display: grid; + gap: 0.5rem; + + &[data-orientation="vertical"] { + grid-template-areas: + "available_label" + "available_list" + "selected_label" + "selected_list"; + grid-template-columns: 100%; + grid-template-rows: auto 1fr auto 1fr; + + &[data-batch] { + grid-template-areas: + "available_label" + "available_list" + "controls" + "selected_label" + "selected_list"; + grid-template-rows: auto 1fr auto auto 1fr; + } + + .transfer-list-controls { + @include flex.row($gap: 0.5rem, $xAlign: center, $yAlign: center); + } + } + + &[data-orientation="horizontal"] { + grid-template-areas: + "available_label selected_label" + "available_list selected_list"; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + + &[data-batch] { + grid-template-areas: + "available_label . selected_label" + "available_list controls selected_list"; + grid-template-columns: 1fr auto 1fr; + } + + .transfer-list-controls { + @include flex.column($gap: 0.5rem, $xAlign: center, $yAlign: center); + } + } + + &[data-disabled] { + opacity: 0.6; + } + + &-available-label { + grid-area: available_label; + } + + &-available-list { + grid-area: available_list; + } + + &-controls { + grid-area: controls; + } + + &-selected-label { + grid-area: selected_label; + } + + &-selected-list { + grid-area: selected_list; + } + + &-header { + display: grid; + grid-template-columns: 1fr auto; + flex-shrink: 0; + gap: 0.5rem; + + line-height: 1rem; + } + + &-label { + @include text.ui($bold: true); + @include text.single-line; + } + + &-count { + @include text.ui($bold: true); + } + } +} diff --git a/src/components/TransferList/TransferList.module.scss.d.ts b/src/components/TransferList/TransferList.module.scss.d.ts new file mode 100644 index 0000000..931ecf4 --- /dev/null +++ b/src/components/TransferList/TransferList.module.scss.d.ts @@ -0,0 +1,9 @@ +export declare const transferList: string; +export declare const transferListAvailableLabel: string; +export declare const transferListAvailableList: string; +export declare const transferListControls: string; +export declare const transferListCount: string; +export declare const transferListHeader: string; +export declare const transferListLabel: string; +export declare const transferListSelectedLabel: string; +export declare const transferListSelectedList: string; diff --git a/src/components/TransferList/TransferList.stories.tsx b/src/components/TransferList/TransferList.stories.tsx new file mode 100644 index 0000000..c6102dd --- /dev/null +++ b/src/components/TransferList/TransferList.stories.tsx @@ -0,0 +1,172 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ControlledStory } from '../../utils/stories/ControlledStory'; +import { UserOption } from '../Select/stories/UserOption'; +import { TransferList } from './TransferList'; + +const meta: Meta = { + title: 'Components/TransferList', + component: TransferList, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + disabled: { + control: 'boolean', + description: 'Whether the component is disabled.', + table: { category: 'Behavior' }, + }, + batch: { + control: 'boolean', + description: 'Renders checkboxes and center arrow buttons for moving multiple items at once, instead of moving items on click.', + table: { category: 'Behavior' }, + }, + availableLabel: { + control: 'text', + description: 'The label for the available (left) pane.', + table: { category: 'Content' }, + }, + selectedLabel: { + control: 'text', + description: 'The label for the selected (right) pane.', + table: { category: 'Content' }, + }, + searchPlaceholder: { + control: 'text', + description: 'The placeholder text for each pane\'s search input.', + table: { category: 'Content' }, + }, + className: { table: { disable: true } }, + options: { table: { disable: true } }, + renderItem: { table: { disable: true } }, + value: { table: { disable: true } }, + onChange: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +const fruitOptions = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Coconut', + 'Cranberry', + 'Date', + 'Dragonfruit', + 'Durian', + 'Elderberry', + 'Fig', + 'Grape', + 'Grapefruit', + 'Guava', + 'Honeydew', + 'Jackfruit', + 'Kiwi', + 'Kumquat', + 'Lemon', + 'Lime', + 'Lychee', + 'Mandarin', + 'Mango', + 'Mulberry', + 'Nectarine', + 'Orange', + 'Papaya', + 'Peach', + 'Pear', + 'Persimmon', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Quince', + 'Raspberry', + 'Star Fruit', + 'Strawberry', + 'Tangerine', + 'Watermelon', +].map((label) => ({ value: label.toLowerCase().replace(/\s+/g, '-'), label })); + +export const Default: Story = { + name: 'Default', + args: { + disabled: false, + batch: false, + options: fruitOptions, + defaultValue: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + }, +}; + +export const Batch: Story = { + name: 'Batch', + args: { + disabled: false, + batch: true, + options: fruitOptions, + defaultValue: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + }, +}; + +const userOptions = [ + 'Alice A.', + 'Bob B.', + 'Charlie C.', + 'Dana D.', + 'Eve E.', + 'Frank F.', + 'Grace G.', + 'Heidi H.', +].map((name) => ({ + value: name.toLowerCase().replace(/\W+/g, '-'), + label: name, +})); + +export const CustomItems: Story = { + name: 'Custom Items', + args: { + disabled: false, + batch: false, + options: userOptions, + defaultValue: ['bob-b-', 'eve-e-'], + availableLabel: 'Users', + selectedLabel: 'Invited', + searchPlaceholder: 'Search users...', + renderItem: (item) => ( + + ), + }, +}; + +export const Controlled: Story = { + name: 'Controlled', + args: { + disabled: false, + batch: false, + }, + render: (args) => ( + (value.length ? value.join(', ') : 'none')} + actions={[ + { label: 'Select Cherry, Kiwi, Papaya', value: ['cherry', 'kiwi', 'papaya'] }, + { label: 'Clear', value: [] }, + ]} + /> + ), +}; diff --git a/src/components/TransferList/TransferList.tsx b/src/components/TransferList/TransferList.tsx new file mode 100644 index 0000000..3f53b89 --- /dev/null +++ b/src/components/TransferList/TransferList.tsx @@ -0,0 +1,162 @@ +import { ReactNode, useState } from 'react'; +import clsx from 'clsx'; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from 'lucide-react'; + +import { TransferListPane } from './components/TransferListPane'; +import { Button } from '../Button'; +import { TransferListItem, TransferListItemState } from './TransferList.types'; + +import styles from './TransferList.module.scss'; + +export interface TransferListProps { + availableLabel?: ReactNode; + batch?: boolean; + className?: string; + defaultValue?: string[]; + disabled?: boolean; + onChange?: (values: string[]) => void; + options: TransferListItem[]; + renderItem?: (item: TransferListItem, state: TransferListItemState) => ReactNode; + searchPlaceholder?: string; + selectedLabel?: ReactNode; + value?: string[]; +} + +export const TransferList = ({ + availableLabel = 'Available', + batch = false, + className, + defaultValue, + disabled = false, + onChange, + options, + renderItem, + searchPlaceholder = 'Filter...', + selectedLabel = 'Selected', + value: controlledValue, +}: TransferListProps): JSX.Element => { + const [value, setValue] = useState(controlledValue ?? defaultValue ?? []); + + const [stagedAvailable, setStagedAvailable] = useState([]); + const [stagedSelected, setStagedSelected] = useState([]); + + const handleChange = (next: string[]) => { + if (value === undefined) { + setValue(next); + } + onChange?.(next); + }; + + const availableOptions = options.filter((option) => !value.includes(option.value)); + const selectedOptions = value + .map((v) => options.find((option) => option.value === v)) + .filter((option) => option !== undefined); + + const checkedAvailable = stagedAvailable.filter((v) => availableOptions.some((o) => o.value === v)); + const checkedSelected = stagedSelected.filter((v) => selectedOptions.some((o) => o.value === v)); + + const moveToSelected = (values: string[]) => { + handleChange([...value, ...values.filter((v) => !value.includes(v))]); + setStagedAvailable([]); + }; + + const moveToAvailable = (values: string[]) => { + handleChange(value.filter((v) => !values.includes(v))); + setStagedSelected([]); + }; + + return ( +
+ + + {batch && ( +
+
+ )} + + +
+ ); +}; + +TransferList.displayName = 'TransferList'; diff --git a/src/components/TransferList/TransferList.types.ts b/src/components/TransferList/TransferList.types.ts new file mode 100644 index 0000000..74d8517 --- /dev/null +++ b/src/components/TransferList/TransferList.types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +export type TransferListItem = { + value: string; + label?: ReactNode; + disabled?: boolean; +}; + +export type TransferListItemState = { + checked?: boolean; + disabled?: boolean; +}; + +export type TransferListItemProps = { + disabled?: boolean; + value: string; + onClick?: () => void; + children?: ReactNode; +}; diff --git a/src/components/TransferList/TransferList.utils.ts b/src/components/TransferList/TransferList.utils.ts new file mode 100644 index 0000000..9102df7 --- /dev/null +++ b/src/components/TransferList/TransferList.utils.ts @@ -0,0 +1,15 @@ +import { TransferListItem } from './TransferList.types'; + +export const matchesFilter = (item: TransferListItem, filter?: string): boolean => { + const normalizedFilter = (filter ?? '').trim().toLowerCase(); + if (!normalizedFilter.length) { + return true; + } + if (typeof item.label === 'string' && item.label.trim().toLowerCase().includes(normalizedFilter)) { + return true; + } + if (item.value.trim().toLowerCase().includes(normalizedFilter)) { + return true; + } + return false; +}; diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss new file mode 100644 index 0000000..2b16518 --- /dev/null +++ b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss @@ -0,0 +1,12 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; +@use "../../../../style/text"; + +@layer components { + .transfer-list-button-item { + @include text.ui; + + width: 100%; + text-align: left; + } +} diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts new file mode 100644 index 0000000..ade0d76 --- /dev/null +++ b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts @@ -0,0 +1 @@ +export declare const transferListButtonItem: string; diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx new file mode 100644 index 0000000..dffc70a --- /dev/null +++ b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx @@ -0,0 +1,26 @@ +import { sx } from '../../../../utils'; +import { TransferListItemProps } from '../../TransferList.types'; + +import styles from './TransferListButtonItem.module.scss'; + +export const TransferListButtonItem = ({ + children, + disabled = false, + onClick, + value, +}: TransferListItemProps): JSX.Element => ( + +); + +TransferListButtonItem.displayName = 'TransferListButtonItem'; diff --git a/src/components/TransferList/components/TransferListButtonItem/index.ts b/src/components/TransferList/components/TransferListButtonItem/index.ts new file mode 100644 index 0000000..44874c6 --- /dev/null +++ b/src/components/TransferList/components/TransferListButtonItem/index.ts @@ -0,0 +1 @@ +export * from './TransferListButtonItem'; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss new file mode 100644 index 0000000..89e6183 --- /dev/null +++ b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss @@ -0,0 +1,12 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; +@use "../../../../style/text"; + +@layer components { + .transfer-list-checkbox-item { + @include text.ui; + @include flex.row($gap: 0.5rem, $yAlign: center); + + width: 100%; + } +} diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts new file mode 100644 index 0000000..3731a67 --- /dev/null +++ b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts @@ -0,0 +1 @@ +export declare const transferListCheckboxItem: string; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx new file mode 100644 index 0000000..2fe0b9a --- /dev/null +++ b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx @@ -0,0 +1,44 @@ +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; +import clsx from 'clsx'; +import { Check } from 'lucide-react'; + +import { sx } from '../../../../utils'; +import { TransferListItemProps } from '../../TransferList.types'; + +import checkboxStyles from '../../../Checkbox/Checkbox.module.scss'; +import styles from './TransferListCheckboxItem.module.scss'; + +export const TransferListCheckboxItem = ({ + children, + disabled = false, + value, +}: TransferListItemProps): JSX.Element => ( + +); + +TransferListCheckboxItem.displayName = 'TransferListCheckboxItem'; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/index.ts b/src/components/TransferList/components/TransferListCheckboxItem/index.ts new file mode 100644 index 0000000..515cc1f --- /dev/null +++ b/src/components/TransferList/components/TransferListCheckboxItem/index.ts @@ -0,0 +1 @@ +export * from './TransferListCheckboxItem'; diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss b/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss new file mode 100644 index 0000000..546eba2 --- /dev/null +++ b/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss @@ -0,0 +1,69 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; +@use "../../../../style/text"; + +@layer components { + .transfer-list-pane { + display: grid; + grid-template-rows: auto 16rem; + flex: 1 1 0; + min-width: 0; + + &-search input { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + &-selection { + &-panel { + overflow: hidden; + height: var(--collapsible-panel-height); + transition: height 150ms ease-in-out; + + &[data-starting-style], + &[data-ending-style] { + height: 0; + } + } + + &-content { + @include text.small($muted: true); + @include text.single-line; + + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + + box-sizing: border-box; + padding: calc(0.75rem - var(--border-width)); + padding-left: calc(1.125rem - var(--border-width)); + border-bottom: var(--border-width) solid var(--color-ghost-secondary-border); + } + } + + &-body { + display: grid; + grid-template-rows: auto 1fr; + + box-sizing: border-box; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &-items { + @include flex.column($gap: 0.25rem); + + padding: calc(0.5rem - var(--border-width)); + } + + &-empty-state { + @include flex.centered($gap: 0); + @include text.small($muted: true); + + height: 100%; + padding: 1rem; + text-align: center; + } + } +} diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts b/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts new file mode 100644 index 0000000..6018ba7 --- /dev/null +++ b/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts @@ -0,0 +1,7 @@ +export declare const transferListPane: string; +export declare const transferListPaneBody: string; +export declare const transferListPaneEmptyState: string; +export declare const transferListPaneItems: string; +export declare const transferListPaneSearch: string; +export declare const transferListPaneSelectionContent: string; +export declare const transferListPaneSelectionPanel: string; diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.tsx b/src/components/TransferList/components/TransferListPane/TransferListPane.tsx new file mode 100644 index 0000000..e1562ee --- /dev/null +++ b/src/components/TransferList/components/TransferListPane/TransferListPane.tsx @@ -0,0 +1,121 @@ +import { ReactNode, useState } from 'react'; +import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'; +import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'; +import clsx from 'clsx'; +import { Search, X } from 'lucide-react'; + +import { sx } from '../../../../utils/getStyleClassNames'; +import { Button } from '../../../Button'; +import { InputText } from '../../../InputText'; +import { ScrollArea } from '../../../ScrollArea'; +import { + TransferListItem, + TransferListItemProps, + TransferListItemState, +} from '../../TransferList.types'; +import { matchesFilter } from '../../TransferList.utils'; +import { TransferListButtonItem } from '../TransferListButtonItem'; +import { TransferListCheckboxItem } from '../TransferListCheckboxItem'; + +import styles from './TransferListPane.module.scss'; + +export interface TransferListPaneProps { + className?: string; + batch: boolean; + checked: string[]; + disabled?: boolean; + emptyMessage?: string; + onChange: (value: string[]) => void; + options: TransferListItem[]; + renderItem?: (item: TransferListItem, state: TransferListItemState) => ReactNode; + searchPlaceholder?: string; +} + +export const TransferListPane = ({ + className, + batch, + checked, + disabled = false, + emptyMessage = 'No items.', + onChange, + options, + renderItem, + searchPlaceholder = 'Filter...', +}: TransferListPaneProps): JSX.Element => { + const [filter, setFilter] = useState(''); + const items: TransferListItemProps[] = options.flatMap((item) => { + if (!matchesFilter(item, filter)) { + return []; + } + const state: TransferListItemState = { + checked: batch ? checked.includes(item.value) : undefined, + disabled: disabled || !!item.disabled, + }; + return [{ + children: renderItem ? renderItem(item, state) : item.label, + disabled: state.disabled, + onClick: !batch ? () => onChange([item.value]) : undefined, + value: item.value, + }]; + }); + return ( +
+ } + value={filter} + onChange={(e) => setFilter(e.target.value)} + placeholder={searchPlaceholder} + disabled={disabled} + /> +
+ 0}> + +
+ {checked.length} selected +
+
+
+ + {items.length === 0 ? ( +
+ {emptyMessage} +
+ ) : batch ? ( + + {items.map((item) => ( + + ))} + + ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +}; + +TransferListPane.displayName = 'TransferListPane'; diff --git a/src/components/TransferList/components/TransferListPane/index.ts b/src/components/TransferList/components/TransferListPane/index.ts new file mode 100644 index 0000000..589a0c9 --- /dev/null +++ b/src/components/TransferList/components/TransferListPane/index.ts @@ -0,0 +1 @@ +export * from './TransferListPane'; diff --git a/src/components/TransferList/index.ts b/src/components/TransferList/index.ts new file mode 100644 index 0000000..873c52b --- /dev/null +++ b/src/components/TransferList/index.ts @@ -0,0 +1,2 @@ +export * from './TransferList'; +export * from './TransferList.types'; diff --git a/src/index.ts b/src/index.ts index 661917d..9843d98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,5 +38,6 @@ export * from './components/Tabs'; export * from './components/Tag'; export * from './components/ThemeProvider'; export * from './components/ToggleGroup'; +export * from './components/TransferList'; export * from './types'; export * from './utils'; diff --git a/src/style/sizes.module.scss b/src/style/sizes.module.scss index e45cb60..248d93c 100644 --- a/src/style/sizes.module.scss +++ b/src/style/sizes.module.scss @@ -4,7 +4,7 @@ @mixin common($size: normal) { // Assume always used with variants (1px border, even if invisible) $padding-y: calc(var(--size-#{$size}-padding) - var(--border-width)); - $padding-x: calc(var(--size-#{$size}-padding) + 0.125rem - var(--border-width)); + $padding-x: calc(var(--size-#{$size}-padding) - var(--border-width)); position: relative; @@ -16,7 +16,7 @@ @include hit-area.min-hit-area(var(--size-#{$size})); &.collapse-padding { - margin: 0 calc(var(--size-#{$size}-padding) * -1); + margin: calc(($padding-y + var(--border-width)) * -1) calc(($padding-x + var(--border-width)) * -1); } &.square { diff --git a/src/style/variants.module.scss b/src/style/variants.module.scss index fe21c74..60d091a 100644 --- a/src/style/variants.module.scss +++ b/src/style/variants.module.scss @@ -28,7 +28,7 @@ $sides: "top", "right", "bottom", "left"; opacity: 0.4; } - &:not(:disabled, [data-disabled], [data-readonly], input, textarea) { + &:not(:disabled, [data-disabled], [data-readonly]) { cursor: pointer; user-select: none; diff --git a/src/utils/stories/ControlledStory.tsx b/src/utils/stories/ControlledStory.tsx new file mode 100644 index 0000000..a36c262 --- /dev/null +++ b/src/utils/stories/ControlledStory.tsx @@ -0,0 +1,57 @@ +import { + ComponentType, + ReactNode, + useState, +} from 'react'; + +import { Button } from '../../components/Button'; + +export interface ControlledStoryAction { + label: string; + value: TValue; +} + +export interface ControlledStoryProps< + TValue, + TProps extends { value?: TValue; onChange?: (value: TValue) => void }, +> { + actions: ControlledStoryAction[]; + component: ComponentType; + initialValue: TValue; + props: Omit; + renderValue?: (value: TValue) => ReactNode; +} + +/** + * Generic Storybook wrapper for demonstrating a controlled component: renders the component with + * externally-managed state, the current value, and a row of buttons to set that value + * programmatically. + */ +export const ControlledStory = < + TValue, + TProps extends { value?: TValue; onChange?: (value: TValue) => void }, +>({ + actions, + component: Component, + initialValue, + props, + renderValue = (value) => JSON.stringify(value), +}: ControlledStoryProps): JSX.Element => { + const [value, setValue] = useState(initialValue); + + return ( +
+ + Current value: {renderValue(value)} +
+ {actions.map((action) => ( +
+
+ ); +}; From f6ead5c3060dc616558b73f7c8087cde7dcaf64f Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Sat, 26 Sep 2026 18:31:35 +0200 Subject: [PATCH 2/5] Total Rework --- .../InputText/InputText.module.scss | 6 + .../ScrollArea/ScrollArea.module.scss | 1 + src/components/ScrollArea/ScrollArea.tsx | 21 +- .../TransferList/TransferList.module.scss | 34 --- .../TransferList.module.scss.d.ts | 4 - .../TransferList/TransferList.stories.tsx | 85 ++++---- src/components/TransferList/TransferList.tsx | 194 +++++++----------- .../TransferList/TransferList.types.ts | 14 +- .../TransferList/TransferList.utils.ts | 30 ++- .../TransferListButtonItem.module.scss | 12 -- .../TransferListButtonItem.module.scss.d.ts | 1 - .../TransferListButtonItem.tsx | 26 --- .../TransferListButtonItem/index.ts | 1 - .../TransferListCheckboxItem.module.scss.d.ts | 1 - .../TransferListCheckboxItem/index.ts | 1 - .../TransferListControls.module.scss | 8 + .../TransferListControls.module.scss.d.ts | 1 + .../TransferListControls.tsx | 58 ++++++ .../components/TransferListControls/index.ts | 1 + .../TransferListGroup.module.scss} | 44 +++- .../TransferListGroup.module.scss.d.ts | 12 ++ .../TransferListGroup/TransferListGroup.tsx | 132 ++++++++++++ .../components/TransferListGroup/index.ts | 1 + .../TransferListItem.module.scss} | 2 +- .../TransferListItem.module.scss.d.ts | 1 + .../TransferListItem.tsx} | 8 +- .../components/TransferListItem/index.ts | 1 + .../TransferListPane.module.scss.d.ts | 7 - .../TransferListPane/TransferListPane.tsx | 121 ----------- .../components/TransferListPane/index.ts | 1 - src/style/variants.module.scss | 21 +- src/utils/stories/ControlledStory.tsx | 18 +- 32 files changed, 473 insertions(+), 395 deletions(-) delete mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss delete mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts delete mode 100644 src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx delete mode 100644 src/components/TransferList/components/TransferListButtonItem/index.ts delete mode 100644 src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts delete mode 100644 src/components/TransferList/components/TransferListCheckboxItem/index.ts create mode 100644 src/components/TransferList/components/TransferListControls/TransferListControls.module.scss create mode 100644 src/components/TransferList/components/TransferListControls/TransferListControls.module.scss.d.ts create mode 100644 src/components/TransferList/components/TransferListControls/TransferListControls.tsx create mode 100644 src/components/TransferList/components/TransferListControls/index.ts rename src/components/TransferList/components/{TransferListPane/TransferListPane.module.scss => TransferListGroup/TransferListGroup.module.scss} (64%) create mode 100644 src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss.d.ts create mode 100644 src/components/TransferList/components/TransferListGroup/TransferListGroup.tsx create mode 100644 src/components/TransferList/components/TransferListGroup/index.ts rename src/components/TransferList/components/{TransferListCheckboxItem/TransferListCheckboxItem.module.scss => TransferListItem/TransferListItem.module.scss} (86%) create mode 100644 src/components/TransferList/components/TransferListItem/TransferListItem.module.scss.d.ts rename src/components/TransferList/components/{TransferListCheckboxItem/TransferListCheckboxItem.tsx => TransferListItem/TransferListItem.tsx} (82%) create mode 100644 src/components/TransferList/components/TransferListItem/index.ts delete mode 100644 src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts delete mode 100644 src/components/TransferList/components/TransferListPane/TransferListPane.tsx delete mode 100644 src/components/TransferList/components/TransferListPane/index.ts diff --git a/src/components/InputText/InputText.module.scss b/src/components/InputText/InputText.module.scss index b2e9748..9bfce09 100644 --- a/src/components/InputText/InputText.module.scss +++ b/src/components/InputText/InputText.module.scss @@ -21,12 +21,18 @@ width: 2.5rem; height: 2.5rem; + color: var(--color-text-muted); + svg { width: 1rem; height: 1rem; } } + &:has(input:disabled) .icon-wrapper { + opacity: 0.6; + } + input { width: 100%; height: 100%; diff --git a/src/components/ScrollArea/ScrollArea.module.scss b/src/components/ScrollArea/ScrollArea.module.scss index dc1835e..8e798ef 100644 --- a/src/components/ScrollArea/ScrollArea.module.scss +++ b/src/components/ScrollArea/ScrollArea.module.scss @@ -12,6 +12,7 @@ overflow: hidden; display: flex; + box-sizing: border-box; width: 100%; height: 100%; diff --git a/src/components/ScrollArea/ScrollArea.tsx b/src/components/ScrollArea/ScrollArea.tsx index 2a031f2..8e773e5 100644 --- a/src/components/ScrollArea/ScrollArea.tsx +++ b/src/components/ScrollArea/ScrollArea.tsx @@ -12,12 +12,14 @@ import { getStyleClassNames } from '../../utils/getStyleClassNames'; import styles from './ScrollArea.module.scss'; type ScrollAreaProps = ComponentPropsWithoutRef<'div'> & { + disabled?: boolean; offset?: Partial>; }; export const ScrollArea = forwardRef(({ className, children, + disabled = false, onScroll, offset, style, @@ -33,6 +35,7 @@ export const ScrollArea = forwardRef(({ '--scroll-area-offset-left': getCssValue(offset?.left), '--scroll-area-offset-right': getCssValue(offset?.right), } as CSSProperties} + data-disabled={disabled || undefined} {...props} > @@ -40,13 +43,17 @@ export const ScrollArea = forwardRef(({ {children} - - - - - - - + {!disabled && ( + <> + + + + + + + + + )} )); diff --git a/src/components/TransferList/TransferList.module.scss b/src/components/TransferList/TransferList.module.scss index 3789e45..822e71c 100644 --- a/src/components/TransferList/TransferList.module.scss +++ b/src/components/TransferList/TransferList.module.scss @@ -25,10 +25,6 @@ "selected_list"; grid-template-rows: auto 1fr auto auto 1fr; } - - .transfer-list-controls { - @include flex.row($gap: 0.5rem, $xAlign: center, $yAlign: center); - } } &[data-orientation="horizontal"] { @@ -44,14 +40,6 @@ "available_list controls selected_list"; grid-template-columns: 1fr auto 1fr; } - - .transfer-list-controls { - @include flex.column($gap: 0.5rem, $xAlign: center, $yAlign: center); - } - } - - &[data-disabled] { - opacity: 0.6; } &-available-label { @@ -62,10 +50,6 @@ grid-area: available_list; } - &-controls { - grid-area: controls; - } - &-selected-label { grid-area: selected_label; } @@ -73,23 +57,5 @@ &-selected-list { grid-area: selected_list; } - - &-header { - display: grid; - grid-template-columns: 1fr auto; - flex-shrink: 0; - gap: 0.5rem; - - line-height: 1rem; - } - - &-label { - @include text.ui($bold: true); - @include text.single-line; - } - - &-count { - @include text.ui($bold: true); - } } } diff --git a/src/components/TransferList/TransferList.module.scss.d.ts b/src/components/TransferList/TransferList.module.scss.d.ts index 931ecf4..2a46afe 100644 --- a/src/components/TransferList/TransferList.module.scss.d.ts +++ b/src/components/TransferList/TransferList.module.scss.d.ts @@ -1,9 +1,5 @@ export declare const transferList: string; export declare const transferListAvailableLabel: string; export declare const transferListAvailableList: string; -export declare const transferListControls: string; -export declare const transferListCount: string; -export declare const transferListHeader: string; -export declare const transferListLabel: string; export declare const transferListSelectedLabel: string; export declare const transferListSelectedList: string; diff --git a/src/components/TransferList/TransferList.stories.tsx b/src/components/TransferList/TransferList.stories.tsx index c6102dd..e7ec86b 100644 --- a/src/components/TransferList/TransferList.stories.tsx +++ b/src/components/TransferList/TransferList.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ControlledStory } from '../../utils/stories/ControlledStory'; import { UserOption } from '../Select/stories/UserOption'; -import { TransferList } from './TransferList'; +import { TransferList, TransferListProps } from './TransferList'; +import { TransferListValue } from './TransferList.types'; const meta: Meta = { title: 'Components/TransferList', @@ -24,28 +25,14 @@ const meta: Meta = { description: 'Whether the component is disabled.', table: { category: 'Behavior' }, }, - batch: { - control: 'boolean', - description: 'Renders checkboxes and center arrow buttons for moving multiple items at once, instead of moving items on click.', - table: { category: 'Behavior' }, - }, - availableLabel: { - control: 'text', - description: 'The label for the available (left) pane.', - table: { category: 'Content' }, - }, - selectedLabel: { - control: 'text', - description: 'The label for the selected (right) pane.', - table: { category: 'Content' }, - }, searchPlaceholder: { control: 'text', description: 'The placeholder text for each pane\'s search input.', table: { category: 'Content' }, }, className: { table: { disable: true } }, - options: { table: { disable: true } }, + groups: { table: { disable: true } }, + items: { table: { disable: true } }, renderItem: { table: { disable: true } }, value: { table: { disable: true } }, onChange: { table: { disable: true } }, @@ -55,7 +42,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const fruitOptions = [ +const fruitItems = [ 'Apple', 'Apricot', 'Banana', @@ -101,27 +88,43 @@ const fruitOptions = [ 'Watermelon', ].map((label) => ({ value: label.toLowerCase().replace(/\s+/g, '-'), label })); +const twoGroups = [ + { key: 'available', title: 'Available' }, + { key: 'selected', title: 'Selected' }, +]; + +const threeGroups = [ + { key: 'available', title: 'Available' }, + { key: 'shortlist', title: 'Shortlist' }, + { key: 'selected', title: 'Selected' }, +]; + export const Default: Story = { name: 'Default', args: { disabled: false, - batch: false, - options: fruitOptions, - defaultValue: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + groups: twoGroups, + items: fruitItems, + defaultValue: { + selected: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + }, }, }; -export const Batch: Story = { - name: 'Batch', +export const ThreeLists: Story = { + name: 'Three Lists', args: { disabled: false, - batch: true, - options: fruitOptions, - defaultValue: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + groups: threeGroups, + items: fruitItems, + defaultValue: { + shortlist: ['banana', 'fig'], + selected: ['mango', 'pear'], + }, }, }; -const userOptions = [ +const userItems = [ 'Alice A.', 'Bob B.', 'Charlie C.', @@ -139,11 +142,12 @@ export const CustomItems: Story = { name: 'Custom Items', args: { disabled: false, - batch: false, - options: userOptions, - defaultValue: ['bob-b-', 'eve-e-'], - availableLabel: 'Users', - selectedLabel: 'Invited', + groups: [ + { key: 'users', title: 'Users' }, + { key: 'invited', title: 'Invited' }, + ], + items: userItems, + defaultValue: { invited: ['bob-b-', 'eve-e-'] }, searchPlaceholder: 'Search users...', renderItem: (item) => ( @@ -151,21 +155,24 @@ export const CustomItems: Story = { }, }; +const getFruitValue = (selected: string[]): TransferListValue => ({ + available: fruitItems.map((o) => o.value).filter((v) => !selected.includes(v)), + selected, +}); + export const Controlled: Story = { name: 'Controlled', args: { disabled: false, - batch: false, }, render: (args) => ( - component={TransferList} - props={{ ...args, options: fruitOptions }} - initialValue={['cherry', 'kiwi', 'papaya']} - renderValue={(value) => (value.length ? value.join(', ') : 'none')} + props={{ ...args, groups: twoGroups, items: fruitItems }} + initialValue={getFruitValue(['cherry', 'kiwi', 'papaya'])} actions={[ - { label: 'Select Cherry, Kiwi, Papaya', value: ['cherry', 'kiwi', 'papaya'] }, - { label: 'Clear', value: [] }, + { label: 'Move Cherry, Kiwi, Papaya to Selected', value: getFruitValue(['cherry', 'kiwi', 'papaya']) }, + { label: 'Move All to Available', value: getFruitValue([]) }, ]} /> ), diff --git a/src/components/TransferList/TransferList.tsx b/src/components/TransferList/TransferList.tsx index 3f53b89..34fcf50 100644 --- a/src/components/TransferList/TransferList.tsx +++ b/src/components/TransferList/TransferList.tsx @@ -1,160 +1,112 @@ -import { ReactNode, useState } from 'react'; -import clsx from 'clsx'; import { - ChevronLeft, - ChevronRight, - ChevronsLeft, - ChevronsRight, -} from 'lucide-react'; + Fragment, + ReactNode, + useState, +} from 'react'; +import clsx from 'clsx'; -import { TransferListPane } from './components/TransferListPane'; -import { Button } from '../Button'; -import { TransferListItem, TransferListItemState } from './TransferList.types'; +import { TransferListControls } from './components/TransferListControls'; +import { TransferListGroup } from './components/TransferListGroup'; +import { + TransferListGroupDef, + TransferListItemDef, + TransferListItemState, + TransferListState, + TransferListValue, +} from './TransferList.types'; +import { stateToValue, valueToState } from './TransferList.utils'; import styles from './TransferList.module.scss'; +type ListId = string; +type ItemValue = string; + export interface TransferListProps { - availableLabel?: ReactNode; - batch?: boolean; className?: string; - defaultValue?: string[]; + defaultValue?: TransferListValue; disabled?: boolean; - onChange?: (values: string[]) => void; - options: TransferListItem[]; - renderItem?: (item: TransferListItem, state: TransferListItemState) => ReactNode; + groups: TransferListGroupDef[]; + onChange?: (value: TransferListValue) => void; + items: TransferListItemDef[]; + renderItem?: (item: TransferListItemDef, state: TransferListItemState) => ReactNode; searchPlaceholder?: string; - selectedLabel?: ReactNode; - value?: string[]; + value?: TransferListValue; } export const TransferList = ({ - availableLabel = 'Available', - batch = false, className, defaultValue, disabled = false, + groups, onChange, - options, + items, renderItem, searchPlaceholder = 'Filter...', - selectedLabel = 'Selected', - value: controlledValue, + value, }: TransferListProps): JSX.Element => { - const [value, setValue] = useState(controlledValue ?? defaultValue ?? []); + const [state, setState] = useState(valueToState(value ?? defaultValue ?? {})); + + const [staged, setStaged] = useState>({}); - const [stagedAvailable, setStagedAvailable] = useState([]); - const [stagedSelected, setStagedSelected] = useState([]); + const getListKey = (item: TransferListItemDef): string => ( + groups.some((list) => list.key === state[item.value]) ? state[item.value] : groups[0].key + ); - const handleChange = (next: string[]) => { + const handleChange = (next: TransferListState) => { if (value === undefined) { - setValue(next); + setState(next); } - onChange?.(next); + onChange?.(stateToValue(next, groups, items)); }; - const availableOptions = options.filter((option) => !value.includes(option.value)); - const selectedOptions = value - .map((v) => options.find((option) => option.value === v)) - .filter((option) => option !== undefined); - - const checkedAvailable = stagedAvailable.filter((v) => availableOptions.some((o) => o.value === v)); - const checkedSelected = stagedSelected.filter((v) => selectedOptions.some((o) => o.value === v)); - - const moveToSelected = (values: string[]) => { - handleChange([...value, ...values.filter((v) => !value.includes(v))]); - setStagedAvailable([]); + const moveItems = (values: string[], listKey: string) => { + handleChange({ + ...Object.fromEntries(items.map((item) => [item.value, getListKey(item)])), + ...Object.fromEntries(values.map((v) => [v, listKey])), + }); + setStaged((prev) => Object.fromEntries( + Object.entries(prev).map(([key, checked]) => [ + key, + checked.filter((v) => !values.includes(v)), + ]), + )); }; - const moveToAvailable = (values: string[]) => { - handleChange(value.filter((v) => !values.includes(v))); - setStagedSelected([]); - }; + const getChecked = (listKey: string): string[] => staged[listKey] ?? []; return (
'1fr').join(' auto '), + }} > - - - {batch && ( -
-
- )} - - + + ))}
); }; diff --git a/src/components/TransferList/TransferList.types.ts b/src/components/TransferList/TransferList.types.ts index 74d8517..207ce18 100644 --- a/src/components/TransferList/TransferList.types.ts +++ b/src/components/TransferList/TransferList.types.ts @@ -1,11 +1,23 @@ import { ReactNode } from 'react'; -export type TransferListItem = { +export type TransferListGroupKey = string; +export type TransferListItemValue = string; + +export type TransferListItemDef = { value: string; label?: ReactNode; disabled?: boolean; }; +export type TransferListGroupDef = { + key: TransferListGroupKey; + title: ReactNode; +}; + +export type TransferListState = Record; + +export type TransferListValue = Record; + export type TransferListItemState = { checked?: boolean; disabled?: boolean; diff --git a/src/components/TransferList/TransferList.utils.ts b/src/components/TransferList/TransferList.utils.ts index 9102df7..ba9400d 100644 --- a/src/components/TransferList/TransferList.utils.ts +++ b/src/components/TransferList/TransferList.utils.ts @@ -1,6 +1,32 @@ -import { TransferListItem } from './TransferList.types'; +import { + TransferListGroupDef, + TransferListItemDef, + TransferListState, + TransferListValue, +} from './TransferList.types'; -export const matchesFilter = (item: TransferListItem, filter?: string): boolean => { +export const valueToState = (value: TransferListValue): TransferListState => ( + Object.fromEntries( + Object.entries(value).flatMap(([groupKey, itemValues]) => ( + itemValues.map((itemValue) => [itemValue, groupKey]) + )), + ) +); + +export const stateToValue = ( + state: TransferListState, + groups: TransferListGroupDef[], + options: TransferListItemDef[], +): TransferListValue => ( + Object.fromEntries(groups.map((group) => [ + group.key, + options + .filter((option) => (state[option.value] ?? groups[0].key) === group.key) + .map((option) => option.value), + ])) +); + +export const matchesFilter = (item: TransferListItemDef, filter?: string): boolean => { const normalizedFilter = (filter ?? '').trim().toLowerCase(); if (!normalizedFilter.length) { return true; diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss deleted file mode 100644 index 2b16518..0000000 --- a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use "../../../../style/layers"; -@use "../../../../style/flex"; -@use "../../../../style/text"; - -@layer components { - .transfer-list-button-item { - @include text.ui; - - width: 100%; - text-align: left; - } -} diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts deleted file mode 100644 index ade0d76..0000000 --- a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.module.scss.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const transferListButtonItem: string; diff --git a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx b/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx deleted file mode 100644 index dffc70a..0000000 --- a/src/components/TransferList/components/TransferListButtonItem/TransferListButtonItem.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { sx } from '../../../../utils'; -import { TransferListItemProps } from '../../TransferList.types'; - -import styles from './TransferListButtonItem.module.scss'; - -export const TransferListButtonItem = ({ - children, - disabled = false, - onClick, - value, -}: TransferListItemProps): JSX.Element => ( - -); - -TransferListButtonItem.displayName = 'TransferListButtonItem'; diff --git a/src/components/TransferList/components/TransferListButtonItem/index.ts b/src/components/TransferList/components/TransferListButtonItem/index.ts deleted file mode 100644 index 44874c6..0000000 --- a/src/components/TransferList/components/TransferListButtonItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TransferListButtonItem'; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts b/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts deleted file mode 100644 index 3731a67..0000000 --- a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const transferListCheckboxItem: string; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/index.ts b/src/components/TransferList/components/TransferListCheckboxItem/index.ts deleted file mode 100644 index 515cc1f..0000000 --- a/src/components/TransferList/components/TransferListCheckboxItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TransferListCheckboxItem'; diff --git a/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss new file mode 100644 index 0000000..24a9760 --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss @@ -0,0 +1,8 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; + +@layer components { + .transfer-list-controls { + @include flex.column($gap: 0.5rem, $xAlign: center, $yAlign: center); + } +} diff --git a/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss.d.ts b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss.d.ts new file mode 100644 index 0000000..e10216f --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss.d.ts @@ -0,0 +1 @@ +export declare const transferListControls: string; diff --git a/src/components/TransferList/components/TransferListControls/TransferListControls.tsx b/src/components/TransferList/components/TransferListControls/TransferListControls.tsx new file mode 100644 index 0000000..f4546dd --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/TransferListControls.tsx @@ -0,0 +1,58 @@ +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { Button } from '../../../Button'; +import { TransferListGroupDef } from '../../TransferList.types'; + +import styles from './TransferListControls.module.scss'; + +export interface TransferListControlsProps { + disabled?: boolean; + groups: TransferListGroupDef[]; + index: number; + onMove: (values: string[], groupKey: string) => void; + staged: Record; +} + +export const TransferListControls = ({ + disabled = false, + groups, + index, + onMove, + staged, +}: TransferListControlsProps): JSX.Element | null => { + const group = groups[index]; + const nextGroup = groups[index + 1]; + if (!nextGroup) { + return null; + } + const rightChecked = staged[group.key] ?? []; + const leftChecked = staged[nextGroup.key] ?? []; + const rightDisabled = disabled || rightChecked.length === 0; + const leftDisabled = disabled || leftChecked.length === 0; + return ( +
+
+ ); +}; + +TransferListControls.displayName = 'TransferListControls'; diff --git a/src/components/TransferList/components/TransferListControls/index.ts b/src/components/TransferList/components/TransferListControls/index.ts new file mode 100644 index 0000000..d343e27 --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/index.ts @@ -0,0 +1 @@ +export * from './TransferListControls'; diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss similarity index 64% rename from src/components/TransferList/components/TransferListPane/TransferListPane.module.scss rename to src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss index 546eba2..b553020 100644 --- a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss +++ b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss @@ -3,17 +3,57 @@ @use "../../../../style/text"; @layer components { - .transfer-list-pane { + .transfer-list-group { display: grid; - grid-template-rows: auto 16rem; + grid-template-rows: auto auto auto 16rem; flex: 1 1 0; min-width: 0; + &-header { + display: grid; + grid-template-columns: 1fr auto; + flex-shrink: 0; + gap: 0.5rem; + + margin-bottom: 0.5rem; + + line-height: 1rem; + + &-text { + @include text.ui($bold: true); + @include text.single-line; + } + + &-count { + @include text.ui($bold: true); + } + } + &-search input { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } + &-bulk { + @include text.small; + @include flex.row($gap: 0.5rem, $yAlign: center); + + width: 100%; + padding: 0 calc(1rem - var(--border-width)); + } + + &-scroll-area { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &[data-disabled] { + .transfer-list-group-header { + cursor: not-allowed; + opacity: 0.4; + } + } + &-selection { &-panel { overflow: hidden; diff --git a/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss.d.ts b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss.d.ts new file mode 100644 index 0000000..8a986e7 --- /dev/null +++ b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss.d.ts @@ -0,0 +1,12 @@ +export declare const transferListGroup: string; +export declare const transferListGroupBody: string; +export declare const transferListGroupBulk: string; +export declare const transferListGroupEmptyState: string; +export declare const transferListGroupHeader: string; +export declare const transferListGroupHeaderCount: string; +export declare const transferListGroupHeaderText: string; +export declare const transferListGroupItems: string; +export declare const transferListGroupScrollArea: string; +export declare const transferListGroupSearch: string; +export declare const transferListGroupSelectionContent: string; +export declare const transferListGroupSelectionPanel: string; diff --git a/src/components/TransferList/components/TransferListGroup/TransferListGroup.tsx b/src/components/TransferList/components/TransferListGroup/TransferListGroup.tsx new file mode 100644 index 0000000..c242077 --- /dev/null +++ b/src/components/TransferList/components/TransferListGroup/TransferListGroup.tsx @@ -0,0 +1,132 @@ +import { ReactNode, useState } from 'react'; +import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'; +import clsx from 'clsx'; +import { Search } from 'lucide-react'; + +import { sx } from '../../../../utils/getStyleClassNames'; +import { Checkbox } from '../../../Checkbox'; +import { InputText } from '../../../InputText'; +import { ScrollArea } from '../../../ScrollArea'; +import { + TransferListItemDef, + TransferListItemProps, + TransferListItemState, +} from '../../TransferList.types'; +import { matchesFilter } from '../../TransferList.utils'; +import { TransferListItem } from '../TransferListItem'; + +import styles from './TransferListGroup.module.scss'; + +export interface TransferListGroupProps { + className?: string; + checked: string[]; + disabled?: boolean; + emptyMessage?: string; + onChange: (value: string[]) => void; + items: TransferListItemDef[]; + renderItem?: (item: TransferListItemDef, state: TransferListItemState) => ReactNode; + searchPlaceholder?: string; + title?: ReactNode; +} + +export const TransferListGroup = ({ + className, + checked, + disabled = false, + emptyMessage = 'No items.', + onChange, + items, + renderItem, + searchPlaceholder = 'Filter...', + title, +}: TransferListGroupProps): JSX.Element => { + const [filter, setFilter] = useState(''); + const itemProps: TransferListItemProps[] = items.flatMap((item) => { + if (!matchesFilter(item, filter)) { + return []; + } + const state: TransferListItemState = { + checked: checked.includes(item.value), + disabled: item.disabled, + }; + return [{ + children: renderItem ? renderItem(item, state) : item.label, + disabled: state.disabled, + value: item.value, + }]; + }); + const selectableValues = itemProps.filter((item) => !item.disabled).map((item) => item.value); + const allChecked = selectableValues.length > 0 && selectableValues.every((v) => checked.includes(v)); + const someChecked = selectableValues.some((v) => checked.includes(v)); + const handleSelectAll = (next: boolean) => { + onChange(next ? [ + ...checked, + ...selectableValues.filter((v) => !checked.includes(v)), + ] : checked.filter((v) => !selectableValues.includes(v))); + }; + return ( +
+ + } + value={filter} + onChange={(e) => setFilter(e.target.value)} + placeholder={searchPlaceholder} + disabled={disabled} + /> + + + {itemProps.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + + {itemProps.map((item) => ( + + ))} + + )} +
+
+ ); +}; + +TransferListGroup.displayName = 'TransferListGroup'; diff --git a/src/components/TransferList/components/TransferListGroup/index.ts b/src/components/TransferList/components/TransferListGroup/index.ts new file mode 100644 index 0000000..cb6e3a8 --- /dev/null +++ b/src/components/TransferList/components/TransferListGroup/index.ts @@ -0,0 +1 @@ +export * from './TransferListGroup'; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss similarity index 86% rename from src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss rename to src/components/TransferList/components/TransferListItem/TransferListItem.module.scss index 89e6183..489057a 100644 --- a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.module.scss +++ b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss @@ -3,7 +3,7 @@ @use "../../../../style/text"; @layer components { - .transfer-list-checkbox-item { + .transfer-list-item { @include text.ui; @include flex.row($gap: 0.5rem, $yAlign: center); diff --git a/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss.d.ts b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss.d.ts new file mode 100644 index 0000000..31146c8 --- /dev/null +++ b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss.d.ts @@ -0,0 +1 @@ +export declare const transferListItem: string; diff --git a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx b/src/components/TransferList/components/TransferListItem/TransferListItem.tsx similarity index 82% rename from src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx rename to src/components/TransferList/components/TransferListItem/TransferListItem.tsx index 2fe0b9a..15179e4 100644 --- a/src/components/TransferList/components/TransferListCheckboxItem/TransferListCheckboxItem.tsx +++ b/src/components/TransferList/components/TransferListItem/TransferListItem.tsx @@ -6,9 +6,9 @@ import { sx } from '../../../../utils'; import { TransferListItemProps } from '../../TransferList.types'; import checkboxStyles from '../../../Checkbox/Checkbox.module.scss'; -import styles from './TransferListCheckboxItem.module.scss'; +import styles from './TransferListItem.module.scss'; -export const TransferListCheckboxItem = ({ +export const TransferListItem = ({ children, disabled = false, value, @@ -19,7 +19,7 @@ export const TransferListCheckboxItem = ({ intent: 'secondary', corners: 'tight', size: 'small', - }, styles.transferListCheckboxItem)} + }, styles.transferListItem)} aria-disabled={disabled} role="button" > @@ -41,4 +41,4 @@ export const TransferListCheckboxItem = ({ ); -TransferListCheckboxItem.displayName = 'TransferListCheckboxItem'; +TransferListItem.displayName = 'TransferListItem'; diff --git a/src/components/TransferList/components/TransferListItem/index.ts b/src/components/TransferList/components/TransferListItem/index.ts new file mode 100644 index 0000000..34f438c --- /dev/null +++ b/src/components/TransferList/components/TransferListItem/index.ts @@ -0,0 +1 @@ +export * from './TransferListItem'; diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts b/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts deleted file mode 100644 index 6018ba7..0000000 --- a/src/components/TransferList/components/TransferListPane/TransferListPane.module.scss.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export declare const transferListPane: string; -export declare const transferListPaneBody: string; -export declare const transferListPaneEmptyState: string; -export declare const transferListPaneItems: string; -export declare const transferListPaneSearch: string; -export declare const transferListPaneSelectionContent: string; -export declare const transferListPaneSelectionPanel: string; diff --git a/src/components/TransferList/components/TransferListPane/TransferListPane.tsx b/src/components/TransferList/components/TransferListPane/TransferListPane.tsx deleted file mode 100644 index e1562ee..0000000 --- a/src/components/TransferList/components/TransferListPane/TransferListPane.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { ReactNode, useState } from 'react'; -import { CheckboxGroup as BaseCheckboxGroup } from '@base-ui/react/checkbox-group'; -import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'; -import clsx from 'clsx'; -import { Search, X } from 'lucide-react'; - -import { sx } from '../../../../utils/getStyleClassNames'; -import { Button } from '../../../Button'; -import { InputText } from '../../../InputText'; -import { ScrollArea } from '../../../ScrollArea'; -import { - TransferListItem, - TransferListItemProps, - TransferListItemState, -} from '../../TransferList.types'; -import { matchesFilter } from '../../TransferList.utils'; -import { TransferListButtonItem } from '../TransferListButtonItem'; -import { TransferListCheckboxItem } from '../TransferListCheckboxItem'; - -import styles from './TransferListPane.module.scss'; - -export interface TransferListPaneProps { - className?: string; - batch: boolean; - checked: string[]; - disabled?: boolean; - emptyMessage?: string; - onChange: (value: string[]) => void; - options: TransferListItem[]; - renderItem?: (item: TransferListItem, state: TransferListItemState) => ReactNode; - searchPlaceholder?: string; -} - -export const TransferListPane = ({ - className, - batch, - checked, - disabled = false, - emptyMessage = 'No items.', - onChange, - options, - renderItem, - searchPlaceholder = 'Filter...', -}: TransferListPaneProps): JSX.Element => { - const [filter, setFilter] = useState(''); - const items: TransferListItemProps[] = options.flatMap((item) => { - if (!matchesFilter(item, filter)) { - return []; - } - const state: TransferListItemState = { - checked: batch ? checked.includes(item.value) : undefined, - disabled: disabled || !!item.disabled, - }; - return [{ - children: renderItem ? renderItem(item, state) : item.label, - disabled: state.disabled, - onClick: !batch ? () => onChange([item.value]) : undefined, - value: item.value, - }]; - }); - return ( -
- } - value={filter} - onChange={(e) => setFilter(e.target.value)} - placeholder={searchPlaceholder} - disabled={disabled} - /> -
- 0}> - -
- {checked.length} selected -
-
-
- - {items.length === 0 ? ( -
- {emptyMessage} -
- ) : batch ? ( - - {items.map((item) => ( - - ))} - - ) : ( -
- {items.map((item) => ( - - ))} -
- )} -
-
-
- ); -}; - -TransferListPane.displayName = 'TransferListPane'; diff --git a/src/components/TransferList/components/TransferListPane/index.ts b/src/components/TransferList/components/TransferListPane/index.ts deleted file mode 100644 index 589a0c9..0000000 --- a/src/components/TransferList/components/TransferListPane/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TransferListPane'; diff --git a/src/style/variants.module.scss b/src/style/variants.module.scss index 60d091a..390959b 100644 --- a/src/style/variants.module.scss +++ b/src/style/variants.module.scss @@ -18,17 +18,23 @@ $sides: "top", "right", "bottom", "left"; } @mixin interactive($variant: "solid") { + &:disabled, + &[data-disabled], + &[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; + + * { + pointer-events: none; + } + } + + /* stylelint-disable no-descending-specificity */ /* stylelint-disable-next-line @stylistic/max-line-length */ &:where(button, input, select, textarea, a, [role="button"], [role="menuitem"], [role="option"], [role="checkbox"], [role="switch"]) { text-decoration: none; // For applying variants to links - &:disabled, - &[data-disabled] { - cursor: not-allowed; - opacity: 0.4; - } - - &:not(:disabled, [data-disabled], [data-readonly]) { + &:not(:disabled, [data-disabled], [aria-disabled="true"], [data-readonly]) { cursor: pointer; user-select: none; @@ -55,6 +61,7 @@ $sides: "top", "right", "bottom", "left"; } } } + /* stylelint-enable no-descending-specificity */ } @mixin solid($intent: "secondary") { diff --git a/src/utils/stories/ControlledStory.tsx b/src/utils/stories/ControlledStory.tsx index a36c262..8135866 100644 --- a/src/utils/stories/ControlledStory.tsx +++ b/src/utils/stories/ControlledStory.tsx @@ -5,6 +5,8 @@ import { } from 'react'; import { Button } from '../../components/Button'; +import { ScrollArea } from '../../components/ScrollArea'; +import { sx } from '../getStyleClassNames'; export interface ControlledStoryAction { label: string; @@ -35,7 +37,9 @@ export const ControlledStory = < component: Component, initialValue, props, - renderValue = (value) => JSON.stringify(value), + renderValue = (value) => ( +
{JSON.stringify(value, null, 2)}
+ ), }: ControlledStoryProps): JSX.Element => { const [value, setValue] = useState(initialValue); @@ -46,7 +50,17 @@ export const ControlledStory = < value={value} onChange={setValue} /> - Current value: {renderValue(value)} +
+ Current value: + +
+ {renderValue(value)} +
+
+
{actions.map((action) => (