Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: shift select #4636

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strange-socks-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ultraviolet/ui": patch
---

`<List />` and `<Table />`: more intuitive behavior for shift+click
135 changes: 64 additions & 71 deletions packages/ui/src/components/List/ListContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
ComponentProps,
Dispatch,
ReactNode,
RefObject,
SetStateAction,
} from 'react'
import type { Checkbox } from '../Checkbox'
import type { ColumnProps } from './types'

type RowState = Record<string | number, boolean>
type MapCheckbox = Map<string | number, HTMLInputElement>

export type ListContextValue = {
// ============ Expandable logic ============
Expand All @@ -40,13 +40,14 @@
subscribeHandler: () => void
columns: ColumnProps[]
inRange: Set<number | string>
mapCheckbox: MapCheckbox
selectable: boolean
selectAll: () => void
selectedRowIds: RowState
selectRow: (rowId: string) => void
unselectAll: () => void
unselectRow: (rowId: string) => void
refList: RefObject<HTMLInputElement[]>
handleOnChange: (value: string, checked: boolean) => void
}

const ListContext = createContext<ListContextValue | undefined>(undefined)
Expand Down Expand Up @@ -86,11 +87,9 @@
}: ListProviderProps) => {
const [expandedRowIds, setExpandedRowIds] = useState<RowState>({})
const [selectedRowIds, setSelectedRowIds] = useState<RowState>({})
const [lastCheckedIndex, setLastCheckedIndex] = useState<
null | (number | string)
>(null)
const [lastCheckedCheckbox, setLastCheckedCheckbox] = useState<string>()
const [inRange, setInRange] = useState<Set<number | string>>(new Set([]))
const refList = useRef<MapCheckbox>(new Map())
const refList = useRef<HTMLInputElement[]>([])

const registerExpandableRow = useCallback(
(rowId: string, expanded = false) => {
Expand Down Expand Up @@ -196,99 +195,91 @@
const handlers: (() => void)[] = []

if (refList.current) {
const handleHover = (checkbox: HTMLInputElement, event: MouseEvent) => {
const isShiftPressed = event.shiftKey

const isHoverActive =
isShiftPressed && lastCheckedIndex !== null && !checkbox.disabled

if (isHoverActive) {
setInRange(prev => new Set([...prev, checkbox.value]))
}

if (!lastCheckedIndex && !checkbox.disabled) {
setLastCheckedIndex(checkbox.value)
}
}

const handleClickRange = (checkbox: HTMLInputElement) => {
const shouldShiftEvent = inRange.size > 0
const isClickInsideRange = inRange.has(checkbox.value)

if (shouldShiftEvent && isClickInsideRange) {
let checkboxRows: RowState = {}
// Ensure that only existing checkboxes are in refList
refList.current = refList.current.filter(checkbox =>
document.contains(checkbox),
)

refList.current.forEach((value, key) => {
if (inRange.has(key)) {
checkboxRows = {
...checkboxRows,
// handle the conflict event ( click and onChange in the same time on the last checkbox click)
[key]: key === checkbox.value ? !value.checked : value.checked,
const handleClickRange = (
currentCheckbox: HTMLInputElement,
index: number,
isShiftPressed: boolean,
) => {
if (isShiftPressed) {
const checkboxesInRange: string[] = []

// Get the index of the lastCheckedCheckbox
const targetCheckbox = refList.current.find(
checkbox => checkbox.value === lastCheckedCheckbox,
)
const lastCheckedIndex = targetCheckbox
? refList.current.indexOf(targetCheckbox)
: undefined

Check warning on line 217 in packages/ui/src/components/List/ListContext.tsx

View check run for this annotation

Codecov / codecov/patch

packages/ui/src/components/List/ListContext.tsx#L217

Added line #L217 was not covered by tests

if (lastCheckedIndex !== undefined) {
const start =
Math.min(lastCheckedIndex, index) -
(Math.min(lastCheckedIndex, index) === index ? 1 : 0)
const end = Math.max(lastCheckedIndex, index)

refList.current.forEach((checkbox, key) => {
if (start < key && key <= end) {
if (!checkbox.disabled) {
checkboxesInRange.push(checkbox.value)
}
}
}
})
const state = checkStateOfCheckboxs(checkboxRows)
const checkboxIds = Object.keys(checkboxRows)
})

if (state === true) {
selectRows(checkboxIds, false)
}
if ([false, 'indeterminate'].includes(state)) {
selectRows(checkboxIds, true)
selectRows(checkboxesInRange, currentCheckbox.checked) // (un)selects the rows in the range
setLastCheckedCheckbox(currentCheckbox.value)
}
}
} else if (index === 0) setLastCheckedCheckbox(undefined)

/**
* Handle the case when there is multiple selected value during a time, and the user click without shift event
*/
setTimeout(() => {
// clean up
setInRange(new Set([]))
setLastCheckedIndex(checkbox.value)
setLastCheckedCheckbox(currentCheckbox.value)
}, 1)
}

const handleOnChange = (checkbox: HTMLInputElement) => {
const shouldHandleEvent = inRange.size === 0

if (shouldHandleEvent) {
selectRows([checkbox.value], !checkbox.checked)
}
setLastCheckedIndex(checkbox.value)
}

refList.current.forEach(checkbox => {
function clickHandler(this: HTMLInputElement) {
handleClickRange(this)
}

function hoverHandler(this: HTMLInputElement, event: MouseEvent) {
handleHover(this, event)
}

function changeHandler(this: HTMLInputElement) {
handleOnChange(this)
refList.current.forEach((checkbox, index) => {
function clickHandler(this: HTMLInputElement, event: MouseEvent) {
handleClickRange(this, index, event.shiftKey)
}

checkbox.addEventListener('change', changeHandler)
checkbox.addEventListener('click', clickHandler)
checkbox.addEventListener('mouseover', hoverHandler)

handlers.push(() => {
checkbox.removeEventListener('change', changeHandler)
checkbox.removeEventListener('click', clickHandler)
checkbox.removeEventListener('mouseover', hoverHandler)
})
})
}

return () => {
handlers.forEach(cleanup => cleanup())
}
}, [inRange, lastCheckedIndex, selectRows])
}, [lastCheckedCheckbox, selectRows])

useEffect(subscribeHandler, [subscribeHandler])

const handleOnChange = useCallback(
(value: string, checked: boolean) => {
selectRows([value], !checked)
setLastCheckedCheckbox(value)
},
[selectRows],
)

useEffect(() => {
// Re-arrange refList everytime it is modified in order to keep the write order of the elements in it.
const allCheckboxes = [
...document.querySelectorAll('input[type="checkbox"]:not([value="all"])'),
].map(element => element as HTMLInputElement)
refList.current = allCheckboxes.map(checkbox => checkbox)
}, [refList.current.length])
Comment on lines +275 to +281
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By doing this you take the risk to select every checkbox in the page. Let's say you have a 2 list in the same page you will select checkboxes for both list while only one is being edited.

We should avoid using a querySelector for interacting with the list / table


const value = useMemo<ListContextValue>(
() => ({
allRowSelectValue,
Expand All @@ -300,7 +291,6 @@
expandedRowIds,
expandRow,
inRange,
mapCheckbox: refList.current,
registerExpandableRow,
registerSelectableRow,
selectable,
Expand All @@ -309,6 +299,8 @@
selectRow,
unselectAll,
unselectRow,
refList,
handleOnChange,
}),
[
allRowSelectValue,
Expand All @@ -329,6 +321,7 @@
selectRow,
unselectAll,
unselectRow,
handleOnChange,
],
)

Expand Down
15 changes: 7 additions & 8 deletions packages/ui/src/components/List/Row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,10 @@ export const Row = forwardRef<HTMLTableRowElement, RowProps>(
registerSelectableRow,
selectedRowIds,
expandButton,
mapCheckbox,
inRange,
columns,
refList,
handleOnChange,
} = useListContext()

const theme = useTheme()
Expand Down Expand Up @@ -277,16 +278,13 @@ export const Row = forwardRef<HTMLTableRowElement, RowProps>(
const canClickRowToExpand = !disabled && !!expandable && !expandButton

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

if (current) {
mapCheckbox.set(id, current)
if (refAtEffectStart && current && !refAtEffectStart.includes(current)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!refAtEffectStart.includes(current)

it's work ?

refList.current.push(current)
}

return () => {
mapCheckbox.delete(id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we must remove the checkbox of the refList.current when the row is delete.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is done somewhere else (l.199 of ListContext.tsx)

}
}, [mapCheckbox, id])
}, [refList])

const childrenLength =
Children.count(children) + (selectable ? 1 : 0) + (expandButton ? 1 : 0)
Expand Down Expand Up @@ -341,6 +339,7 @@ export const Row = forwardRef<HTMLTableRowElement, RowProps>(
ref={checkboxRef}
disabled={isSelectDisabled}
inRange={inRange?.has(id)}
onChange={() => handleOnChange(id, selectedRowIds[id])}
/>
</Tooltip>
</StyledCheckboxContainer>
Expand Down
109 changes: 54 additions & 55 deletions packages/ui/src/components/List/__stories__/Selectable.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,67 @@
import type { StoryFn } from '@storybook/react'
import { useState } from 'react'
import { List } from '..'
import { Button } from '../../Button'
import { Stack } from '../../Stack'
import { Text } from '../../Text'
import { Template } from './Template.stories'
import { data } from './resources'
import { columns, data } from './resources'

export const Selectable = Template.bind({})
export const Selectable: StoryFn<typeof List> = args => {
const [clicked, setClick] = useState(true)

Selectable.args = {
...Template.args,
selectable: true,
children: (
return (
<>
{data.map(planet => (
<List.Row
key={planet.id}
id={planet.id}
disabled={planet.id === 'mercury'}
selectDisabled={
planet.id === 'home-sweet-home'
? "Earth isn't selectable"
: undefined
}
expandable={false}
>
<List.Cell>
{planet.name}
{planet.id === 'mercury'
? ' (Not selectable because the row itself is disabled)'
: ''}
{planet.id === 'home-sweet-home'
? ' (Not selectable because of prop `selectDisabled`)'
: ''}
</List.Cell>
<List.Cell>{planet.perihelion}AU</List.Cell>
<List.Cell>{planet.aphelion}AU</List.Cell>
</List.Row>
))}
<List.SelectBar data={data} idKey="id">
{({ selectedItems, unselectAll }) => (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={2}
>
<Text variant="bodyStrong" as="p" sentiment="primary">
{selectedItems.length} item(s) selected
</Text>
<Button
size="small"
onClick={() => {
// oxlint-disable-next-line eslint/no-alert
alert('elements could be deleted')
unselectAll()
}}
<List {...args} columns={columns} selectable>
{data.map(planet =>
planet.id !== 'mars' || clicked ? (
<List.Row
key={planet.id}
id={planet.id}
expandable="Planet description"
>
Delete
</Button>
</Stack>
<List.Cell>{planet.id}</List.Cell>
<List.Cell>{planet.name}</List.Cell>
<List.Cell>{planet.perihelion}AU</List.Cell>
<List.Cell>{planet.aphelion}AU</List.Cell>
</List.Row>
) : null,
)}
</List.SelectBar>

<List.SelectBar data={data} idKey="id">
{({ selectedItems, unselectAll }) => (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={2}
>
<Text variant="bodyStrong" as="p" sentiment="primary">
{selectedItems.length} item(s) selected (
{selectedItems.map(
(item, index) => `${index > 0 ? ', ' : ''}${item.name}`,
)}
)
</Text>
<Button
size="small"
onClick={() => {
// oxlint-disable-next-line eslint/no-alert
alert('elements could be deleted')
unselectAll()
}}
>
Delete
</Button>
</Stack>
)}
</List.SelectBar>
</List>

<button type="button" onClick={() => setClick(!clicked)}>
{clicked ? 'remove' : 'add'} mars as a planet
</button>
</>
),
)
}

Selectable.parameters = {
Expand Down
Loading
Loading