From 2cdf4aeaa1a7e20cf951956559cbde5478d480b1 Mon Sep 17 00:00:00 2001 From: ChrisCoastal Date: Mon, 30 Oct 2023 12:25:47 -0700 Subject: [PATCH] feat(dropdownitems): add DropdownItemCheckbox and DropdownItemRadio re 916 --- src/components/Dropdown/Dropdown.spec.tsx | 78 +++++++++++++++ src/components/Dropdown/Dropdown.tsx | 31 +++++- src/components/Dropdown/DropdownContext.tsx | 5 +- src/components/Dropdown/DropdownItem.tsx | 1 + .../Dropdown/DropdownItemCheckbox.tsx | 96 +++++++++++++++++++ src/components/Dropdown/DropdownItemRadio.tsx | 93 ++++++++++++++++++ src/components/Dropdown/theme.ts | 1 + 7 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 src/components/Dropdown/DropdownItemCheckbox.tsx create mode 100644 src/components/Dropdown/DropdownItemRadio.tsx diff --git a/src/components/Dropdown/Dropdown.spec.tsx b/src/components/Dropdown/Dropdown.spec.tsx index c5f1895c82..713461a515 100644 --- a/src/components/Dropdown/Dropdown.spec.tsx +++ b/src/components/Dropdown/Dropdown.spec.tsx @@ -186,6 +186,46 @@ describe('Components / Dropdown', () => { expect(screen.getByRole('link')).toBe(item); }); }); + + describe('Dropdown item radio', async () => { + it('should toggle radio item to checked when clicked', async () => { + const user = userEvent.setup(); + render(); + + await act(() => user.click(button())); + expect(screen.getByRole('radio', { name: 'Berlin' })).not.toBeChecked(); + await act(() => userEvent.click(screen.getByText('Berlin'))); + + expect(screen.getByRole('radio', { name: 'Berlin' })).toBeChecked(); + }); + + it('should toggle radio when another radio in group in checked', async () => { + const user = userEvent.setup(); + render(); + + await act(() => user.click(button())); + expect(screen.getByRole('radio', { name: 'Berlin' })).not.toBeChecked(); + await act(() => userEvent.click(screen.getByText('Berlin'))); + + expect(screen.getByRole('radio', { name: 'Berlin' })).toBeChecked(); + await act(() => userEvent.click(screen.getByText('Tokyo'))); + expect(screen.getByRole('radio', { name: 'Berlin' })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: 'Tokyo' })).toBeChecked(); + }); + }); + + describe('Dropdown item checkbox', async () => { + it('should toggle checkbox item to checked when clicked', async () => { + const user = userEvent.setup(); + render(); + + await act(() => user.click(button())); + expect(screen.getByRole('checkbox', { name: 'House' })).not.toBeChecked(); + await act(() => userEvent.click(screen.getByText('House'))); + + expect(screen.getByRole('checkbox', { name: 'House' })).toBeChecked(); + }); + }); }); const TestDropdown: FC> = ({ @@ -216,6 +256,44 @@ const TestDropdown: FC> = ({ ); +const TestDropdownInputs: FC> = ({ + dismissOnClick = true, + inline = false, + disabled, + trigger, + renderTrigger, +}) => ( + + + Select City + + + Berlin + + + Chicago + + + Lagos + + + Tokyo + + + Apartment + House + Hotel + +); + const button = () => screen.getByRole('button', { name: /Dropdown button/i }); const dropdown = () => screen.queryByTestId('flowbite-dropdown'); diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index d37d3dde00..d15d71e297 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -27,6 +27,8 @@ import { DropdownContext } from './DropdownContext'; import { DropdownDivider, type FlowbiteDropdownDividerTheme } from './DropdownDivider'; import { DropdownHeader, type FlowbiteDropdownHeaderTheme } from './DropdownHeader'; import { DropdownItem, type FlowbiteDropdownItemTheme } from './DropdownItem'; +import { DropdownItemCheckbox } from './DropdownItemCheckbox'; +import { DropdownItemRadio } from './DropdownItemRadio'; export interface FlowbiteDropdownFloatingTheme extends FlowbiteFloatingTheme, @@ -56,6 +58,13 @@ export interface DropdownProps 'data-testid'?: string; } +type RadioGroupId = string; + +export interface DropdownInputsState { + radios: { [key: RadioGroupId]: string | null }; + checkboxes: string[]; +} + const icons: Record>> = { top: HiOutlineChevronUp, right: HiOutlineChevronRight, @@ -125,6 +134,13 @@ const DropdownComponent: FC = ({ const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); const [selectedIndex, setSelectedIndex] = useState(null); + const [checkedInputs, setCheckedInputs] = useState<{ + radios: { [key: string]: string | null }; + checkboxes: string[]; + }>({ + radios: {}, + checkboxes: [], + }); const [buttonWidth, setButtonWidth] = useState(undefined); const elementsRef = useRef>([]); const labelsRef = useRef>([]); @@ -141,10 +157,13 @@ const DropdownComponent: FC = ({ ...buttonProps } = theirProps; - const handleSelect = useCallback((index: number | null) => { - setSelectedIndex(index); - setOpen(false); - }, []); + const handleSelect = useCallback( + (index: number | null) => { + dismissOnClick ? setSelectedIndex(null) : setSelectedIndex(index); + dismissOnClick && setOpen(false); + }, + [dismissOnClick], + ); const handleTypeaheadMatch = useCallback( (index: number | null) => { @@ -211,6 +230,8 @@ const DropdownComponent: FC = ({ activeIndex, dismissOnClick, getItemProps, + checkedInputs, + setCheckedInputs, handleSelect, }} > @@ -251,6 +272,8 @@ DropdownDivider.displayName = 'Dropdown.Divider'; export const Dropdown = Object.assign(DropdownComponent, { Item: DropdownItem, + ItemCheckbox: DropdownItemCheckbox, + ItemRadio: DropdownItemRadio, Header: DropdownHeader, Divider: DropdownDivider, }); diff --git a/src/components/Dropdown/DropdownContext.tsx b/src/components/Dropdown/DropdownContext.tsx index 43a177d2c6..7d6b58b94a 100644 --- a/src/components/Dropdown/DropdownContext.tsx +++ b/src/components/Dropdown/DropdownContext.tsx @@ -1,15 +1,18 @@ 'use client'; import type { useInteractions } from '@floating-ui/react'; +import type { Dispatch, SetStateAction } from 'react'; import { createContext, useContext } from 'react'; import type { DeepPartial } from '../../types'; -import type { FlowbiteDropdownTheme } from './Dropdown'; +import type { DropdownInputsState, FlowbiteDropdownTheme } from './Dropdown'; type DropdownContext = { theme?: DeepPartial; activeIndex: number | null; dismissOnClick?: boolean; getItemProps: ReturnType['getItemProps']; + checkedInputs: DropdownInputsState; + setCheckedInputs: Dispatch>; handleSelect: (index: number | null) => void; }; diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index c4cf054576..c8d9876950 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -12,6 +12,7 @@ export interface FlowbiteDropdownItemTheme { container: string; base: string; icon: string; + input: string; } export type DropdownItemProps = { diff --git a/src/components/Dropdown/DropdownItemCheckbox.tsx b/src/components/Dropdown/DropdownItemCheckbox.tsx new file mode 100644 index 0000000000..890a648903 --- /dev/null +++ b/src/components/Dropdown/DropdownItemCheckbox.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useListItem } from '@floating-ui/react'; +import type { ComponentProps, ComponentPropsWithoutRef, ElementType, FC, Ref, RefCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { mergeDeep } from '../../helpers/merge-deep'; +import { getTheme } from '../../theme-store'; +import type { DeepPartial } from '../../types'; +import type { ButtonBaseProps } from '../Button/ButtonBase'; +import { ButtonBase } from '../Button/ButtonBase'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { useDropdownContext } from './DropdownContext'; +import type { FlowbiteDropdownItemTheme } from './DropdownItem'; + +export type DropdownItemCheckboxProps = { + label: string; + value?: string; + name?: string; + icon?: FC>; + onClick?: () => void; + theme?: DeepPartial; +} & ComponentPropsWithoutRef; + +export const DropdownItemCheckbox = forwardRef(function DropdownItemCheckBox( + { + label, + value, + name, + children, + className, + icon: Icon, + onClick, + theme: customTheme = {}, + ...props + }: DropdownItemCheckboxProps, + inputRef: Ref, +) { + const { ref, index } = useListItem({ + label: typeof children === 'string' ? children : undefined, + }); + const { activeIndex, checkedInputs, setCheckedInputs, getItemProps, handleSelect } = useDropdownContext(); + const inputId = label + index; + const isActive = activeIndex === index; + const isChecked = checkedInputs.checkboxes.includes(inputId); + const theme = mergeDeep(getTheme().dropdown.floating.item, customTheme); + + const theirProps = props as ButtonBaseProps; + + const handleCheckboxSelect = useCallback( + (index: number | null, inputId: string) => { + setCheckedInputs((prev) => { + return prev.checkboxes.includes(inputId) + ? { + ...prev, + checkboxes: prev.checkboxes.filter((item) => item !== inputId), + } + : { ...prev, checkboxes: [...prev.checkboxes, inputId] }; + }); + + handleSelect(index); + }, + [handleSelect, setCheckedInputs], + ); + + return ( +
  • + +
  • + ); +}); diff --git a/src/components/Dropdown/DropdownItemRadio.tsx b/src/components/Dropdown/DropdownItemRadio.tsx new file mode 100644 index 0000000000..6f4f65dd20 --- /dev/null +++ b/src/components/Dropdown/DropdownItemRadio.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useListItem } from '@floating-ui/react'; +import type { ComponentProps, ComponentPropsWithoutRef, ElementType, FC, Ref, RefCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { mergeDeep } from '../../helpers/merge-deep'; +import { getTheme } from '../../theme-store'; +import type { DeepPartial } from '../../types'; +import type { ButtonBaseProps } from '../Button/ButtonBase'; +import { ButtonBase } from '../Button/ButtonBase'; +import { Radio } from '../Radio/Radio'; +import { useDropdownContext } from './DropdownContext'; +import type { FlowbiteDropdownItemTheme } from './DropdownItem'; + +export type DropdownItemRadioProps = { + label: string; + value?: string; + name?: string; + icon?: FC>; + onClick?: () => void; + theme?: DeepPartial; +} & ComponentPropsWithoutRef; + +export const DropdownItemRadio = forwardRef(function DropdownItemRadio( + { + label, + value, + name = 'radiogroup', + children, + className, + icon: Icon, + onClick, + theme: customTheme = {}, + ...props + }: DropdownItemRadioProps, + inputRef: Ref, +) { + const { ref, index } = useListItem({ + label: typeof children === 'string' ? children : undefined, + }); + const { activeIndex, checkedInputs, setCheckedInputs, getItemProps, handleSelect } = useDropdownContext(); + const inputId = label + index; + const isActive = activeIndex === index; + const isChecked = checkedInputs.radios[name] === inputId; + const theme = mergeDeep(getTheme().dropdown.floating.item, customTheme); + + const theirProps = props as ButtonBaseProps; + + const handleRadioSelect = useCallback( + (index: number | null, inputId: string, radioGroup: string) => { + setCheckedInputs((prev) => { + return { + ...prev, + radios: { ...prev.radios, [radioGroup]: inputId }, + }; + }); + handleSelect(index); + }, + [handleSelect, setCheckedInputs], + ); + + return ( +
  • + +
  • + ); +}); diff --git a/src/components/Dropdown/theme.ts b/src/components/Dropdown/theme.ts index ea43d17017..9c52e341a6 100644 --- a/src/components/Dropdown/theme.ts +++ b/src/components/Dropdown/theme.ts @@ -23,6 +23,7 @@ export const dropdownTheme: FlowbiteDropdownTheme = { container: '', base: 'flex items-center justify-start py-2 px-4 text-sm text-gray-700 cursor-pointer w-full hover:bg-gray-100 focus:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 focus:outline-none dark:hover:text-white dark:focus:bg-gray-600 dark:focus:text-white', icon: 'mr-2 h-4 w-4', + input: 'mr-2', }, style: { dark: 'bg-gray-900 text-white dark:bg-gray-700',