diff --git a/package-lock.json b/package-lock.json index 0273f4e3a..7513fce0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3039,6 +3039,68 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", + "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", + "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.0", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", @@ -41090,8 +41152,7 @@ "node_modules/tslib": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", - "dev": true + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -43605,6 +43666,10 @@ "version": "1.1.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "8.0.0", + "@dnd-kit/utilities": "3.2.2", "@types/react": "18.0.27", "bootstrap": "5.2.3", "react-bootstrap": "2.7.2", diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md index 3477e0b2b..992bef245 100644 --- a/packages/design-system/CHANGELOG.md +++ b/packages/design-system/CHANGELOG.md @@ -46,6 +46,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - **FormTextArea:** extend Props Interface to accept `autoFocus` prop. - **FormSelect:** extend Props Interface to accept `autoFocus` prop. - **Stack:** NEW Stack element to manage layouts. +- **TransferList:** NEW TransferList component to transfer items between two list, also sortable. - **Table:** extend Props Interface to accept `bordered` prop to add or remove borders on all sides of the table and cells. Defaults to true. # [1.1.0](https://github.com/IQSS/dataverse-frontend/compare/@iqss/dataverse-design-system@1.0.1...@iqss/dataverse-design-system@1.1.0) (2024-03-12) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 00b5361bc..4d0fe2e08 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -31,6 +31,10 @@ "test:coverage": "nyc check-coverage" }, "dependencies": { + "@dnd-kit/core": "6.1.0", + "@dnd-kit/modifiers": "^7.0.0", + "@dnd-kit/sortable": "8.0.0", + "@dnd-kit/utilities": "3.2.2", "@types/react": "18.0.27", "bootstrap": "5.2.3", "react-bootstrap": "2.7.2", diff --git a/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss b/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss index 429903c63..81bd1e725 100644 --- a/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss +++ b/packages/design-system/src/lib/assets/styles/design-tokens/colors.module.scss @@ -1,6 +1,6 @@ // Base colors -$dv-brand-color: #C55B28; -$dv-primary-color: #337AB7; +$dv-brand-color: #c55b28; +$dv-primary-color: #337ab7; $dv-secondary-color: #e0e0e0; $dv-success-color: #3c763d; $dv-danger-color: #a94442; @@ -10,7 +10,7 @@ $dv-success-box-color: #e0e0e0; $dv-danger-box-color: #f2dede; $dv-warning-box-color: #fcf8e3; $dv-info-box-color: #d9edf7; -$dv-info-border-color: #428BCA; +$dv-info-border-color: #428bca; // Text colors $dv-text-color: #333; @@ -22,13 +22,16 @@ $dv-secondary-text-color: $dv-text-color; $dv-headings-color: #333; // Link colors -$dv-link-color: #3174AF; +$dv-link-color: #3174af; $dv-link-hover-color: #23527c; $dv-tooltip-color: #99bcdb; $dv-tooltip-hover-color: #337ab7; // Button colors -$dv-button-border-color: #CCC; +$dv-button-border-color: #ccc; + +// Border colors +$dv-border-color: #dee2e6; :export { brand: $dv-brand-color; @@ -52,4 +55,5 @@ $dv-button-border-color: #CCC; buttonBorderColor: $dv-button-border-color; tooltipFillColor: $dv-tooltip-color; tooltipBorderColor: $dv-tooltip-hover-color; -} \ No newline at end of file + borderColor: $dv-border-color; +} diff --git a/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx b/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx new file mode 100644 index 000000000..562cfc4d9 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/ItemsList.tsx @@ -0,0 +1,86 @@ +import { ListGroup } from 'react-bootstrap' +import { DndContext, DragEndEvent } from '@dnd-kit/core' +import { arrayMove, SortableContext } from '@dnd-kit/sortable' +import { restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { type TransferListItem } from './TransferList' +import { ListItem } from './ListItem' +import styles from './TransferList.module.scss' + +type ListProps = + | { + items: TransferListItem[] + side: 'left' + checked: TransferListItem[] + onToggle: (item: TransferListItem) => () => void + rightItems?: never + setRight?: never + onChange?: never + } + | { + items: TransferListItem[] + side: 'right' + checked: TransferListItem[] + onToggle: (item: TransferListItem) => () => void + rightItems: TransferListItem[] + setRight: React.Dispatch> + onChange?: (selected: TransferListItem[]) => void + } + +export const ItemsList = ({ + items, + side, + checked, + onToggle, + rightItems, + setRight, + onChange +}: ListProps) => { + if (side === 'left') { + return ( + + {items.map((item: TransferListItem) => ( + + ))} + + ) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + + if (over && active.id !== over.id) { + const oldIndex = rightItems.findIndex((item) => item.id === active.id) + const newIndex = rightItems.findIndex((item) => item.id === over.id) + + const newItems = arrayMove(rightItems, oldIndex, newIndex) + + setRight(newItems) + + onChange && onChange(newItems) + } + } + + return ( + + + + {items.map((item: TransferListItem) => ( + + ))} + + + + ) +} diff --git a/packages/design-system/src/lib/components/transfer-list/ListItem.tsx b/packages/design-system/src/lib/components/transfer-list/ListItem.tsx new file mode 100644 index 000000000..b7b25d021 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/ListItem.tsx @@ -0,0 +1,74 @@ +import { useId } from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { ListGroup } from 'react-bootstrap' +import { Form } from '../form/Form' +import { TransferListItem } from './TransferList' +import { Stack } from '../stack/Stack' +import styles from './TransferList.module.scss' + +interface ListItemProps { + item: TransferListItem + side: 'left' | 'right' + checked: readonly TransferListItem[] + onToggle: (item: TransferListItem) => () => void +} + +export const ListItem = ({ item, side, checked, onToggle }: ListItemProps) => { + const { attributes, listeners, transform, transition, setNodeRef, setActivatorNodeRef } = + useSortable({ id: item.id }) + + const uniqueID = useId() + const labelId = `transfer-list-item-${item.value}-label-${uniqueID}` + + if (side === 'left') { + return ( + + + + ) + } + + const style = { + transform: CSS.Transform.toString(transform), + transition + } + + return ( + + + + + + + ) +} diff --git a/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss b/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss new file mode 100644 index 000000000..83fcc7593 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/TransferList.module.scss @@ -0,0 +1,130 @@ +@use 'sass:color'; +@import 'src/lib/assets/styles/design-tokens/colors.module'; + +.transfer-list { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @media screen and (min-width: 768px) { + flex-flow: row wrap; + } +} + +.items-column { + position: relative; + z-index: 1; + width: 230px; + height: 240px; + overflow-x: hidden; + overflow-y: auto; + border: solid 1px $dv-border-color; + border-radius: 6px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $dv-secondary-color; + border-radius: 6px; + + &:hover { + background-color: color.adjust($dv-secondary-color, $blackness: 40%); + } + } + + .items-list { + margin-bottom: 0; + padding-left: 0; + list-style-type: none; + + .list-item { + position: relative; + padding-inline: 1rem; + touch-action: none; + + :global .form-check { + display: flex; + align-items: center; + margin-bottom: 0; + padding-left: 0; + } + + input[type='checkbox'] { + flex-shrink: 0; + float: unset; + margin-top: 0; + margin-left: 0; + cursor: pointer; + } + + label { + width: 100%; + padding-left: 0.5rem; + padding-block: 0.5rem; + cursor: pointer; + } + + .drag-handle { + padding: 0; + background-color: transparent; + border: 0; + border-radius: 4px; + cursor: grab; + + &:hover { + background-color: $dv-secondary-color; + } + + &:active { + cursor: grabbing; + } + } + + &:hover, + &:focus-visible { + color: $dv-text-color; + background-color: color.adjust($dv-secondary-color, $alpha: -0.7); + } + + &:has(input[type='checkbox']:checked) { + background-color: color.adjust($dv-secondary-color, $alpha: -0.4); + } + + &[aria-pressed='true'] { + z-index: 999; + background-color: color.adjust($dv-secondary-color, $alpha: -0.3); + } + } + } + + .column-label { + position: sticky; + top: 0; + left: 0; + margin: 0; + padding: 0.35rem 0.5rem; + font-weight: bold; + text-align: center; + background-color: #f5f5f5; + } +} + +.middle-column { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + justify-content: center; + padding: 1rem 2rem; + + .transfer-button { + min-width: 64px; + } +} diff --git a/packages/design-system/src/lib/components/transfer-list/TransferList.tsx b/packages/design-system/src/lib/components/transfer-list/TransferList.tsx new file mode 100644 index 000000000..7b052d942 --- /dev/null +++ b/packages/design-system/src/lib/components/transfer-list/TransferList.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react' +import { Button } from '../button/Button' +import { + ChevronDoubleLeft, + ChevronDoubleRight, + ChevronLeft, + ChevronRight +} from 'react-bootstrap-icons' +import { ItemsList } from './ItemsList' +import styles from './TransferList.module.scss' + +function not(a: TransferListItem[], b: TransferListItem[]) { + return a.filter((item) => !b.some((bItem) => bItem.value === item.value)) +} + +function intersection(a: TransferListItem[], b: TransferListItem[]) { + return a.filter((item) => b.some((bItem) => bItem.value === item.value)) +} + +export interface TransferListItem { + value: string | number + label: string + id: string | number +} + +export interface TransferListProps { + availableItems: TransferListItem[] + defaultSelected?: TransferListItem[] + onChange?: (selected: TransferListItem[]) => void + leftLabel?: string + rightLabel?: string +} + +export const TransferList = ({ + availableItems, + defaultSelected = [], + onChange, + leftLabel, + rightLabel +}: TransferListProps) => { + const [checked, setChecked] = useState([]) + const [left, setLeft] = useState(not(availableItems, defaultSelected)) + const [right, setRight] = useState( + intersection(availableItems, defaultSelected) + ) + + const leftChecked = intersection(checked, left) + const rightChecked = intersection(checked, right) + + const handleToggle = (item: TransferListItem) => () => { + const currentIndex = checked.findIndex((checkedItem) => checkedItem.value === item.value) + const newChecked = [...checked] + + if (currentIndex === -1) { + newChecked.push(item) + } else { + newChecked.splice(currentIndex, 1) + } + + setChecked(newChecked) + } + + const handleAllRight = () => { + setRight(right.concat(left)) + onChange && onChange(right.concat(left)) + setLeft([]) + } + + const handleCheckedRight = () => { + setRight(right.concat(leftChecked)) + onChange && onChange(right.concat(leftChecked)) + setLeft(not(left, leftChecked)) + setChecked(not(checked, leftChecked)) + } + + const handleCheckedLeft = () => { + setLeft(left.concat(rightChecked.filter((item) => availableItems.includes(item)))) + setRight(not(right, rightChecked)) + onChange && onChange(not(right, rightChecked)) + setChecked(not(checked, rightChecked)) + } + + const handleAllLeft = () => { + setLeft(left.concat(right.filter((item) => availableItems.includes(item)))) + setRight([]) + onChange && onChange([]) + } + + useEffect(() => { + // Update the left items when the available items change + setLeft(not(availableItems, right)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableItems]) + + return ( +
+
+ {leftLabel &&

{leftLabel}

} + +
+
+
+
+ {rightLabel &&

{rightLabel}

} + +
+
+ ) +} diff --git a/packages/design-system/src/lib/index.ts b/packages/design-system/src/lib/index.ts index 6c08768a9..533931299 100644 --- a/packages/design-system/src/lib/index.ts +++ b/packages/design-system/src/lib/index.ts @@ -28,3 +28,4 @@ export { SelectAdvanced } from './components/select-advanced/SelectAdvanced' export { Card } from './components/card/Card' export { ProgressBar } from './components/progress-bar/ProgressBar' export { Stack } from './components/stack/Stack' +export { TransferList } from './components/transfer-list/TransferList' diff --git a/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx b/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx new file mode 100644 index 000000000..a28d5737e --- /dev/null +++ b/packages/design-system/src/lib/stories/transfer-list/TransferList.stories.tsx @@ -0,0 +1,307 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { TransferList, TransferListItem } from '../../components/transfer-list/TransferList' +import { useState } from 'react' +import { SelectAdvanced } from '../../components/select-advanced/SelectAdvanced' + +/** + * ## Description + * The transfer list component is an element that allows users to transfer on or more items between two lists. + * + * The list on the left is the source list and the list on the right is the target list. + * + * The right list can be empty or have some items initially. + * + * You can also sort the items on the right list by dragging them. + * + * You can pass an onChange function to know when the selected items change. + * + * *Note: You can open the developer console to see the selected items when they change.* + */ + +const meta: Meta = { + title: 'Transfer List', + component: TransferList, + tags: ['autodocs'] +} + +const aToEitems: TransferListItem[] = [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } +] + +const fToJitems: TransferListItem[] = [ + { + label: 'Item F', + value: 'F', + id: 'F' + }, + { + label: 'Item G', + value: 'G', + id: 'G' + }, + { + label: 'Item H', + value: 'H', + id: 'H' + }, + { + label: 'Item I', + value: 'I', + id: 'I' + }, + { + label: 'Item J', + value: 'J', + id: 'J' + } +] + +const kToÑitems: TransferListItem[] = [ + { + label: 'Item K', + value: 'K', + id: 'K' + }, + { + label: 'Item L', + value: 'L', + id: 'L' + }, + { + label: 'Item M', + value: 'M', + id: 'M' + }, + { + label: 'Item N', + value: 'N', + id: 'N' + }, + { + label: 'Item Ñ', + value: 'Ñ', + id: 'Ñ' + } +] + +const oToUitems: TransferListItem[] = [ + { + label: 'Item O', + value: 'O', + id: 'O' + }, + { + label: 'Item P', + value: 'P', + id: 'P' + }, + { + label: 'Item Q', + value: 'Q', + id: 'Q' + }, + { + label: 'Item R', + value: 'R', + id: 'R' + }, + { + label: 'Item S', + value: 'S', + id: 'S' + }, + { + label: 'Item T', + value: 'T', + id: 'T' + }, + { + label: 'Item U', + value: 'U', + id: 'U' + } +] + +const vToZitems: TransferListItem[] = [ + { + label: 'Item V', + value: 'V', + id: 'V' + }, + { + label: 'Item W', + value: 'W', + id: 'W' + }, + { + label: 'Item X', + value: 'X', + id: 'X' + }, + { + label: 'Item Y', + value: 'Y', + id: 'Y' + }, + { + label: 'Item Z', + value: 'Z', + id: 'Z' + } +] + +const allItems: TransferListItem[] = [ + ...aToEitems, + ...fToJitems, + ...kToÑitems, + ...oToUitems, + ...vToZitems +] + +const defaultSelected: TransferListItem[] = [ + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + } +] + +export default meta +type Story = StoryObj + +const onChangeFn = (items: TransferListItem[]) => { + console.log('%cSelected items:', 'color: #00d400; font-weight: bold;', items) +} + +export const Default: Story = { + render: () => +} + +/** + * Use the `defaultSelected` prop to set the items that are selected by default. + * The component will render with the selected items on the right list and remove them from the left list of available items. + */ + +export const WithDefaultSelected: Story = { + render: () => ( + + ) +} + +/** + * Use the `leftLabel` and `rightLabel` props to set the labels of the lists. + * Both left and right labels are optional. + */ + +export const WithLabels: Story = { + render: () => ( + + ) +} + +/** + * You can change the items of the left list dynamically by changing the `availableItems` prop. This is just an example using the SelectAdvanced component. + * - In this example, the `availableItems` prop changes when the user selects an option from the select and make the items of the left list change. + * - The right list will keep the selected items even if they are not available in the left list. + * - If there is an item in the right list that belongs to new available items, it will remain on the right list and will be filtered out from the new available items for the left list. + * - If you move an item from the right list to the left list but this item does not exist anymore in the current available items, it wont appear on the left list. + */ + +export const WithChangingAvailableItems: Story = { + render: () => +} + +type SelectOption = 'All' | 'A to E' | 'F to J' | 'K to Ñ' | 'O to U' | 'V to Z' +const WithChangingAvailableItemsComponent = () => { + const [items, setItems] = useState(allItems) + const options: SelectOption[] = ['All', 'A to E', 'F to J', 'K to Ñ', 'O to U', 'V to Z'] + + const handleChangeSelect = (selected: string) => { + const castedSelected = selected as SelectOption + switch (castedSelected) { + case 'All': + setItems(allItems) + break + case 'A to E': + setItems(aToEitems) + break + case 'F to J': + setItems(fToJitems) + break + case 'K to Ñ': + setItems(kToÑitems) + break + case 'O to U': + setItems(oToUitems) + break + case 'V to Z': + setItems(vToZitems) + break + } + } + + return ( +
+
+
+ +
+ + +
+
+ ) +} diff --git a/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx new file mode 100644 index 000000000..9e1e19a83 --- /dev/null +++ b/packages/design-system/tests/component/transfer-list/TransferList.spec.tsx @@ -0,0 +1,689 @@ +import { + TransferList, + TransferListItem +} from '../../../src/lib/components/transfer-list/TransferList' + +const availableItems: TransferListItem[] = [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } +] + +describe('TransferList', () => { + beforeEach(() => { + cy.viewport(1280, 720) + }) + it('should render correctly', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + }) + + it('should render correctly with default selected items', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('not.exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('not.exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + cy.get('@rightList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item C').should('exist') + }) + }) + + it('should move all items to the right', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all right').click() + }) + + cy.get('@leftList').children().should('have.length', 0) + cy.get('@rightList').children().should('have.length', 5) + }) + + it('should move all items to the left', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all left').click() + }) + + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').children().should('have.length', 0) + }) + + it('should move selected items to the right', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').should('be.disabled') + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').should('not.be.disabled') + cy.findByLabelText('move selected to right').click() + }) + + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('not.exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('not.exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + + cy.get('@rightList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('not.exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('not.exist') + cy.findByText('Item E').should('not.exist') + }) + }) + + it('should move selected items to the left', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').should('be.disabled') + }) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').should('not.be.disabled') + cy.findByLabelText('move selected to left').click() + }) + + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByText('Item A').should('exist') + cy.findByText('Item B').should('exist') + cy.findByText('Item C').should('exist') + cy.findByText('Item D').should('exist') + cy.findByText('Item E').should('exist') + }) + }) + + it('should show left and right labels', () => { + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.findByText('Left label').should('exist') + cy.findByText('Right label').should('exist') + }) + + it('should check and uncheck items', () => { + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('not.be.checked') + cy.findByLabelText('Item C').should('not.be.checked') + cy.findByLabelText('Item E').should('not.be.checked') + + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('be.checked') + cy.findByLabelText('Item C').should('be.checked') + cy.findByLabelText('Item E').should('be.checked') + }) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').should('not.exist') + cy.findByLabelText('Item C').should('not.exist') + cy.findByLabelText('Item E').should('not.exist') + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').should('not.be.checked') + cy.findByLabelText('Item C').should('not.be.checked') + cy.findByLabelText('Item E').should('not.be.checked') + }) + }) + + describe('drag and drop', () => { + it('should sort item A for Item B', () => { + cy.mount( + + ) + + cy.findByTestId('right-list-group').as('rightList') + + // Check initial order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item A') + cy.wrap(secondItem).should('contain.text', 'Item B') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .first() + .focus() + .type('{enter}') + .type('{downArrow}') + .type('{downArrow}') // with two presses of down arrow, item A should be moved to the Item B position + .type('{enter}') + + // Check the new order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item B') + cy.wrap(secondItem).should('contain.text', 'Item A') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + }) + + it('should sort item C to the top of the list', () => { + cy.mount( + + ) + + cy.findByTestId('right-list-group').as('rightList') + + // Check initial order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item A') + cy.wrap(secondItem).should('contain.text', 'Item B') + cy.wrap(thirdItem).should('contain.text', 'Item C') + }) + }) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .last() + .focus() + .type('{enter}') + .type('{upArrow}') + .type('{upArrow}') + .type('{upArrow}') + .type('{upArrow}') // with 4 press of up arrow, item C should be moved to the top of the list + .type('{enter}') + + // Check the new order of items + cy.get('@rightList').within(() => { + cy.get('[aria-roledescription="sortable"]').as('sortableItems') + cy.get('@sortableItems').should('have.length', 3) + + cy.get('@sortableItems').spread((firstItem, secondItem, thirdItem) => { + cy.wrap(firstItem).should('contain.text', 'Item C') + cy.wrap(secondItem).should('contain.text', 'Item A') + cy.wrap(thirdItem).should('contain.text', 'Item B') + }) + }) + }) + }) + + describe('onChange calls', () => { + it('should call onChange correctly when moving checked items to right', () => { + const onChange = cy.stub().as('onChange') + + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@leftList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + cy.findByLabelText('Item E').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to right').click() + }) + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } + ]) + }) + + it('should call onChange correctly when moving checked items to left', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@rightList').within(() => { + cy.findByLabelText('Item A').click() + cy.findByLabelText('Item C').click() + }) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move selected to left').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', []) + }) + + it('should call onChange correctly when moving all items to right', () => { + const onChange = cy.stub().as('onChange') + + cy.mount() + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 5) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 0) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all right').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + }, + { + label: 'Item D', + value: 'D', + id: 'D' + }, + { + label: 'Item E', + value: 'E', + id: 'E' + } + ]) + }) + + it('should call onChange correctly when moving all items to left', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.findByTestId('left-list-group').as('leftList') + cy.findByTestId('actions-column').as('actionsColumn') + cy.findByTestId('right-list-group').as('rightList') + + cy.get('@leftList').should('exist') + cy.get('@leftList').children().should('have.length', 3) + cy.get('@rightList').should('exist') + cy.get('@rightList').children().should('have.length', 2) + + cy.get('@actionsColumn').within(() => { + cy.findByLabelText('move all left').click() + }) + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', []) + }) + + it('should call onChange correctly when sorting items', () => { + const onChange = cy.stub().as('onChange') + + cy.mount( + + ) + + cy.wait(1000) + + cy.findAllByLabelText('press space to select and keys to drag').as('dragHandles') + + cy.get('@dragHandles').should('have.length', 3) + + cy.get('@dragHandles') + .first() + .focus() + .type('{enter}') + .type('{downArrow}') + .type('{downArrow}') // with two presses of down arrow, item A should be moved to the Item B position + .type('{enter}') + + cy.get('@onChange').should('have.been.calledWith', [ + { + label: 'Item B', + value: 'B', + id: 'B' + }, + { + label: 'Item A', + value: 'A', + id: 'A' + }, + { + label: 'Item C', + value: 'C', + id: 'C' + } + ]) + }) + }) +})