diff --git a/front/src/modules/ui/dropdown/components/DropdownButton.tsx b/front/src/modules/ui/dropdown/components/DropdownButton.tsx
index 81cfb94526..c3839c67f0 100644
--- a/front/src/modules/ui/dropdown/components/DropdownButton.tsx
+++ b/front/src/modules/ui/dropdown/components/DropdownButton.tsx
@@ -8,12 +8,11 @@ import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useLis
import { useRecoilScopedFamilyState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedFamilyState';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
+import { HotkeyEffect } from '../../utilities/hotkey/components/HotkeyEffect';
import { useDropdownButton } from '../hooks/useDropdownButton';
import { dropdownButtonCustomHotkeyScopeScopedFamilyState } from '../states/dropdownButtonCustomHotkeyScopeScopedFamilyState';
import { DropdownRecoilScopeContext } from '../states/recoil-scope-contexts/DropdownRecoilScopeContext';
-import { HotkeyEffect } from './HotkeyEffect';
-
const StyledContainer = styled.div`
position: relative;
z-index: 100;
diff --git a/front/src/modules/ui/icon/index.ts b/front/src/modules/ui/icon/index.ts
index 5ad890a94f..b8ed49431b 100644
--- a/front/src/modules/ui/icon/index.ts
+++ b/front/src/modules/ui/icon/index.ts
@@ -23,6 +23,7 @@ export {
IconCheckbox,
IconChevronDown,
IconChevronLeft,
+ IconChevronRight,
IconChevronsRight,
IconCircleDot,
IconCirclePlus,
diff --git a/front/src/modules/ui/icon/types/IconComponent.ts b/front/src/modules/ui/icon/types/IconComponent.ts
new file mode 100644
index 0000000000..26c42b0260
--- /dev/null
+++ b/front/src/modules/ui/icon/types/IconComponent.ts
@@ -0,0 +1,3 @@
+import { ComponentType } from 'react';
+
+export type IconComponent = ComponentType<{ size: number }>;
diff --git a/front/src/modules/ui/menu-item/components/MenuItem.tsx b/front/src/modules/ui/menu-item/components/MenuItem.tsx
new file mode 100644
index 0000000000..4d701e3df9
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItem.tsx
@@ -0,0 +1,53 @@
+import { useTheme } from '@emotion/react';
+
+import { FloatingIconButton } from '@/ui/button/components/FloatingIconButton';
+import { FloatingIconButtonGroup } from '@/ui/button/components/FloatingIconButtonGroup';
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+
+import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
+import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
+import { MenuItemAccent } from '../types/MenuItemAccent';
+
+export type MenuItemIconButton = {
+ Icon: IconComponent;
+ onClick: () => void;
+};
+
+export type MenuItemProps = {
+ LeftIcon?: IconComponent;
+ accent: MenuItemAccent;
+ text: string;
+ iconButtons?: MenuItemIconButton[];
+ className: string;
+ onClick?: () => void;
+};
+
+export function MenuItem({
+ LeftIcon,
+ accent,
+ text,
+ iconButtons,
+ className,
+ onClick,
+}: MenuItemProps) {
+ const theme = useTheme();
+
+ const showIconButtons = Array.isArray(iconButtons) && iconButtons.length > 0;
+
+ return (
+
+
+ {showIconButtons && (
+
+ {iconButtons?.map(({ Icon, onClick }, index) => (
+ }
+ key={index}
+ onClick={onClick}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/MenuItemCommand.tsx b/front/src/modules/ui/menu-item/components/MenuItemCommand.tsx
new file mode 100644
index 0000000000..ce925b9a56
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItemCommand.tsx
@@ -0,0 +1,71 @@
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+
+import {
+ StyledMenuItemBase,
+ StyledMenuItemLabel,
+ StyledMenuItemLeftContent,
+} from '../internals/components/StyledMenuItemBase';
+
+const StyledMenuItemLabelText = styled(StyledMenuItemLabel)`
+ color: ${({ theme }) => theme.font.color.primary};
+`;
+
+const StyledBigIconContainer = styled.div`
+ align-items: center;
+ background: ${({ theme }) => theme.background.transparent.light};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+
+ display: flex;
+
+ flex-direction: row;
+
+ padding: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledCommandText = styled.div`
+ color: ${({ theme }) => theme.font.color.light};
+
+ padding-left: ${({ theme }) => theme.spacing(2)};
+ padding-right: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledMenuItemCommandContainer = styled(StyledMenuItemBase)`
+ height: 24px;
+`;
+
+export type MenuItemProps = {
+ LeftIcon?: IconComponent;
+ text: string;
+ command: string;
+ className: string;
+ onClick?: () => void;
+};
+
+export function MenuItemCommand({
+ LeftIcon,
+ text,
+ command,
+ className,
+ onClick,
+}: MenuItemProps) {
+ const theme = useTheme();
+
+ return (
+
+
+ {LeftIcon && (
+
+
+
+ )}
+
+ {text}
+
+
+ {command}
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/MenuItemMultiSelect.tsx b/front/src/modules/ui/menu-item/components/MenuItemMultiSelect.tsx
new file mode 100644
index 0000000000..c8940ff11a
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItemMultiSelect.tsx
@@ -0,0 +1,43 @@
+import styled from '@emotion/styled';
+
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+import { Checkbox } from '@/ui/input/checkbox/components/Checkbox';
+
+import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
+import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
+
+const StyledLeftContentWithCheckboxContainer = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+type OwnProps = {
+ LeftIcon?: IconComponent;
+ selected: boolean;
+ text: string;
+ className: string;
+ onSelectChange?: (selected: boolean) => void;
+};
+
+export function MenuItemMultiSelect({
+ LeftIcon,
+ text,
+ selected,
+ className,
+ onSelectChange,
+}: OwnProps) {
+ function handleOnClick() {
+ onSelectChange?.(!selected);
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/MenuItemNavigate.tsx b/front/src/modules/ui/menu-item/components/MenuItemNavigate.tsx
new file mode 100644
index 0000000000..0ae19e95f0
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItemNavigate.tsx
@@ -0,0 +1,30 @@
+import { useTheme } from '@emotion/react';
+
+import { IconChevronRight } from '@/ui/icon';
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+
+import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
+import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
+
+export type MenuItemProps = {
+ LeftIcon?: IconComponent;
+ text: string;
+ onClick?: () => void;
+ className: string;
+};
+
+export function MenuItemNavigate({
+ LeftIcon,
+ text,
+ className,
+ onClick,
+}: MenuItemProps) {
+ const theme = useTheme();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/MenuItemSelect.tsx b/front/src/modules/ui/menu-item/components/MenuItemSelect.tsx
new file mode 100644
index 0000000000..6502e1438a
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItemSelect.tsx
@@ -0,0 +1,50 @@
+import { css, useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+
+import { IconCheck } from '@/ui/icon';
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+
+import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
+import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
+
+const StyledMenuItemSelect = styled(StyledMenuItemBase)<{ selected: boolean }>`
+ ${({ theme, selected }) => {
+ if (selected) {
+ return css`
+ background: ${theme.background.transparent.light};
+ &:hover {
+ background: ${theme.background.transparent.medium};
+ }
+ `;
+ }
+ }}
+`;
+
+type OwnProps = {
+ LeftIcon?: IconComponent;
+ selected: boolean;
+ text: string;
+ className: string;
+ onClick?: () => void;
+};
+
+export function MenuItemSelect({
+ LeftIcon,
+ text,
+ selected,
+ className,
+ onClick,
+}: OwnProps) {
+ const theme = useTheme();
+
+ return (
+
+
+ {selected && }
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/MenuItemToggle.tsx b/front/src/modules/ui/menu-item/components/MenuItemToggle.tsx
new file mode 100644
index 0000000000..b742edb873
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/MenuItemToggle.tsx
@@ -0,0 +1,32 @@
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+import { Toggle } from '@/ui/input/toggle/components/Toggle';
+
+import { MenuItemLeftContent } from '../internals/components/MenuItemLeftContent';
+import { StyledMenuItemBase } from '../internals/components/StyledMenuItemBase';
+
+type OwnProps = {
+ LeftIcon?: IconComponent;
+ toggled: boolean;
+ text: string;
+ className: string;
+ onToggleChange?: (toggled: boolean) => void;
+};
+
+export function MenuItemToggle({
+ LeftIcon,
+ text,
+ toggled,
+ className,
+ onToggleChange,
+}: OwnProps) {
+ function handleOnClick() {
+ onToggleChange?.(!toggled);
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItem.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItem.stories.tsx
new file mode 100644
index 0000000000..2cee5fbefc
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItem.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemAccent } from '../../types/MenuItemAccent';
+import { MenuItem } from '../MenuItem';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItem',
+ component: MenuItem,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'Menu item text',
+ LeftIcon: IconBell,
+ accent: 'default',
+ iconButtons: [
+ { Icon: IconBell, onClick: () => console.log('Clicked') },
+ { Icon: IconBell, onClick: () => console.log('Clicked') },
+ ],
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { ...Default.args },
+ argTypes: {
+ accent: { control: false },
+ className: { control: false },
+ iconButtons: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'accents',
+ values: ['default', 'danger'] satisfies MenuItemAccent[],
+ props: (accent: MenuItemAccent) => ({ accent }),
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: state };
+ default:
+ return {};
+ }
+ },
+ },
+ {
+ name: 'iconButtons',
+ values: ['no icon button', 'two icon buttons'],
+ props: (choice: string) => {
+ switch (choice) {
+ case 'no icon button': {
+ return {
+ iconButtons: [],
+ };
+ }
+ case 'two icon buttons': {
+ return {
+ iconButtons: [
+ {
+ Icon: IconBell,
+ onClick: () =>
+ console.log('Clicked on first icon button'),
+ },
+ {
+ Icon: IconBell,
+ onClick: () =>
+ console.log('Clicked on second icon button'),
+ },
+ ],
+ };
+ }
+ }
+ },
+ },
+ ] as CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } as CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItemCommand.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItemCommand.stories.tsx
new file mode 100644
index 0000000000..0e15254276
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItemCommand.stories.tsx
@@ -0,0 +1,78 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemCommand } from '../MenuItemCommand';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItemCommand',
+ component: MenuItemCommand,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'First option',
+ command: '⌘ 1',
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { LeftIcon: IconBell, text: 'Menu item', command: '⌘1' },
+ argTypes: {
+ className: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'selected',
+ values: [true, false],
+ props: (selected: boolean) => ({ selected }),
+ labels: (selected: boolean) =>
+ selected ? 'Selected' : 'Not selected',
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: state };
+ default:
+ return {};
+ }
+ },
+ },
+ ] as CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } as CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItemMultiSelect.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItemMultiSelect.stories.tsx
new file mode 100644
index 0000000000..5b8c6cfc64
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItemMultiSelect.stories.tsx
@@ -0,0 +1,77 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemMultiSelect } from '../MenuItemMultiSelect';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItemMultiSelect',
+ component: MenuItemMultiSelect,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'First option',
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { LeftIcon: IconBell, text: 'Menu item' },
+ argTypes: {
+ className: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'selected',
+ values: [true, false],
+ props: (selected: boolean) => ({ selected }),
+ labels: (selected: boolean) =>
+ selected ? 'Selected' : 'Not selected',
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: state };
+ default:
+ return {};
+ }
+ },
+ },
+ ] as CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } as CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItemNavigate.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItemNavigate.stories.tsx
new file mode 100644
index 0000000000..24539f6698
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItemNavigate.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemNavigate } from '../MenuItemNavigate';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItemNavigate',
+ component: MenuItemNavigate,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'First option',
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { LeftIcon: IconBell, text: 'Menu item' },
+ argTypes: {
+ className: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: state };
+ default:
+ return {};
+ }
+ },
+ },
+ ] as CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } as CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItemSelect.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItemSelect.stories.tsx
new file mode 100644
index 0000000000..e01940473e
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItemSelect.stories.tsx
@@ -0,0 +1,78 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemSelect } from '../MenuItemSelect';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItemSelect',
+ component: MenuItemSelect,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'First option',
+ LeftIcon: IconBell,
+ },
+ argTypes: {
+ className: { control: false },
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { LeftIcon: IconBell, text: 'Menu item' },
+ argTypes: {
+ className: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover', 'selected', 'hover+selected'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: 'hover' };
+ case 'selected':
+ return { selected: true };
+ case 'hover+selected':
+ return { className: 'hover', selected: true };
+ default:
+ return {};
+ }
+ },
+ },
+ ] as CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } as CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/components/__stories__/MenuItemToggle.stories.tsx b/front/src/modules/ui/menu-item/components/__stories__/MenuItemToggle.stories.tsx
new file mode 100644
index 0000000000..4a9f5cedcf
--- /dev/null
+++ b/front/src/modules/ui/menu-item/components/__stories__/MenuItemToggle.stories.tsx
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { IconBell } from '@/ui/icon';
+import {
+ CatalogDecorator,
+ CatalogDimension,
+ CatalogOptions,
+} from '~/testing/decorators/CatalogDecorator';
+import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
+
+import { MenuItemToggle } from '../MenuItemToggle';
+
+const meta: Meta = {
+ title: 'UI/MenuItem/MenuItemToggle',
+ component: MenuItemToggle,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ text: 'First option',
+ },
+ decorators: [ComponentDecorator],
+};
+
+export const Catalog: Story = {
+ args: { LeftIcon: IconBell, text: 'Menu item' },
+ argTypes: {
+ className: { control: false },
+ },
+ parameters: {
+ pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'withIcon',
+ values: [true, false],
+ props: (withIcon: boolean) => ({
+ LeftIcon: withIcon ? IconBell : undefined,
+ }),
+ labels: (withIcon: boolean) =>
+ withIcon ? 'With left icon' : 'Without left icon',
+ },
+ {
+ name: 'toggled',
+ values: [true, false],
+ props: (toggled: boolean) => ({ toggled }),
+ labels: (toggled: boolean) => (toggled ? 'Toggled' : 'Not toggled'),
+ },
+ {
+ name: 'states',
+ values: ['default', 'hover'],
+ props: (state: string) => {
+ switch (state) {
+ case 'default':
+ return {};
+ case 'hover':
+ return { className: state };
+ default:
+ return {};
+ }
+ },
+ },
+ ] satisfies CatalogDimension[],
+ options: {
+ elementContainer: {
+ width: 200,
+ },
+ } satisfies CatalogOptions,
+ },
+ },
+ decorators: [CatalogDecorator],
+};
diff --git a/front/src/modules/ui/menu-item/internals/components/MenuItemLeftContent.tsx b/front/src/modules/ui/menu-item/internals/components/MenuItemLeftContent.tsx
new file mode 100644
index 0000000000..5035817296
--- /dev/null
+++ b/front/src/modules/ui/menu-item/internals/components/MenuItemLeftContent.tsx
@@ -0,0 +1,24 @@
+import { useTheme } from '@emotion/react';
+
+import { IconComponent } from '@/ui/icon/types/IconComponent';
+
+import {
+ StyledMenuItemLabel,
+ StyledMenuItemLeftContent,
+} from './StyledMenuItemBase';
+
+type OwnProps = {
+ LeftIcon?: IconComponent;
+ text: string;
+};
+
+export function MenuItemLeftContent({ LeftIcon, text }: OwnProps) {
+ const theme = useTheme();
+
+ return (
+
+ {LeftIcon && }
+ {text}
+
+ );
+}
diff --git a/front/src/modules/ui/menu-item/internals/components/StyledMenuItemBase.tsx b/front/src/modules/ui/menu-item/internals/components/StyledMenuItemBase.tsx
new file mode 100644
index 0000000000..73e8dbfa09
--- /dev/null
+++ b/front/src/modules/ui/menu-item/internals/components/StyledMenuItemBase.tsx
@@ -0,0 +1,85 @@
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+import { hoverBackground } from '@/ui/theme/constants/effects';
+
+import { MenuItemAccent } from '../../types/MenuItemAccent';
+
+export type MenuItemBaseProps = {
+ accent?: MenuItemAccent;
+};
+
+export const StyledMenuItemBase = styled.li`
+ --horizontal-padding: ${({ theme }) => theme.spacing(1)};
+ --vertical-padding: ${({ theme }) => theme.spacing(2)};
+
+ align-items: center;
+
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+
+ font-size: ${({ theme }) => theme.font.size.sm};
+
+ gap: ${({ theme }) => theme.spacing(2)};
+
+ height: calc(32px - 2 * var(--vertical-padding));
+
+ justify-content: space-between;
+
+ padding: var(--vertical-padding) var(--horizontal-padding);
+
+ ${hoverBackground};
+
+ ${({ theme, accent }) => {
+ switch (accent) {
+ case 'danger': {
+ return css`
+ color: ${theme.font.color.danger};
+ &:hover {
+ background: ${theme.background.transparent.danger};
+ }
+ `;
+ }
+ case 'default':
+ default: {
+ return css`
+ color: ${theme.font.color.secondary};
+ `;
+ }
+ }
+ }}
+
+ position: relative;
+ user-select: none;
+
+ width: calc(100% - 2 * var(--horizontal-padding));
+`;
+
+export const StyledMenuItemLabel = styled.div<{ hasLeftIcon: boolean }>`
+ font-size: ${({ theme }) => theme.font.size.sm};
+ font-weight: ${({ theme }) => theme.font.weight.regular};
+ padding-left: ${({ theme, hasLeftIcon }) =>
+ hasLeftIcon ? '' : theme.spacing(1)};
+`;
+
+export const StyledNoIconFiller = styled.div`
+ width: ${({ theme }) => theme.spacing(1)};
+`;
+
+export const StyledMenuItemLeftContent = styled.div`
+ align-items: center;
+ display: flex;
+
+ flex-direction: row;
+
+ gap: ${({ theme }) => theme.spacing(1)};
+`;
+
+export const StyledMenuItemRightContent = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+`;
diff --git a/front/src/modules/ui/menu-item/types/MenuItemAccent.ts b/front/src/modules/ui/menu-item/types/MenuItemAccent.ts
new file mode 100644
index 0000000000..72013f8a23
--- /dev/null
+++ b/front/src/modules/ui/menu-item/types/MenuItemAccent.ts
@@ -0,0 +1 @@
+export type MenuItemAccent = 'default' | 'danger';
diff --git a/front/src/modules/ui/dropdown/components/HotkeyEffect.tsx b/front/src/modules/ui/utilities/hotkey/components/HotkeyEffect.tsx
similarity index 100%
rename from front/src/modules/ui/dropdown/components/HotkeyEffect.tsx
rename to front/src/modules/ui/utilities/hotkey/components/HotkeyEffect.tsx
diff --git a/front/src/testing/decorators/CatalogDecorator.tsx b/front/src/testing/decorators/CatalogDecorator.tsx
index 4b0bbe2ed5..7297ed39fa 100644
--- a/front/src/testing/decorators/CatalogDecorator.tsx
+++ b/front/src/testing/decorators/CatalogDecorator.tsx
@@ -56,8 +56,9 @@ const StyledRowContainer = styled.div`
gap: ${({ theme }) => theme.spacing(2)};
`;
-const StyledElementContainer = styled.div`
+const StyledElementContainer = styled.div<{ width: number }>`
display: flex;
+ ${({ width }) => width && `min-width: ${width}px;`}
`;
const StyledCellContainer = styled.div`
@@ -67,64 +68,73 @@ const StyledCellContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
`;
-const emptyVariable = {
+const emptyDimension = {
name: '',
values: [undefined],
props: () => ({}),
+} as CatalogDimension;
+
+export type CatalogDimension = {
+ name: string;
+ values: any[];
+ props: (value: any) => Record;
+ labels?: (value: any) => string;
+};
+
+export type CatalogOptions = {
+ elementContainer?: {
+ width?: number;
+ };
};
export const CatalogDecorator: Decorator = (Story, context) => {
const {
catalog: { dimensions, options },
} = context.parameters;
+
const [
- variable1,
- variable2 = emptyVariable,
- variable3 = emptyVariable,
- variable4 = emptyVariable,
- ] = dimensions;
+ dimension1,
+ dimension2 = emptyDimension,
+ dimension3 = emptyDimension,
+ dimension4 = emptyDimension,
+ ] = dimensions as CatalogDimension[];
return (
- {variable4.values.map((value4: string) => (
+ {dimension4.values.map((value4: any) => (
- {(variable4.labels?.(value4) || value4) && (
-
- {variable4.labels?.(value4) || value4}
-
- )}
- {variable3.values.map((value3: string) => (
+
+ {dimension4.labels?.(value4) ??
+ (typeof value4 === 'string' ? value4 : '')}
+
+ {dimension3.values.map((value3: any) => (
- {(variable3.labels?.(value3) || value3) && (
-
- {variable3.labels?.(value3) || value3}
-
- )}
- {variable2.values.map((value2: string) => (
+
+ {dimension3.labels?.(value3) ??
+ (typeof value3 === 'string' ? value3 : '')}
+
+ {dimension2.values.map((value2: any) => (
- {(variable2.labels?.(value2) || value2) && (
-
- {variable2.labels?.(value2) || value2}
-
- )}
- {variable1.values.map((value1: string) => (
+
+ {dimension2.labels?.(value2) ??
+ (typeof value2 === 'string' ? value2 : '')}
+
+ {dimension1.values.map((value1: any) => (
- {(variable1.labels?.(value1) || value1) && (
-
- {variable1.labels?.(value1) || value1}
-
- )}
-
+
+ {dimension1.labels?.(value1) ??
+ (typeof value1 === 'string' ? value1 : '')}
+