Skip to content

Commit

Permalink
feat: shift click for multiselect (#4467)
Browse files Browse the repository at this point in the history
* feat: shit click for multiselect

* fix: use ref instead of query selector

* fix: add tests

* feat: add hover style

* fix: add tests

* fix: add test
  • Loading branch information
lisalupi authored Nov 26, 2024
1 parent ea57fc9 commit bbdad4a
Show file tree
Hide file tree
Showing 9 changed files with 5,051 additions and 309 deletions.
5 changes: 5 additions & 0 deletions .changeset/hip-dancers-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ultraviolet/ui": patch
---

`<List />` and `<Table />` : hold shift to multiselect
114 changes: 113 additions & 1 deletion packages/ui/src/components/List/ListContext.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import type { ComponentProps, Dispatch, ReactNode, SetStateAction } from 'react'
import type {
ComponentProps,
Dispatch,
ReactNode,
RefObject,
SetStateAction,
} from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import type { Checkbox } from '../Checkbox'
Expand Down Expand Up @@ -32,6 +40,8 @@ type ListContextValue = {
allRowSelectValue: ComponentProps<typeof Checkbox>['checked']
selectAll: () => void
unselectAll: () => void
refList: RefObject<HTMLInputElement[]>
inRange: string[]
}

const ListContext = createContext<ListContextValue | undefined>(undefined)
Expand All @@ -53,6 +63,7 @@ export const ListProvider = ({
}: ListProviderProps) => {
const [expandedRowIds, setExpandedRowIds] = useState<RowState>({})
const [selectedRowIds, setSelectedRowIds] = useState<RowState>({})
const refList = useRef<HTMLInputElement[]>([])

const registerExpandableRow = useCallback(
(rowId: string, expanded = false) => {
Expand Down Expand Up @@ -176,6 +187,103 @@ export const ListProvider = ({
[onSelectedChange, selectedRowIds],
)

const [lastCheckedIndex, setLastCheckedIndex] = useState<null | number>(null)
const [inRange, setInRange] = useState<string[]>([])

useEffect(() => {
const handlers: (() => void)[] = []
if (refList.current) {
const handleClick = (
index: number,
isShiftPressed: boolean,
checked: boolean,
) => {
if (index !== 0) {
setLastCheckedIndex(index)
if (isShiftPressed && lastCheckedIndex !== null) {
const start = Math.min(lastCheckedIndex, index)
const end = Math.max(lastCheckedIndex, index)

const newSelectedRowIds = {
...selectedRowIds,
}

for (let i = start; i <= end; i += 1) {
const checkbox = refList.current[i]
const checkboxValue = checkbox.value

if (!checkbox.disabled) {
if (checked) {
newSelectedRowIds[checkboxValue] = false
} else {
newSelectedRowIds[checkboxValue] = true
}
}
}
setSelectedRowIds(newSelectedRowIds)
if (onSelectedChange) {
onSelectedChange(
Object.keys(newSelectedRowIds).filter(
row => newSelectedRowIds[row],
),
)
}
}
} else setLastCheckedIndex(null)
}

const handleHover = (
index: number,
isShiftPressed: boolean,
leaving: boolean,
) => {
const newRange: string[] = []

if (isShiftPressed && lastCheckedIndex !== null) {
const start = Math.min(lastCheckedIndex, index)
const end = Math.max(lastCheckedIndex, index)

for (let i = start; i < end; i += 1) {
const checkbox = refList.current[i]
if (!checkbox.disabled && !leaving) {
newRange.push(checkbox.value)
}
}
}
setInRange(newRange)
}

refList.current.forEach((checkbox, index) => {
const clickHandler = (event: MouseEvent) =>
handleClick(
index,
event.shiftKey,
selectedRowIds[(event.target as HTMLInputElement).value],
)

const hoverEnteringHandler = (event: MouseEvent) =>
handleHover(index, event.shiftKey, false)

const hoverLeavingHandler = (event: MouseEvent) =>
handleHover(index, event.shiftKey, true)

checkbox.addEventListener('click', clickHandler)
checkbox.addEventListener('mousemove', hoverEnteringHandler)
checkbox.addEventListener('mouseout', hoverLeavingHandler)

handlers.push(() => {
checkbox.removeEventListener('click', clickHandler)
checkbox.removeEventListener('mouseout', hoverEnteringHandler)
checkbox.removeEventListener('mousemove', hoverLeavingHandler)
})
})
}

return () => {
handlers.forEach(cleanup => cleanup())
}
}, [lastCheckedIndex, onSelectedChange, selectedRowIds, unselectRow])

const value = useMemo<ListContextValue>(
() => ({
registerExpandableRow,
Expand All @@ -191,6 +299,8 @@ export const ListProvider = ({
unselectAll,
allRowSelectValue,
expandButton,
refList,
inRange,
}),
[
registerExpandableRow,
Expand All @@ -206,6 +316,8 @@ export const ListProvider = ({
unselectAll,
allRowSelectValue,
expandButton,
refList,
inRange,
],
)

Expand Down
30 changes: 27 additions & 3 deletions packages/ui/src/components/List/Row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import styled from '@emotion/styled'
import type { ForwardedRef, ReactNode } from 'react'
import { forwardRef, useCallback, useEffect } from 'react'
import { forwardRef, useCallback, useEffect, useRef } from 'react'
import type { SENTIMENTS, space } from '../../theme'
import { Button } from '../Button'
import { Checkbox } from '../Checkbox'
Expand All @@ -21,6 +21,15 @@ const ExpandableWrapper = styled('div', {
border-radius: 0 0 ${({ theme }) => theme.radii.default} ${({ theme }) => theme.radii.default};
`

const StyledCheckbox = styled(Checkbox, {
shouldForwardProp: prop => !['inRange'].includes(prop),
})<{ inRange: boolean }>`
rect {
${({ theme, inRange }) => (inRange ? `fill: ${theme.colors.neutral.backgroundHover};stroke: ${theme.colors.neutral.borderHover};` : '')}
}
`

export const StyledRow = styled('div', {
shouldForwardProp: prop => !['sentiment'].includes(prop),
})<{
Expand Down Expand Up @@ -127,8 +136,12 @@ export const Row = forwardRef(
selectRow,
unselectRow,
expandButton,
refList,
inRange,
} = useListContext()

const checkboxRef = useRef<HTMLInputElement>(null)

const isSelectDisabled =
disabled || (selectDisabled !== undefined && selectDisabled !== false)

Expand Down Expand Up @@ -163,6 +176,15 @@ export const Row = forwardRef(

const canClickRowToExpand = !disabled && !!expandable && !expandButton

useEffect(() => {
const refAtEffectStart = refList.current
const { current } = checkboxRef

if (refAtEffectStart && current && !refAtEffectStart.includes(current)) {
refList.current.push(current)
}
}, [refList])

return (
<StyledRow
className={className}
Expand All @@ -183,7 +205,7 @@ export const Row = forwardRef(
sentiment={sentiment}
aria-disabled={disabled}
aria-expanded={expandable ? expandedRowIds[id] : undefined}
data-highlight={!!selectedRowIds[id]}
data-highlight={selectable && !!selectedRowIds[id]}
data-testid={dataTestid}
>
{selectable ? (
Expand All @@ -196,11 +218,12 @@ export const Row = forwardRef(
: undefined
}
>
<Checkbox
<StyledCheckbox
name="list-select-checkbox"
aria-label="select"
checked={selectedRowIds[id]}
value={id}
ref={checkboxRef}
onChange={() => {
if (selectedRowIds[id]) {
unselectRow(id)
Expand All @@ -209,6 +232,7 @@ export const Row = forwardRef(
}
}}
disabled={isSelectDisabled}
inRange={inRange.includes(id)}
/>
</Tooltip>
</StyledCheckboxContainer>
Expand Down
Loading

0 comments on commit bbdad4a

Please sign in to comment.