refactor: review action menu

This commit is contained in:
Carlos Valente
2025-07-08 06:45:22 +02:00
committed by Carlos Valente
parent 9281c0b51d
commit a1ad1d47a4
9 changed files with 227 additions and 61 deletions
@@ -0,0 +1,70 @@
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
padding-block: 0.25rem;
color: $ui-white;
background-color: $gray-1250;
font-size: calc(1rem - 2px);
border-radius: 3px;
border: 1px solid $gray-1000;
box-shadow: $box-shadow-l1;
outline: none;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.item {
outline: 0;
cursor: default;
padding-block: 0.5rem;
padding-inline: 1rem 2rem;
display: flex;
gap: 0.5rem;
line-height: 1em;
svg {
color: $gray-500;
}
&[data-disabled] {
opacity: 0.4;
cursor: not-allowed;
}
&[data-highlighted] {
z-index: 0;
position: relative;
}
&[data-highlighted]:not([data-disabled])::before {
content: '';
z-index: -1;
position: absolute;
inset-block: 0;
inset-inline: 0.25rem;
border-radius: 3px;
background-color: $gray-1000;
}
}
.separator {
margin: 0.25rem 0.75rem;
height: 1px;
background-color: $white-7;
}
@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
import style from './Menu.module.scss';
type MenuItemDivider = { type: 'divider' };
type MenuItem = {
type: 'item';
label: string;
icon?: ReactNode;
disabled?: boolean;
onClick: () => void;
};
interface MenuProps {
items: Array<MenuItemDivider | MenuItem>;
isOpen: boolean;
position: { x: number; y: number };
onClose: () => void;
}
export default function Menu({ items, isOpen, position, onClose }: MenuProps) {
return (
<BaseMenu.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<BaseMenu.Trigger
style={{ position: 'absolute', left: position.x, top: position.y, pointerEvents: 'none' }}
aria-hidden
/>
<BaseMenu.Portal>
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
<BaseMenu.Popup className={style.popup}>
{items.map((item, index) => {
if (item.type === 'divider') {
return <BaseMenu.Separator key={index} className={style.separator} />;
}
return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon} {item.label}
</BaseMenu.Item>
);
})}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
</BaseMenu.Root>
);
}
@@ -1,16 +1,14 @@
.popup { .popup {
box-sizing: border-box; box-sizing: border-box;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
z-index: $zindex-dialog;
color: $ui-white; color: $ui-white;
background-color: $gray-1250; background-color: $gray-1250;
border-radius: 3px; border-radius: 3px;
box-shadow: $box-shadow-l1; box-shadow: $box-shadow-l1;
border: 2px solid $gray-1200; border: 1px solid $gray-1000;
//width: 32rem;
max-width: 90vw; max-width: 90vw;
transform-origin: var(--transform-origin); transform-origin: var(--transform-origin);
@@ -1,7 +1,7 @@
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry } from 'ontime-types'; import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { useCurrentBlockId } from '../../../../common/hooks/useSocket'; import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
@@ -45,7 +45,7 @@ export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, t
onClick={(e) => { onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2; const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex, null); openMenu({ x: rect.x, y: yPos }, blockId, SupportedEntry.Block, rowIndex, null);
}} }}
> >
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />
@@ -126,10 +126,13 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
return ( return (
<MilestoneRow <MilestoneRow
key={key} key={key}
entryId={entry.id}
isPast={isPast} isPast={isPast}
parentBgColour={parentBgColour} parentBgColour={parentBgColour}
parentId={entry.parent}
rowBgColour={rowBgColour} rowBgColour={rowBgColour}
rowId={row.id} rowId={row.id}
rowIndex={index}
table={table} table={table}
/> />
); );
@@ -2,7 +2,7 @@ import { MutableRefObject, useEffect, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types'; import { OntimeEntry, OntimeEvent, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
@@ -100,7 +100,7 @@ export default function EventRow({
onClick={(e) => { onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2; const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, event.id, rowIndex, event.parent); openMenu({ x: rect.x, y: yPos }, event.id, SupportedEntry.Event, rowIndex, event.parent);
}} }}
> >
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />
@@ -1,29 +1,43 @@
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types'; import { EntryId, OntimeEntry, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, enDash } from '../../../../common/utils/styleUtils'; import { cx, enDash } from '../../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../../ontimeConfig'; import { AppMode, sessionKeys } from '../../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../../cuesheet.options'; import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from './MilestoneRow.module.scss'; import style from './MilestoneRow.module.scss';
interface MilestoneRowProps { interface MilestoneRowProps {
entryId: EntryId;
isPast: boolean; isPast: boolean;
parentBgColour: string | null; parentBgColour: string | null;
parentId: EntryId | null;
rowBgColour?: string; rowBgColour?: string;
rowId: string; rowId: string;
rowIndex: number;
table: Table<OntimeEntry>; table: Table<OntimeEntry>;
} }
export default function MilestoneRow({ isPast, parentBgColour, rowBgColour, rowId, table }: MilestoneRowProps) { export default function MilestoneRow({
entryId,
isPast,
parentBgColour,
parentId,
rowBgColour,
rowId,
rowIndex,
table,
}: MilestoneRowProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn); const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const [cuesheetMode] = useSessionStorage<AppMode>({ const [cuesheetMode] = useSessionStorage<AppMode>({
key: sessionKeys.cuesheetMode, key: sessionKeys.cuesheetMode,
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
return ( return (
<tr <tr
@@ -36,7 +50,16 @@ export default function MilestoneRow({ isPast, parentBgColour, rowBgColour, rowI
> >
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
<td className={style.actionColumn} tabIndex={-1} role='cell'> <td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton aria-label='Options' variant='subtle-white' size='small' onClick={() => undefined}> <IconButton
aria-label='Options'
variant='subtle-white'
size='small'
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, entryId, SupportedEntry.Milestone, rowIndex, parentId);
}}
>
<IoEllipsisHorizontal /> <IoEllipsisHorizontal />
</IconButton> </IconButton>
</td> </td>
@@ -1,8 +1,8 @@
import { memo } from 'react'; import { memo } from 'react';
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5'; import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
import { SupportedEntry } from 'ontime-types'; import { SupportedEntry } from 'ontime-types';
import Menu from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
@@ -15,52 +15,58 @@ function CuesheetTableMenu() {
const { addEntry, clone, deleteEntry, move } = useEntryActions(); const { addEntry, clone, deleteEntry, move } = useEntryActions();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry); const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
if (!isOpen) {
return null;
}
return ( return (
<Portal> <Menu
{isOpen && ( isOpen
<Menu isOpen size='sm' onClose={closeMenu} isLazy variant='ontime-on-dark'> onClose={closeMenu}
<MenuButton items={[
position='absolute' { type: 'item', label: 'Edit...', onClick: () => showModal(entryId), icon: <IoOptions /> },
left={position.x} { type: 'divider' },
top={position.y} {
pointerEvents='none' type: 'item',
aria-hidden label: 'Add event above',
w={1} onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }),
h={1} icon: <IoAdd />,
/> },
<MenuList> {
<MenuItem icon={<IoOptions />} onClick={() => showModal(entryId)}> type: 'item',
Edit ... label: 'Add event below',
</MenuItem> onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }),
<MenuDivider /> icon: <IoAdd />,
<MenuItem },
icon={<IoAdd />} {
onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId })} type: 'item',
> label: 'Clone event',
Add event above onClick: () => clone(entryId),
</MenuItem> icon: <IoDuplicateOutline />,
<MenuItem },
icon={<IoAdd />} { type: 'divider' },
onClick={() => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId })} {
> type: 'item',
Add event below label: 'Move up',
</MenuItem> onClick: () => move(entryId, 'up'),
<MenuItem icon={<IoDuplicateOutline />} onClick={() => clone(entryId)}> icon: <IoArrowUp />,
Clone event disabled: entryIndex < 1,
</MenuItem> },
<MenuDivider /> {
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(entryId, 'up')}> type: 'item',
Move up label: 'Move down',
</MenuItem> onClick: () => move(entryId, 'down'),
<MenuItem icon={<IoArrowDown />} onClick={() => move(entryId, 'down')}> icon: <IoArrowDown />,
Move down },
</MenuItem> { type: 'divider' },
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([entryId])}> {
Delete type: 'item',
</MenuItem> label: 'Delete',
</MenuList> onClick: () => deleteEntry([entryId]),
</Menu> icon: <IoTrash />,
)} },
</Portal> ]}
position={position}
/>
); );
} }
@@ -1,4 +1,4 @@
import { EntryId } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
type Anchor = { x: number; y: number }; type Anchor = { x: number; y: number };
@@ -6,6 +6,7 @@ type Anchor = { x: number; y: number };
type OpenMenu = { type OpenMenu = {
isOpen: true; isOpen: true;
entryId: EntryId; entryId: EntryId;
entryType: SupportedEntry;
entryIndex: number; entryIndex: number;
parentId: EntryId | null; parentId: EntryId | null;
}; };
@@ -13,23 +14,36 @@ type OpenMenu = {
type ClosedMenu = { type ClosedMenu = {
isOpen: false; isOpen: false;
entryId: null; entryId: null;
entryType: null;
entryIndex: null; entryIndex: null;
parentId: null; parentId: null;
}; };
type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & { type CuesheetTableMenuStore = (OpenMenu | ClosedMenu) & {
position: Anchor; position: Anchor;
openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) => void; openMenu: (
position: Anchor,
entryId: EntryId,
entryType: SupportedEntry,
entryIndex: number,
parentId: EntryId | null,
) => void;
closeMenu: () => void; closeMenu: () => void;
}; };
export const useCuesheetTableMenu = create<CuesheetTableMenuStore>((set) => ({ export const useCuesheetTableMenu = create<CuesheetTableMenuStore>((set) => ({
isOpen: false, isOpen: false,
entryId: null, entryId: null,
entryType: null,
entryIndex: null, entryIndex: null,
parentId: null, parentId: null,
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
openMenu: (position: Anchor, entryId: EntryId, entryIndex: number, parentId: EntryId | null) => openMenu: (
set({ isOpen: true, position, entryId, entryIndex, parentId }), position: Anchor,
entryId: EntryId,
entryType: SupportedEntry,
entryIndex: number,
parentId: EntryId | null,
) => set({ isOpen: true, position, entryId, entryType, entryIndex, parentId }),
closeMenu: () => set({ isOpen: false }), closeMenu: () => set({ isOpen: false }),
})); }));