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',