Skip to content

Commit

Permalink
feat(dropdownitems): add DropdownItemCheckbox and DropdownItemRadio
Browse files Browse the repository at this point in the history
re 916
  • Loading branch information
ChrisCoastal committed Oct 30, 2023
1 parent 8ddebe1 commit 2cdf4ae
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 5 deletions.
78 changes: 78 additions & 0 deletions src/components/Dropdown/Dropdown.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestDropdownInputs dismissOnClick={false} />);

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(<TestDropdownInputs dismissOnClick={false} />);

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(<TestDropdownInputs dismissOnClick={false} />);

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<Partial<DropdownProps>> = ({
Expand Down Expand Up @@ -216,6 +256,44 @@ const TestDropdown: FC<Partial<DropdownProps>> = ({
</Dropdown>
);

const TestDropdownInputs: FC<Partial<DropdownProps>> = ({
dismissOnClick = true,
inline = false,
disabled,
trigger,
renderTrigger,
}) => (
<Dropdown
label="Dropdown button"
placement="right"
dismissOnClick={dismissOnClick}
inline={inline}
trigger={trigger}
disabled={disabled}
renderTrigger={renderTrigger}
>
<Dropdown.Header>
<span className="block text-sm">Select City</span>
</Dropdown.Header>
<Dropdown.ItemRadio label="berlin" name="city">
Berlin
</Dropdown.ItemRadio>
<Dropdown.ItemRadio label="chicago" name="city">
Chicago
</Dropdown.ItemRadio>
<Dropdown.ItemRadio label="lagos" name="city">
Lagos
</Dropdown.ItemRadio>
<Dropdown.ItemRadio label="tokyo" name="city">
Tokyo
</Dropdown.ItemRadio>
<Dropdown.Divider />
<Dropdown.ItemCheckbox label="apartment">Apartment</Dropdown.ItemCheckbox>
<Dropdown.ItemCheckbox label="house">House</Dropdown.ItemCheckbox>
<Dropdown.ItemCheckbox label="hotel">Hotel</Dropdown.ItemCheckbox>
</Dropdown>
);

const button = () => screen.getByRole('button', { name: /Dropdown button/i });

const dropdown = () => screen.queryByTestId('flowbite-dropdown');
Expand Down
31 changes: 27 additions & 4 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, FC<ComponentProps<'svg'>>> = {
top: HiOutlineChevronUp,
right: HiOutlineChevronRight,
Expand Down Expand Up @@ -125,6 +134,13 @@ const DropdownComponent: FC<DropdownProps> = ({
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [checkedInputs, setCheckedInputs] = useState<{
radios: { [key: string]: string | null };
checkboxes: string[];
}>({
radios: {},
checkboxes: [],
});
const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
Expand All @@ -141,10 +157,13 @@ const DropdownComponent: FC<DropdownProps> = ({
...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) => {
Expand Down Expand Up @@ -211,6 +230,8 @@ const DropdownComponent: FC<DropdownProps> = ({
activeIndex,
dismissOnClick,
getItemProps,
checkedInputs,
setCheckedInputs,
handleSelect,
}}
>
Expand Down Expand Up @@ -251,6 +272,8 @@ DropdownDivider.displayName = 'Dropdown.Divider';

export const Dropdown = Object.assign(DropdownComponent, {
Item: DropdownItem,
ItemCheckbox: DropdownItemCheckbox,
ItemRadio: DropdownItemRadio,
Header: DropdownHeader,
Divider: DropdownDivider,
});
5 changes: 4 additions & 1 deletion src/components/Dropdown/DropdownContext.tsx
Original file line number Diff line number Diff line change
@@ -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<FlowbiteDropdownTheme>;
activeIndex: number | null;
dismissOnClick?: boolean;
getItemProps: ReturnType<typeof useInteractions>['getItemProps'];
checkedInputs: DropdownInputsState;
setCheckedInputs: Dispatch<SetStateAction<DropdownInputsState>>;
handleSelect: (index: number | null) => void;
};

Expand Down
1 change: 1 addition & 0 deletions src/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface FlowbiteDropdownItemTheme {
container: string;
base: string;
icon: string;
input: string;
}

export type DropdownItemProps<T extends ElementType = 'button'> = {
Expand Down
96 changes: 96 additions & 0 deletions src/components/Dropdown/DropdownItemCheckbox.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends ElementType = 'button'> = {
label: string;
value?: string;
name?: string;
icon?: FC<ComponentProps<'svg'>>;
onClick?: () => void;
theme?: DeepPartial<FlowbiteDropdownItemTheme>;
} & ComponentPropsWithoutRef<T>;

export const DropdownItemCheckbox = forwardRef(function DropdownItemCheckBox<T extends ElementType = 'button'>(
{
label,
value,
name,
children,
className,
icon: Icon,
onClick,
theme: customTheme = {},
...props
}: DropdownItemCheckboxProps<T>,
inputRef: Ref<HTMLInputElement>,
) {
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<T>;

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 (
<li role="menuitem" className={theme.container}>
<label htmlFor={inputId}>
<ButtonBase
ref={ref as RefCallback<T>}
className={twMerge(theme.base, className)}
{...theirProps}
{...getItemProps({
onClick: () => {
onClick && onClick();
handleCheckboxSelect(index, inputId);
},
})}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
ref={inputRef}
checked={isChecked}
value={value || label}
name={name}
id={inputId}
onChange={() => null}
className={theme.input}
/>
{Icon && <Icon className={theme.icon} />}
{children ? children : label}
</ButtonBase>
</label>
</li>
);
});
93 changes: 93 additions & 0 deletions src/components/Dropdown/DropdownItemRadio.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends ElementType = 'button'> = {
label: string;
value?: string;
name?: string;
icon?: FC<ComponentProps<'svg'>>;
onClick?: () => void;
theme?: DeepPartial<FlowbiteDropdownItemTheme>;
} & ComponentPropsWithoutRef<T>;

export const DropdownItemRadio = forwardRef(function DropdownItemRadio<T extends ElementType = 'button'>(
{
label,
value,
name = 'radiogroup',
children,
className,
icon: Icon,
onClick,
theme: customTheme = {},
...props
}: DropdownItemRadioProps<T>,
inputRef: Ref<HTMLInputElement>,
) {
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<T>;

const handleRadioSelect = useCallback(
(index: number | null, inputId: string, radioGroup: string) => {
setCheckedInputs((prev) => {
return {
...prev,
radios: { ...prev.radios, [radioGroup]: inputId },
};
});
handleSelect(index);
},
[handleSelect, setCheckedInputs],
);

return (
<li role="menuitem" className={theme.container}>
<label htmlFor={inputId}>
<ButtonBase
ref={ref as RefCallback<T>}
className={twMerge(theme.base, className)}
{...theirProps}
{...getItemProps({
onClick: () => {
onClick && onClick();
handleRadioSelect(index, inputId, name);
},
})}
tabIndex={isActive ? 0 : -1}
>
<Radio
ref={inputRef}
checked={isChecked}
value={value || label}
name={name}
id={inputId}
onChange={() => null}
className={theme.input}
/>
{Icon && <Icon className={theme.icon} />}
{children ? children : label}
</ButtonBase>
</label>
</li>
);
});
1 change: 1 addition & 0 deletions src/components/Dropdown/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down

0 comments on commit 2cdf4ae

Please sign in to comment.