diff --git a/packages/ui/src/features/playground/components/Mixer/__tests__/MixerView.test.tsx b/packages/ui/src/features/playground/components/Mixer/__tests__/MixerView.test.tsx index e4e0831..9f7155a 100644 --- a/packages/ui/src/features/playground/components/Mixer/__tests__/MixerView.test.tsx +++ b/packages/ui/src/features/playground/components/Mixer/__tests__/MixerView.test.tsx @@ -22,6 +22,7 @@ function createTestStore(tracks: ReturnType[]) { setCurrentSection: noop, addSection: noop, renameSection: noop, + duplicateSection: noop, removeSection: noop, setTrackVolume: noop, setTrackPan: noop, @@ -32,6 +33,8 @@ function createTestStore(tracks: ReturnType[]) { updateNote: noop, setClipNotes: noop, createClip: noop, + duplicateClip: noop, + removeClip: noop, setClipLength: noop, addNewTrack: noop, removeTrack: noop, diff --git a/packages/ui/src/features/playground/components/PlaygroundsDashboard/PlaygroundsDashboard.tsx b/packages/ui/src/features/playground/components/PlaygroundsDashboard/PlaygroundsDashboard.tsx index e4ca02f..1b59784 100644 --- a/packages/ui/src/features/playground/components/PlaygroundsDashboard/PlaygroundsDashboard.tsx +++ b/packages/ui/src/features/playground/components/PlaygroundsDashboard/PlaygroundsDashboard.tsx @@ -27,12 +27,14 @@ import type { PlaygroundState } from '../../types'; interface PlaygroundCardProps { playground: PlaygroundState; + index: number; onPress?: (id: string) => void; onMenuPress?: (id: string) => void; } const PlaygroundCard = memo(function PlaygroundCard({ playground, + index, onPress, onMenuPress, }: PlaygroundCardProps) { @@ -75,6 +77,7 @@ const PlaygroundCard = memo(function PlaygroundCard({ hitSlop={12} accessibilityRole="button" accessibilityLabel="More options" + testID={`playgroundMenuButton_${index}`} > @@ -173,10 +176,11 @@ export const PlaygroundsDashboard = memo(function PlaygroundsDashboard({ { paddingHorizontal: padding }, ]} > - {playgrounds.map((p) => ( + {playgrounds.map((p, index) => ( diff --git a/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/PlaygroundsDashboard.test.tsx b/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/PlaygroundsDashboard.test.tsx index 56dd1ef..8876fb9 100644 --- a/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/PlaygroundsDashboard.test.tsx +++ b/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/PlaygroundsDashboard.test.tsx @@ -70,6 +70,21 @@ describe('PlaygroundsDashboard behavior', () => { expect(onSelect).toHaveBeenCalled(); }); + it('calls onMenuPress from a stable indexed menu trigger', () => { + const onMenuPress = jest.fn(); + const playgrounds = createMockPlaygroundsList(2); + const { getByTestId } = renderWithTheme( + + ); + + fireEvent.press(getByTestId('playgroundMenuButton_1')); + + expect(onMenuPress).toHaveBeenCalledWith(playgrounds[1]!.id); + }); + it('calls onCreate when tapping + New', () => { const onCreate = jest.fn(); const playgrounds = createMockPlaygroundsList(1); diff --git a/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/__snapshots__/PlaygroundsDashboard.test.tsx.snap b/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/__snapshots__/PlaygroundsDashboard.test.tsx.snap index 72c5b7a..bda1e5e 100644 --- a/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/__snapshots__/PlaygroundsDashboard.test.tsx.snap +++ b/packages/ui/src/features/playground/components/PlaygroundsDashboard/__tests__/__snapshots__/PlaygroundsDashboard.test.tsx.snap @@ -582,6 +582,7 @@ exports[`PlaygroundsDashboard snapshots matches snapshot refresh with playground "padding": 12, } } + testID="playgroundMenuButton_0" > void) => + Platform.OS === 'web' + ? ({ + onContextMenu: (event: any) => { + event.preventDefault?.(); + open(); + }, + } as any) + : {}; + // ── Clip Cell ─────────────────────────────────────────────────────────────── const CLIP_PAD = 2; @@ -56,6 +81,8 @@ const ClipCell = memo(function ClipCell({ sampleCount, defaultOctave, onPress, + onLongPress, + testID, }: { clip?: Clip; color: string; @@ -63,6 +90,8 @@ const ClipCell = memo(function ClipCell({ sampleCount?: number; defaultOctave?: number; onPress?: () => void; + onLongPress?: () => void; + testID?: string; }) { if (!clip) { return ( @@ -100,6 +129,11 @@ const ClipCell = memo(function ClipCell({ return ( {}))} style={[s.cell, { backgroundColor: color, borderRadius: 6 }]} > @@ -174,6 +208,8 @@ export const SongView = memo(function SongView({ }: SongViewProps) { const { colors } = useTheme(); const [exportVisible, setExportVisible] = useState(false); + const [menuTarget, setMenuTarget] = useState(null); + const [sectionName, setSectionName] = useState(''); // State — fine-grained selectors const currentTab = useSongContext((s) => s.currentTab); @@ -189,7 +225,10 @@ export const SongView = memo(function SongView({ addSection, showAddTrackMenu, removeTrack, + duplicateClip, + removeClip, renameSection, + duplicateSection, removeSection, } = useSongActions(); @@ -208,16 +247,30 @@ export const SongView = memo(function SongView({ {tracks.map((t) => ( { - Alert.alert('Delete Track', `Delete "${t.title}"?`, [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => removeTrack(t.id), - }, - ]); - }} + onPress={() => + setMenuTarget({ + kind: 'track', + trackId: t.id, + title: t.title, + }) + } + onLongPress={() => + setMenuTarget({ + kind: 'track', + trackId: t.id, + title: t.title, + }) + } + testID={`track-label-${t.id}`} + accessibilityLabel={`${t.title} track options`} + accessibilityRole="button" + {...webContextMenu(() => + setMenuTarget({ + kind: 'track', + trackId: t.id, + title: t.title, + }) + )} style={[ s.label, { @@ -258,64 +311,54 @@ export const SongView = memo(function SongView({ {sections.map((sec, index) => { const isActive = sec.id === currentSectionId; + const openSectionMenu = () => + setMenuTarget({ + kind: 'section', + sectionId: sec.id, + name: sec.name || `Section ${index + 1}`, + }); return ( - setCurrentSection(sec.id)} - onLongPress={() => { - Alert.alert( - sec.name || `Section ${index + 1}`, - undefined, - [ - { - text: 'Rename', - onPress: () => { - Alert.prompt?.( - 'Rename Section', - undefined, - (newName: string) => { - if (newName.trim()) - renameSection(sec.id, newName.trim()); - }, - 'plain-text', - sec.name || `Section ${index + 1}` - ); - // Alert.prompt is iOS-only; on Android renameSection is called from settings - }, - }, - ...(sections.length > 1 - ? [ - { - text: 'Delete', - style: 'destructive' as const, - onPress: () => removeSection(sec.id), - }, - ] - : []), - { text: 'Cancel', style: 'cancel' as const }, - ] - ); - }} - style={[ - s.secTab, - { - backgroundColor: isActive - ? colors.mcWhite - : colors.mcWhite3, - }, - ]} - accessibilityLabel={`Section ${sec.name || index + 1}`} - accessibilityRole="button" - > - + setCurrentSection(sec.id)} + onLongPress={openSectionMenu} + testID={`section-${sec.id}`} + {...webContextMenu(openSectionMenu)} + style={[ + s.secTab, + { + backgroundColor: isActive + ? colors.mcWhite + : colors.mcWhite3, + }, + ]} + accessibilityLabel={`Section ${sec.name || index + 1}`} + accessibilityRole="button" + > + + {sec.name || `${index + 1}`} + + + - {sec.name || `${index + 1}`} - - + + + ); })} c.sectionID === sec.id ); + const openClipMenu = clip + ? () => + setMenuTarget({ + kind: 'clip', + trackId: t.id, + clipId: clip.id, + }) + : undefined; const cell = ( - { - if (clip) { - openClipEditor(t.id, clip.id); - } else { - // Create clip then immediately open editor - // Compute new ID using same logic as songStore.createClip - const allClipIds = tracks.flatMap((tr) => - tr.clips.map((c) => c.id) - ); - const newClipId = - allClipIds.length > 0 - ? Math.max(...allClipIds) + 1 - : 0; - createClip(t.id, sec.id); - openClipEditor(t.id, newClipId); + + + onLongPress={openClipMenu} + onPress={() => { + if (clip) { + openClipEditor(t.id, clip.id); + } else { + // Create clip then immediately open editor + // Compute new ID using same logic as songStore.createClip + const allClipIds = tracks.flatMap((tr) => + tr.clips.map((c) => c.id) + ); + const newClipId = + allClipIds.length > 0 + ? Math.max(...allClipIds) + 1 + : 0; + createClip(t.id, sec.id); + openClipEditor(t.id, newClipId); + } + }} + /> + {clip && openClipMenu && ( + + + + )} + ); return trackIndex === 0 && sectionIndex === 0 ? ( @@ -386,6 +459,218 @@ export const SongView = memo(function SongView({ {showTab && } + setMenuTarget(null)} + > + + setMenuTarget(null)} + accessible={false} + testID="close-song-context-menu" + /> + + {menuTarget?.kind === 'track' && + (tracks.length > 1 ? ( + + setMenuTarget({ + kind: 'confirmTrackDelete', + trackId: menuTarget.trackId, + title: menuTarget.title, + }) + } + accessibilityLabel="Delete track" + accessibilityRole="button" + testID="delete-track-action" + > + + Delete Track + + + ) : ( + + Cannot delete last track + + ))} + {menuTarget?.kind === 'confirmTrackDelete' && ( + <> + + Delete “{menuTarget.title}” and all its clips? + + { + removeTrack(menuTarget.trackId); + setMenuTarget(null); + }} + accessibilityLabel="Confirm delete track" + accessibilityRole="button" + testID="confirm-delete-track" + > + + Delete + + + + )} + {menuTarget?.kind === 'clip' && ( + <> + { + openClipEditor(menuTarget.trackId, menuTarget.clipId); + setMenuTarget(null); + }} + accessibilityLabel="Edit clip" + accessibilityRole="button" + testID="edit-clip-action" + > + + Edit Clip + + + { + duplicateClip(menuTarget.trackId, menuTarget.clipId); + setMenuTarget(null); + }} + accessibilityLabel="Duplicate clip" + accessibilityRole="button" + testID="duplicate-clip-action" + > + + Duplicate + + + { + removeClip(menuTarget.trackId, menuTarget.clipId); + setMenuTarget(null); + }} + accessibilityLabel="Delete clip" + accessibilityRole="button" + testID="delete-clip-action" + > + + Delete + + + + )} + {menuTarget?.kind === 'section' && ( + <> + { + setSectionName(menuTarget.name); + setMenuTarget({ + kind: 'renameSection', + sectionId: menuTarget.sectionId, + }); + }} + accessibilityLabel="Rename section" + accessibilityRole="button" + testID="rename-section-action" + > + + Edit Title + + + { + duplicateSection(menuTarget.sectionId); + setMenuTarget(null); + }} + accessibilityLabel="Duplicate section" + accessibilityRole="button" + testID="duplicate-section-action" + > + + Duplicate + + + {sections.length > 1 && ( + { + removeSection(menuTarget.sectionId); + setMenuTarget(null); + }} + accessibilityLabel="Delete section" + accessibilityRole="button" + testID="delete-section-action" + > + + Delete + + + )} + + )} + {menuTarget?.kind === 'renameSection' && ( + <> + + { + if (sectionName.trim()) + renameSection(menuTarget.sectionId, sectionName.trim()); + setMenuTarget(null); + }} + accessibilityLabel="Save section name" + accessibilityRole="button" + testID="save-section-name" + > + + Save + + + + )} + setMenuTarget(null)} + accessibilityLabel={ + menuTarget?.kind === 'confirmTrackDelete' || + menuTarget?.kind === 'renameSection' + ? 'Cancel' + : 'Close menu' + } + accessibilityRole="button" + testID="close-song-menu-action" + > + + {menuTarget?.kind === 'confirmTrackDelete' || + menuTarget?.kind === 'renameSection' + ? 'Cancel' + : 'Close'} + + + + + + setExportVisible(false)} @@ -422,6 +707,11 @@ const s = StyleSheet.create({ gap: GAP, marginBottom: GAP, }, + secTabContainer: { + width: CELL_W, + height: SECTION_H, + position: 'relative', + }, secTab: { width: CELL_W, height: SECTION_H, @@ -458,6 +748,47 @@ const s = StyleSheet.create({ borderWidth: 1, }, cellContent: { flex: 1, width: '100%', position: 'relative' }, + clipCellContainer: { + width: CELL_W, + height: CELL_H, + position: 'relative', + }, + itemMenuButton: { + position: 'absolute', + top: 2, + right: 2, + width: 24, + height: 24, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + menuLayer: { + flex: 1, + justifyContent: 'center', + padding: 24, + }, + menuBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.7)', + }, + menuCard: { + width: '100%', + maxWidth: 420, + alignSelf: 'center', + padding: 20, + borderRadius: 6, + backgroundColor: '#1A1C20', + gap: 8, + }, + menuAction: { paddingVertical: 12 }, + menuInput: { + color: '#F7F7F7', + borderWidth: 1, + borderColor: 'rgba(247,247,247,0.3)', + borderRadius: 6, + padding: 12, + }, addTrack: { width: CELL_W, height: CELL_H, diff --git a/packages/ui/src/features/playground/components/SongView/__tests__/SongView.test.tsx b/packages/ui/src/features/playground/components/SongView/__tests__/SongView.test.tsx index 395fcd2..2bc16e9 100644 --- a/packages/ui/src/features/playground/components/SongView/__tests__/SongView.test.tsx +++ b/packages/ui/src/features/playground/components/SongView/__tests__/SongView.test.tsx @@ -41,6 +41,7 @@ function createTestStore( setCurrentSection: jest.fn(), addSection: jest.fn(), renameSection: jest.fn(), + duplicateSection: jest.fn(), removeSection: jest.fn(), setTrackVolume: jest.fn(), setTrackPan: jest.fn(), @@ -51,6 +52,8 @@ function createTestStore( updateNote: jest.fn(), setClipNotes: jest.fn(), createClip: jest.fn(), + duplicateClip: jest.fn(), + removeClip: jest.fn(), setClipLength: jest.fn(), addNewTrack: jest.fn(), removeTrack: jest.fn(), @@ -186,6 +189,85 @@ describe('SongMixerTabBar behavior', () => { }); }); +describe('SongView context menus', () => { + it('explains why the last track cannot be deleted', () => { + const store = createTestStore({ tracks: [createMockTrack({ id: 1 })] }); + const screen = renderWithStore(, store); + + fireEvent.press(screen.getByTestId('track-label-1')); + + expect(screen.getByText('Cannot delete last track')).toBeTruthy(); + expect(screen.queryByTestId('delete-track-action')).toBeNull(); + expect(store.getState().removeTrack).not.toHaveBeenCalled(); + }); + + it('deletes a track after confirmation', () => { + const store = createTestStore({ + tracks: [createMockTrack({ id: 1 }), createMockTrack({ id: 2 })], + }); + const { getByTestId } = renderWithStore(, store); + + fireEvent.press(getByTestId('track-label-1')); + expect( + getByTestId('close-song-context-menu', { includeHiddenElements: true }) + .props.accessible + ).toBe(false); + expect( + getByTestId('song-context-menu').props.accessibilityViewIsModal + ).toBe(true); + expect(getByTestId('close-song-menu-action').props.accessibilityLabel).toBe( + 'Close menu' + ); + fireEvent.press(getByTestId('delete-track-action')); + expect(getByTestId('close-song-menu-action').props.accessibilityLabel).toBe( + 'Cancel' + ); + fireEvent.press(getByTestId('confirm-delete-track')); + + expect(store.getState().removeTrack).toHaveBeenCalledWith(1); + }); + + it('duplicates and deletes clips from their context menu', () => { + const clip = createMockClip({ id: 11, sectionID: 1 }); + const store = createTestStore({ + tracks: [createMockTrack({ id: 1, clips: [clip] })], + }); + const screen = renderWithStore(, store); + + fireEvent.press(screen.getByTestId('clip-menu-1-11')); + expect( + screen.getByTestId('close-song-menu-action').props.accessibilityLabel + ).toBe('Close menu'); + fireEvent.press(screen.getByTestId('duplicate-clip-action')); + expect(store.getState().duplicateClip).toHaveBeenCalledWith(1, 11); + + fireEvent.press(screen.getByTestId('clip-menu-1-11')); + fireEvent.press(screen.getByTestId('delete-clip-action')); + expect(store.getState().removeClip).toHaveBeenCalledWith(1, 11); + }); + + it('renames and duplicates sections from their context menu', () => { + const store = createTestStore(); + const screen = renderWithStore(, store); + + fireEvent.press(screen.getByTestId('section-menu-1')); + expect( + screen.getByTestId('close-song-menu-action').props.accessibilityLabel + ).toBe('Close menu'); + fireEvent.press(screen.getByTestId('rename-section-action')); + expect( + screen.getByTestId('close-song-menu-action').props.accessibilityLabel + ).toBe('Cancel'); + fireEvent.changeText(screen.getByTestId('section-name-input'), 'Drop'); + fireEvent.press(screen.getByTestId('save-section-name')); + expect(store.getState().renameSection).toHaveBeenCalledWith(1, 'Drop'); + + fireEvent.press(screen.getByTestId('section-menu-1')); + fireEvent.press(screen.getByTestId('duplicate-section-action')); + expect(store.getState().duplicateSection).toHaveBeenCalledWith(1); + }); +}); + describe('SongView share beat flow', () => { it('dispatches the share action from settings', () => { const onShareBeat = jest.fn(); diff --git a/packages/ui/src/features/playground/components/SongView/__tests__/__snapshots__/SongView.test.tsx.snap b/packages/ui/src/features/playground/components/SongView/__tests__/__snapshots__/SongView.test.tsx.snap index f2a8622..397c0e6 100644 --- a/packages/ui/src/features/playground/components/SongView/__tests__/__snapshots__/SongView.test.tsx.snap +++ b/packages/ui/src/features/playground/components/SongView/__tests__/__snapshots__/SongView.test.tsx.snap @@ -4698,6 +4698,8 @@ exports[`SongView snapshots matches snapshot for song view 1`] = ` } /> + + + Intro + + + + + + + + + - - Intro - + + Verse + + + + + + + - - Verse - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - @@ -5373,24 +5822,11 @@ exports[`SongView snapshots matches snapshot for song view 1`] = ` { "backgroundColor": "rgba(26,28,32,0.4)", "borderRadius": 2, - "height": 3.6, + "height": 2.7, "left": 4.25, "position": "absolute", - "top": 20, - "width": 2, - } - } - /> - @@ -5399,24 +5835,11 @@ exports[`SongView snapshots matches snapshot for song view 1`] = ` { "backgroundColor": "rgba(26,28,32,0.4)", "borderRadius": 2, - "height": 3.6, + "height": 2.7, "left": 8.5, "position": "absolute", - "top": 20, - "width": 2, - } - } - /> - @@ -5425,260 +5848,134 @@ exports[`SongView snapshots matches snapshot for song view 1`] = ` { "backgroundColor": "rgba(26,28,32,0.4)", "borderRadius": 2, - "height": 3.6, + "height": 2.7, "left": 12.75, "position": "absolute", - "top": 20, - "width": 2, - } - } - /> - - - - - + "position": "absolute", + "right": 2, + "top": 2, + "width": 24, + "zIndex": 1, + } + } + testID="clip-menu-2-3" + > + + + - - - - - - - - - - - + + + @@ -5692,111 +5989,131 @@ exports[`SongView snapshots matches snapshot for song view 1`] = ` } > + - - + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "borderWidth": 1, + "height": 60, + "justifyContent": "center", + "padding": 4, + "width": 80, + }, + { + "borderColor": "#3AA0FF", + }, + ] + } + > + + + + - - + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + [ + { + "alignItems": "center", + "borderWidth": 1, + "height": 60, + "justifyContent": "center", + "padding": 4, + "width": 80, + }, + { + "borderColor": "#3AA0FF", + }, + ] + } + > + + + diff --git a/packages/ui/src/features/playground/screens/PlaygroundScreen.tsx b/packages/ui/src/features/playground/screens/PlaygroundScreen.tsx index dc1cdcd..3346fa1 100644 --- a/packages/ui/src/features/playground/screens/PlaygroundScreen.tsx +++ b/packages/ui/src/features/playground/screens/PlaygroundScreen.tsx @@ -33,6 +33,7 @@ export const PlaygroundScreen = memo(function PlaygroundScreen({ setCurrentSection: noop, addSection: noop, renameSection: noop, + duplicateSection: noop, removeSection: noop, setTrackVolume: noop, setTrackPan: noop, @@ -43,6 +44,8 @@ export const PlaygroundScreen = memo(function PlaygroundScreen({ updateNote: noop, setClipNotes: noop, createClip: noop, + duplicateClip: noop, + removeClip: noop, setClipLength: noop, addNewTrack: noop, removeTrack: noop, diff --git a/packages/ui/src/features/playground/stores/playgroundStore.tsx b/packages/ui/src/features/playground/stores/playgroundStore.tsx index 3891669..71bc51c 100644 --- a/packages/ui/src/features/playground/stores/playgroundStore.tsx +++ b/packages/ui/src/features/playground/stores/playgroundStore.tsx @@ -47,6 +47,7 @@ export interface SongActions { setCurrentSection: (sectionId: number) => void; addSection: () => void; renameSection: (sectionId: number, name: string) => void; + duplicateSection: (sectionId: number) => void; removeSection: (sectionId: number) => void; // Mixer @@ -68,6 +69,8 @@ export interface SongActions { // Clip createClip: (trackId: number, sectionId: number) => void; + duplicateClip: (trackId: number, clipId: number) => void; + removeClip: (trackId: number, clipId: number) => void; setClipLength: (trackId: number, clipId: number, bars: number) => void; // Track management