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/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss index d0c8e96..e18d9c9 100644 --- a/src/components/Checkbox/Checkbox.module.scss +++ b/src/components/Checkbox/Checkbox.module.scss @@ -29,14 +29,6 @@ padding: 0; outline: 0; - - &[data-checked], - &[data-indeterminate] { - --variant-border: var(--color-solid-primary-bg); - - color: var(--color-solid-primary-text); - background-color: var(--color-solid-primary-bg); - } } &-indicator { diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index f9f514c..17ad3a4 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -30,9 +30,9 @@ export const Checkbox = forwardRef, Checkbo clsx(getStyleClassNames({ + variant: state.checked || state.indeterminate ? 'solid' : 'ghost', + intent: state.checked || state.indeterminate ? 'primary' : 'secondary', border: true, corners: 'tight', }), styles.checkboxControl, className)} 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..74dca08 100644 --- a/src/components/ScrollArea/ScrollArea.tsx +++ b/src/components/ScrollArea/ScrollArea.tsx @@ -12,12 +12,16 @@ import { getStyleClassNames } from '../../utils/getStyleClassNames'; import styles from './ScrollArea.module.scss'; type ScrollAreaProps = ComponentPropsWithoutRef<'div'> & { + disabled?: boolean; + focusable?: boolean; offset?: Partial>; }; export const ScrollArea = forwardRef(({ className, children, + disabled = false, + focusable = true, onScroll, offset, style, @@ -33,20 +37,29 @@ 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} > - + {children} - - - - - - - + {!disabled && ( + <> + + + + + + + + + )} )); diff --git a/src/components/TransferList/TransferList.module.scss b/src/components/TransferList/TransferList.module.scss new file mode 100644 index 0000000..822e71c --- /dev/null +++ b/src/components/TransferList/TransferList.module.scss @@ -0,0 +1,61 @@ +@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; + } + } + + &[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; + } + } + + &-available-label { + grid-area: available_label; + } + + &-available-list { + grid-area: available_list; + } + + &-selected-label { + grid-area: selected_label; + } + + &-selected-list { + grid-area: selected_list; + } + } +} 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..2a46afe --- /dev/null +++ b/src/components/TransferList/TransferList.module.scss.d.ts @@ -0,0 +1,5 @@ +export declare const transferList: string; +export declare const transferListAvailableLabel: string; +export declare const transferListAvailableList: 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..fe228f1 --- /dev/null +++ b/src/components/TransferList/TransferList.stories.tsx @@ -0,0 +1,202 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { ControlledStory } from '../../utils/stories/ControlledStory'; +import { UserOption } from '../Select/stories/UserOption'; +import { TransferList, TransferListProps } from './TransferList'; +import { TransferListValue } from './TransferList.types'; + +const meta: Meta = { + title: 'Components/TransferList', + component: TransferList, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story, { args }) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + argTypes: { + disabled: { + control: 'boolean', + description: 'Whether the component is disabled.', + table: { category: 'Behavior' }, + }, + orientation: { + control: 'inline-radio', + options: ['horizontal', 'vertical'], + description: 'Whether the groups are laid out side by side or stacked.', + table: { category: 'Appearance' }, + }, + searchPlaceholder: { + control: 'text', + description: 'The placeholder text for each pane\'s search input.', + table: { category: 'Content' }, + }, + className: { table: { disable: true } }, + groups: { table: { disable: true } }, + items: { table: { disable: true } }, + renderItem: { table: { disable: true } }, + value: { table: { disable: true } }, + onChange: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +const fruitItems = [ + '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 })); + +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, + orientation: 'horizontal', + groups: twoGroups, + items: fruitItems, + defaultValue: { + selected: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + }, + }, +}; + +export const Vertical: Story = { + name: 'Vertical', + args: { + disabled: false, + orientation: 'vertical', + groups: twoGroups, + items: fruitItems, + defaultValue: { + selected: ['banana', 'fig', 'mango', 'pear', 'watermelon'], + }, + }, +}; + +export const ThreeLists: Story = { + name: 'Three Lists', + args: { + disabled: false, + orientation: 'horizontal', + groups: threeGroups, + items: fruitItems, + defaultValue: { + shortlist: ['banana', 'fig'], + selected: ['mango', 'pear'], + }, + }, +}; + +const userItems = [ + '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, + orientation: 'horizontal', + groups: [ + { key: 'users', title: 'Users' }, + { key: 'invited', title: 'Invited' }, + ], + items: userItems, + defaultValue: { invited: ['bob-b-', 'eve-e-'] }, + searchPlaceholder: 'Search users...', + renderItem: (item) => ( + + ), + }, +}; + +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, + orientation: 'horizontal', + }, + render: (args) => ( + + component={TransferList} + props={{ ...args, groups: twoGroups, items: fruitItems }} + initialValue={getFruitValue(['cherry', 'kiwi', 'papaya'])} + actions={[ + { 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 new file mode 100644 index 0000000..10fd522 --- /dev/null +++ b/src/components/TransferList/TransferList.tsx @@ -0,0 +1,133 @@ +import { + CSSProperties, + Fragment, + ReactNode, + useEffect, + useState, +} from 'react'; +import clsx from 'clsx'; + +import { TransferListControls } from './components/TransferListControls'; +import { TransferListGroup } from './components/TransferListGroup'; +import { + TransferListGroupDef, + TransferListItemDef, + TransferListItemState, + TransferListOrientation, + 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 { + className?: string; + defaultValue?: TransferListValue; + disabled?: boolean; + groups: TransferListGroupDef[]; + onChange?: (value: TransferListValue) => void; + items: TransferListItemDef[]; + orientation?: TransferListOrientation; + renderItem?: (item: TransferListItemDef, state: TransferListItemState) => ReactNode; + searchPlaceholder?: string; + value?: TransferListValue; +} + +export const TransferList = ({ + className, + defaultValue, + disabled = false, + groups, + onChange, + items, + orientation = 'horizontal', + renderItem, + searchPlaceholder = 'Filter...', + value: controlledValue, +}: TransferListProps): JSX.Element => { + const [value, setValue] = useState(defaultValue ?? {}); + + const state = valueToState(controlledValue ?? value); + + const [staged, setStaged] = useState>({}); + + useEffect(() => { + if (disabled) { + setStaged({}); + } + }, [disabled]); + + const getListKey = (item: TransferListItemDef): string => ( + groups.some((list) => list.key === state[item.value]) ? state[item.value] : groups[0].key + ); + + const handleChange = (next: TransferListState) => { + if (controlledValue === undefined) { + setValue(stateToValue(next, groups, items)); + } + onChange?.(stateToValue(next, groups, items)); + }; + + 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 getChecked = (listKey: string): string[] => staged[listKey] ?? []; + + return ( +
'1fr').join(' auto '), + } : { + '--transfer-list-group-height': '10rem', + gridTemplateAreas: 'none', + gridTemplateRows: 'none', + gridTemplateColumns: '1fr', + } as CSSProperties} + > + {groups.map((group, index) => ( + + getListKey(item) === group.key)} + checked={getChecked(group.key)} + onChange={(next) => setStaged((prev) => ({ ...prev, [group.key]: next }))} + disabled={disabled} + emptyMessage="No matching items." + renderItem={renderItem} + searchPlaceholder={searchPlaceholder} + /> + + + ))} +
+ ); +}; + +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..c75de4b --- /dev/null +++ b/src/components/TransferList/TransferList.types.ts @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; + +export type TransferListGroupKey = string; +export type TransferListItemValue = string; + +export type TransferListItemDef = { + value: string; + label?: ReactNode; + disabled?: boolean; +}; + +export type TransferListGroupDef = { + key: TransferListGroupKey; + title: string; +}; + +export type TransferListOrientation = 'horizontal' | 'vertical'; + +export type TransferListState = Record; + +export type TransferListValue = Record; + +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..ba9400d --- /dev/null +++ b/src/components/TransferList/TransferList.utils.ts @@ -0,0 +1,41 @@ +import { + TransferListGroupDef, + TransferListItemDef, + TransferListState, + TransferListValue, +} from './TransferList.types'; + +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; + } + 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/TransferListControls/TransferListControls.module.scss b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss new file mode 100644 index 0000000..5fc9cbd --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/TransferListControls.module.scss @@ -0,0 +1,12 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; + +@layer components { + .transfer-list-controls { + @include flex.column($gap: 0.5rem, $xAlign: center, $yAlign: center); + + &[data-orientation="vertical"] { + @include flex.row($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..d6fb7c6 --- /dev/null +++ b/src/components/TransferList/components/TransferListControls/TransferListControls.tsx @@ -0,0 +1,65 @@ +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, +} from 'lucide-react'; + +import { Button } from '../../../Button'; +import { TransferListGroupDef, TransferListOrientation } from '../../TransferList.types'; + +import styles from './TransferListControls.module.scss'; + +export interface TransferListControlsProps { + disabled?: boolean; + groups: TransferListGroupDef[]; + index: number; + onMove: (values: string[], groupKey: string) => void; + orientation?: TransferListOrientation; + staged: Record; +} + +export const TransferListControls = ({ + disabled = false, + groups, + index, + onMove, + orientation = 'horizontal', + 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/TransferListGroup/TransferListGroup.module.scss b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss new file mode 100644 index 0000000..3a59769 --- /dev/null +++ b/src/components/TransferList/components/TransferListGroup/TransferListGroup.module.scss @@ -0,0 +1,109 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; +@use "../../../../style/text"; + +@layer components { + .transfer-list-group { + display: grid; + grid-template-rows: auto auto auto var(--transfer-list-group-height, 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; + 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/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..9f32a55 --- /dev/null +++ b/src/components/TransferList/components/TransferListGroup/TransferListGroup.tsx @@ -0,0 +1,133 @@ +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: 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} + aria-label={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/TransferListItem/TransferListItem.module.scss b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss new file mode 100644 index 0000000..489057a --- /dev/null +++ b/src/components/TransferList/components/TransferListItem/TransferListItem.module.scss @@ -0,0 +1,12 @@ +@use "../../../../style/layers"; +@use "../../../../style/flex"; +@use "../../../../style/text"; + +@layer components { + .transfer-list-item { + @include text.ui; + @include flex.row($gap: 0.5rem, $yAlign: center); + + width: 100%; + } +} 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/TransferListItem/TransferListItem.tsx b/src/components/TransferList/components/TransferListItem/TransferListItem.tsx new file mode 100644 index 0000000..50d2314 --- /dev/null +++ b/src/components/TransferList/components/TransferListItem/TransferListItem.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 './TransferListItem.module.scss'; + +export const TransferListItem = ({ + children, + disabled = false, + value, +}: TransferListItemProps): JSX.Element => ( + +); + +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/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..4131e76 100644 --- a/src/style/variants.module.scss +++ b/src/style/variants.module.scss @@ -18,17 +18,25 @@ $sides: "top", "right", "bottom", "left"; } @mixin interactive($variant: "solid") { - /* 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], + &[aria-disabled="true"] { + cursor: not-allowed; + opacity: var(--disabled-opacity, 0.5); - &:disabled, - &[data-disabled] { - cursor: not-allowed; - opacity: 0.4; + * { + --disabled-opacity: 1; + + 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"], [data-interactive]) { + text-decoration: none; // For applying variants to links - &:not(:disabled, [data-disabled], [data-readonly], input, textarea) { + &:not(:disabled, [data-disabled], [aria-disabled="true"], [data-readonly]) { cursor: pointer; user-select: none; @@ -55,6 +63,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 new file mode 100644 index 0000000..8135866 --- /dev/null +++ b/src/utils/stories/ControlledStory.tsx @@ -0,0 +1,71 @@ +import { + ComponentType, + ReactNode, + useState, +} from 'react'; + +import { Button } from '../../components/Button'; +import { ScrollArea } from '../../components/ScrollArea'; +import { sx } from '../getStyleClassNames'; + +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, null, 2)}
+ ), +}: ControlledStoryProps): JSX.Element => { + const [value, setValue] = useState(initialValue); + + return ( +
+ +
+ Current value: + +
+ {renderValue(value)} +
+
+
+
+ {actions.map((action) => ( +
+
+ ); +};