From 240edda25ca5b21bda44824422a556a025020f6b Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 1 Sep 2023 11:35:19 +0200 Subject: [PATCH] New MenuItem components (#1389) * wip * Finished * Fix from review * Fix lint * Fixed toggle --- .../ui/dropdown/components/DropdownButton.tsx | 3 +- front/src/modules/ui/icon/index.ts | 1 + .../modules/ui/icon/types/IconComponent.ts | 3 + .../ui/menu-item/components/MenuItem.tsx | 53 ++++++++ .../menu-item/components/MenuItemCommand.tsx | 71 +++++++++++ .../components/MenuItemMultiSelect.tsx | 43 +++++++ .../menu-item/components/MenuItemNavigate.tsx | 30 +++++ .../menu-item/components/MenuItemSelect.tsx | 50 ++++++++ .../menu-item/components/MenuItemToggle.tsx | 32 +++++ .../__stories__/MenuItem.stories.tsx | 113 ++++++++++++++++++ .../__stories__/MenuItemCommand.stories.tsx | 78 ++++++++++++ .../MenuItemMultiSelect.stories.tsx | 77 ++++++++++++ .../__stories__/MenuItemNavigate.stories.tsx | 70 +++++++++++ .../__stories__/MenuItemSelect.stories.tsx | 78 ++++++++++++ .../__stories__/MenuItemToggle.stories.tsx | 76 ++++++++++++ .../components/MenuItemLeftContent.tsx | 24 ++++ .../components/StyledMenuItemBase.tsx | 85 +++++++++++++ .../ui/menu-item/types/MenuItemAccent.ts | 1 + .../hotkey}/components/HotkeyEffect.tsx | 0 .../testing/decorators/CatalogDecorator.tsx | 84 +++++++------ 20 files changed, 933 insertions(+), 39 deletions(-) create mode 100644 front/src/modules/ui/icon/types/IconComponent.ts create mode 100644 front/src/modules/ui/menu-item/components/MenuItem.tsx create mode 100644 front/src/modules/ui/menu-item/components/MenuItemCommand.tsx create mode 100644 front/src/modules/ui/menu-item/components/MenuItemMultiSelect.tsx create mode 100644 front/src/modules/ui/menu-item/components/MenuItemNavigate.tsx create mode 100644 front/src/modules/ui/menu-item/components/MenuItemSelect.tsx create mode 100644 front/src/modules/ui/menu-item/components/MenuItemToggle.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItem.stories.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItemCommand.stories.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItemMultiSelect.stories.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItemNavigate.stories.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItemSelect.stories.tsx create mode 100644 front/src/modules/ui/menu-item/components/__stories__/MenuItemToggle.stories.tsx create mode 100644 front/src/modules/ui/menu-item/internals/components/MenuItemLeftContent.tsx create mode 100644 front/src/modules/ui/menu-item/internals/components/StyledMenuItemBase.tsx create mode 100644 front/src/modules/ui/menu-item/types/MenuItemAccent.ts rename front/src/modules/ui/{dropdown => utilities/hotkey}/components/HotkeyEffect.tsx (100%) 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 : '')} +