Skip to content

Commit

Permalink
feat(dropdown): sync dropdown items dynamically on children updates
Browse files Browse the repository at this point in the history
  • Loading branch information
Powerplex committed Nov 22, 2023
1 parent df2d0f5 commit 2e4b827
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 78 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 12 additions & 25 deletions packages/components/dropdown/src/Dropdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,6 @@ const meta: Meta<typeof Dropdown> = {

export default meta

const books = [
{ value: 'book-1', text: 'To Kill a Mockingbird' },
{ value: 'book-2', text: 'War and Peace' },
{ value: 'book-3', text: 'The Idiot', disabled: true },
{ value: 'book-4', text: 'A Picture of Dorian Gray' },
{ value: 'book-5', text: '1984' },
{ value: 'book-6', text: 'Pride and Prejudice' },
{ value: 'book-7', text: 'Meditations' },
{
value: 'book-8',
text: 'The Brothers Karamazov',
},
{ value: 'book-9', text: 'Anna Karenina' },
{ value: 'book-10', text: 'Crime and Punishment' },
]

export const Default: StoryFn = _args => {
const [removeItems, setRemoveItems] = useState(false)

Expand All @@ -38,15 +22,18 @@ export const Default: StoryFn = _args => {
<Dropdown>
<Dropdown.Trigger />
<Dropdown.Items aria-label="Job type">
{books.map(item => {
if (removeItems && item.value === 'book-1') return null

return (
<Dropdown.Item value={item.value} key={item.value} disabled={item.disabled}>
{item.text}
</Dropdown.Item>
)
})}
{!removeItems && <Dropdown.Item value="book-1">To Kill a Mockingbird</Dropdown.Item>}
<Dropdown.Item value="book-2">War and Peace</Dropdown.Item>
<Dropdown.Item value="book-3" disabled>
The Idiot
</Dropdown.Item>
{!removeItems && <Dropdown.Item value="book-4">A Picture of Dorian Gray</Dropdown.Item>}
<Dropdown.Item value="book-5">1984</Dropdown.Item>
<Dropdown.Item value="book-6">Pride and Prejudice</Dropdown.Item>
<Dropdown.Item value="book-7">Meditations</Dropdown.Item>
<Dropdown.Item value="book-8">The Brothers Karamazov</Dropdown.Item>
<Dropdown.Item value="book-9">Anna Karenina</Dropdown.Item>
<Dropdown.Item value="book-10">Crime and Punishment</Dropdown.Item>
</Dropdown.Items>
</Dropdown>
</div>
Expand Down
19 changes: 15 additions & 4 deletions packages/components/dropdown/src/Dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import { Dropdown } from './Dropdown'
import { Dropdown } from '.'

describe('Dropdown', () => {
it('should render', () => {
render(<Dropdown />)
it('should render list of options', () => {
render(
<Dropdown>
<Dropdown.Trigger />
<Dropdown.Items aria-label="Job type">
<Dropdown.Item value="book-2">War and Peace</Dropdown.Item>
<Dropdown.Item value="book-5">1984</Dropdown.Item>
<Dropdown.Item value="book-6">Pride and Prejudice</Dropdown.Item>
</Dropdown.Items>
</Dropdown>
)

expect(screen.getByText(/dropdown/)).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'War and Peace' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '1984' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Pride and Prejudice' })).toBeInTheDocument()
})
})
55 changes: 26 additions & 29 deletions packages/components/dropdown/src/DropdownContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { useSelect } from 'downshift'
import { createContext, PropsWithChildren, useContext, useState } from 'react'
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'

import { type DownshiftState, type DropdownItem, type ItemsMap } from './types'
import { getElementByIndex } from './utils'
import { getElementByIndex, getOrderedItems } from './utils'
export interface DropdownContextState extends DownshiftState {
/**
* Used by `Dropdown.Item` to register it's data in the global context.
* It makes the context aware of the items to manage.
*/
registerItem: (item: DropdownItem) => void
unregisterItem: (value: string) => void
computedItems: ItemsMap
higlightedItem: DropdownItem | undefined
}
Expand Down Expand Up @@ -53,37 +47,40 @@ export const DropdownProvider = ({ children }: DropdownContextProps) => {
// environment?: Environment
})

const registerItem = (item: DropdownItem) => {
console.log('REGISTER => ', item.value)

setComputedItems(map => {
return new Map(map.set(item.value, item))
/**
* Indices in a Map are set when an element is added to the Map.
* If for some reason, in the Dropdown:
* - children order changes
* - children are added
* - children are removed
*
* The Map must be rebuilt from the new children in order to preserve logical indices.
*
* Downshift is heavily indices based for keyboard navigation, so it it important.
*/
const syncItems = () => {
const newMap: ItemsMap = new Map()

getOrderedItems(children).forEach(({ value, disabled, children }) => {
newMap.set(value, {
value,
disabled: !!disabled,
text: children,
})
})
}

const unregisterItem = (value: string) => {
console.log('UNREGISTER => ', value)

// setComputedItems(map => {
// map.delete(value)

// return new Map(map)
// })

// const newComputedItems = new Map(computedItems)
// newComputedItems.delete(value)
// setComputedItems(newComputedItems)
setComputedItems(newMap)
}

console.log(computedItems)
useEffect(() => {
syncItems()
}, [children])

Check warning on line 77 in packages/components/dropdown/src/DropdownContext.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'syncItems'. Either include it or remove the dependency array

return (
<DropdownContext.Provider
value={{
...downshift,
computedItems,
registerItem,
unregisterItem,
higlightedItem: getElementByIndex(computedItems, downshift.highlightedIndex),
}}
>
Expand Down
28 changes: 8 additions & 20 deletions packages/components/dropdown/src/DropdownItem.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,24 @@
import { cx } from 'class-variance-authority'
import { useEffect } from 'react'

import { useDropdown } from './DropdownContext'
import { getIndexByKey } from './utils'

export interface ItemProps {
disabled?: boolean
value: string
children: string
}

export const Item = ({
disabled = false,
value,
children, // TODO: allow more than string and implement Dropdown.ItemText
}: {
disabled?: boolean
value: string
children: string
}) => {
const {
computedItems,
selectedItem,
getItemProps,
registerItem,
unregisterItem,
higlightedItem,
} = useDropdown()
}: ItemProps) => {
const { computedItems, selectedItem, getItemProps, higlightedItem } = useDropdown()

const index = getIndexByKey(computedItems, value)
const itemData = { disabled, value, text: children }

useEffect(() => {
registerItem(itemData)

return () => unregisterItem(value)
}, [])

return (
<li
className={cx(
Expand Down
23 changes: 23 additions & 0 deletions packages/components/dropdown/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import React, { type FC, isValidElement, type ReactElement, type ReactNode } from 'react'

import { type ItemProps } from './DropdownItem'
import { type ItemsMap } from './types'

export function getIndexByKey(map: ItemsMap, targetKey: string) {
Expand Down Expand Up @@ -27,3 +30,23 @@ export const getElementByIndex = (map: ItemsMap, index: number) => {

return key !== undefined ? map.get(key) : undefined
}

const getElementId = (element?: ReactElement) => {
return element ? (element.type as FC & { id?: string }).id : ''
}

export const getOrderedItems = (children: ReactNode, result: ItemProps[] = []): ItemProps[] => {
React.Children.forEach(children, child => {
if (!isValidElement(child)) return

if (getElementId(child) === 'Item') {
result.push(child.props as ItemProps)
}

if (child.props.children) {
getOrderedItems(child.props.children, result)
}
})

return result
}

0 comments on commit 2e4b827

Please sign in to comment.