@hyperframes/studio 0.8.50 → 0.8.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CxxDxHTW.js → hyperframes-player-I5YRzB02.js} +3 -3
- package/dist/assets/{index-Dt3qBI-9.js → index-BTAfgesM.js} +1 -1
- package/dist/assets/index-CxAu_Okg.css +1 -0
- package/dist/assets/index-DocJlW2v.js +376 -0
- package/dist/assets/{index-DUAIWxkI.js → index-Du5x7KUR.js} +1 -1
- package/dist/index.d.ts +419 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7496 -8288
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +13 -12
- package/src/App.tsx +131 -143
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/captions/components/CaptionOverlayUtils.ts +2 -3
- package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
- package/src/captions/components/CaptionTimeline.tsx +1 -1
- package/src/captions/components/shared.tsx +2 -2
- package/src/components/AskAgentModal.tsx +5 -5
- package/src/components/EditorShell.tsx +8 -7
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.tsx +110 -210
- package/src/components/StudioLeftSidebar.tsx +7 -4
- package/src/components/StudioOverlays.tsx +1 -1
- package/src/components/StudioRightPanel.tsx +51 -39
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +8 -12
- package/src/components/dock/Dock.test.tsx +260 -0
- package/src/components/dock/Dock.tsx +281 -0
- package/src/components/dock/dock.css +28 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +105 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.ts +91 -0
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/dock/panelRegistry.ts +67 -0
- package/src/components/editor/AnimationCard.tsx +4 -4
- package/src/components/editor/AnimationCardParts.tsx +6 -6
- package/src/components/editor/BlockParamsPanel.tsx +4 -4
- package/src/components/editor/BorderRadiusEditor.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +1 -1
- package/src/components/editor/EaseParamFields.tsx +5 -5
- package/src/components/editor/FileTree.tsx +4 -4
- package/src/components/editor/FileTreeIcons.tsx +1 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -13
- package/src/components/editor/InlineTextToolbar.tsx +1 -1
- package/src/components/editor/InspectorHeaderActions.tsx +3 -3
- package/src/components/editor/KeyframeDiamond.tsx +1 -1
- package/src/components/editor/KeyframeEaseList.tsx +1 -1
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +7 -5
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.tsx +2 -2
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +7 -5
- package/src/components/editor/Transform3DCube.tsx +2 -2
- package/src/components/editor/audioFxRevealTarget.ts +1 -0
- package/src/components/editor/domEditing.test.ts +1 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
- package/src/components/editor/domEditingAgentPrompt.ts +4 -3
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
- package/src/components/feedback/feedbackTrigger.ts +0 -1
- package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
- package/src/components/nle/NLEPreview.tsx +3 -3
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/TimelinePane.tsx +3 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- package/src/components/panels/SlideshowPanel.tsx +2 -2
- package/src/components/panels/SlideshowSubPanels.tsx +20 -17
- package/src/components/panels/VariablesBindElement.tsx +2 -2
- package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +7 -7
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/sidebar/AssetCard.tsx +8 -8
- package/src/components/sidebar/AssetContextMenu.tsx +8 -8
- package/src/components/sidebar/AssetsTab.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +6 -6
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
- package/src/components/sidebar/LeftSidebar.tsx +19 -5
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +252 -0
- package/src/components/ui/Button.tsx +89 -126
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/hooks/useAppHotkeys.ts +21 -80
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/index.ts +51 -0
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -3
- package/src/player/components/ClipContextMenu.tsx +3 -3
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +2 -1
- package/src/player/components/TimelineCanvas.tsx +21 -14
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +32 -0
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +2 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -1
- package/src/player/components/timelineDragDrop.ts +57 -15
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/lib/describeClips.ts +3 -54
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/iconTokens.ts +20 -0
- package/src/styles/palette-reset.css +6 -0
- package/src/styles/studio.css +24 -21
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +243 -0
- package/src/styles/theme.test.ts +183 -0
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -1
- package/src/utils/studioUiPreferences.ts +7 -0
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-n2ml4tUn.js +0 -442
- package/dist/assets/index-pO6p0H27.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
- package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
- package/src/components/storyboard/FramePoster.test.tsx +0 -61
- package/src/components/storyboard/FramePoster.tsx +0 -86
- package/src/components/storyboard/StoryboardDirection.tsx +0 -45
- package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
- package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
- package/src/components/storyboard/StoryboardGrid.tsx +0 -55
- package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
- package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
- package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
- package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
- package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
- package/src/components/storyboard/StoryboardView.tsx +0 -169
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
- package/src/components/storyboard/frameComments.test.ts +0 -95
- package/src/components/storyboard/frameComments.ts +0 -115
- package/src/components/storyboard/frameStatus.ts +0 -36
- package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
- package/src/components/storyboard/storyboardReviewStage.ts +0 -45
- package/src/components/storyboard/useFrameComments.ts +0 -98
- package/src/contexts/ViewModeContext.tsx +0 -142
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/** Menu, ContextMenu and Popover: key/aria behaviour, dismissal, and that emitted classes resolve. */
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import React, { act, useState } from "react";
|
|
6
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
7
|
+
import { compile } from "tailwindcss";
|
|
8
|
+
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
9
|
+
import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
|
|
10
|
+
import { ContextMenu, Menu, MenuItem, MenuRadioGroup, MenuRadioItem, MenuSeparator } from "./Menu";
|
|
11
|
+
import { Popover } from "./Popover";
|
|
12
|
+
import { isTypingTarget } from "../../utils/typingTarget";
|
|
13
|
+
import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
|
|
14
|
+
|
|
15
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
let mounted: { root: Root; host: HTMLElement } | null = null;
|
|
18
|
+
|
|
19
|
+
function render(element: React.ReactElement): HTMLElement {
|
|
20
|
+
const host = document.createElement("div");
|
|
21
|
+
document.body.append(host);
|
|
22
|
+
const root = createRoot(host);
|
|
23
|
+
mounted = { root, host };
|
|
24
|
+
act(() => root.render(element));
|
|
25
|
+
return host;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
if (!mounted) return;
|
|
30
|
+
const { root, host } = mounted;
|
|
31
|
+
mounted = null;
|
|
32
|
+
act(() => root.unmount());
|
|
33
|
+
host.remove();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Base UI moves focus into the popup one task after open, not synchronously. */
|
|
37
|
+
async function settle(): Promise<void> {
|
|
38
|
+
await act(async () => {
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function press(target: Element): void {
|
|
44
|
+
// `composed` is what a real browser sets: without it the event never crosses
|
|
45
|
+
// a shadow boundary and Base UI's document listener never sees it.
|
|
46
|
+
const init = { bubbles: true, cancelable: true, composed: true };
|
|
47
|
+
act(() => {
|
|
48
|
+
target.dispatchEvent(new PointerEvent("pointerdown", init));
|
|
49
|
+
target.dispatchEvent(new MouseEvent("mousedown", init));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A mouse click, spelled out: bare `.click()` reads as keyboard activation to Base UI,
|
|
55
|
+
* which pre-highlights the first item and shifts every arrow-key assertion by one.
|
|
56
|
+
*/
|
|
57
|
+
function clickWithMouse(target: Element): void {
|
|
58
|
+
const init = { bubbles: true, cancelable: true, composed: true, detail: 1 };
|
|
59
|
+
act(() => {
|
|
60
|
+
target.dispatchEvent(new PointerEvent("pointerdown", { ...init, pointerType: "mouse" }));
|
|
61
|
+
target.dispatchEvent(new MouseEvent("mousedown", init));
|
|
62
|
+
target.dispatchEvent(new PointerEvent("pointerup", { ...init, pointerType: "mouse" }));
|
|
63
|
+
target.dispatchEvent(new MouseEvent("mouseup", init));
|
|
64
|
+
target.dispatchEvent(new MouseEvent("click", init));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function key(k: string, target: Element | null = document.activeElement): void {
|
|
69
|
+
const init = { key: k, bubbles: true, cancelable: true, composed: true };
|
|
70
|
+
act(() => {
|
|
71
|
+
(target ?? document.body).dispatchEvent(new KeyboardEvent("keydown", init));
|
|
72
|
+
(target ?? document.body).dispatchEvent(new KeyboardEvent("keyup", init));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function one<T extends Element>(selector: string, what: string): T {
|
|
77
|
+
const el = document.querySelector<T>(selector);
|
|
78
|
+
if (!el) throw new Error(`${what} not rendered`);
|
|
79
|
+
return el;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const trigger = () => one<HTMLElement>('[data-testid="trigger"]', "trigger");
|
|
83
|
+
const popup = () => document.querySelector<HTMLElement>('[role="menu"]');
|
|
84
|
+
const items = () => [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')];
|
|
85
|
+
|
|
86
|
+
/** A Studio element menu: actions, a hairline, a destructive item; `disabled` marks the passed label. */
|
|
87
|
+
function ActionMenu({
|
|
88
|
+
activated,
|
|
89
|
+
disable,
|
|
90
|
+
}: {
|
|
91
|
+
activated: string[];
|
|
92
|
+
disable?: string;
|
|
93
|
+
}): React.JSX.Element {
|
|
94
|
+
return (
|
|
95
|
+
<Menu trigger={<button data-testid="trigger">Actions</button>} aria-label="Element actions">
|
|
96
|
+
<MenuItem shortcut="⌘]" onClick={() => activated.push("front")}>
|
|
97
|
+
Bring to front
|
|
98
|
+
</MenuItem>
|
|
99
|
+
<MenuItem disabled={disable === "group"} onClick={() => activated.push("group")}>
|
|
100
|
+
Group
|
|
101
|
+
</MenuItem>
|
|
102
|
+
<MenuSeparator />
|
|
103
|
+
<MenuItem tone="danger" shortcut="⌫" onClick={() => activated.push("delete")}>
|
|
104
|
+
Delete
|
|
105
|
+
</MenuItem>
|
|
106
|
+
</Menu>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function openActionMenu(activated: string[] = [], disable?: string): Promise<void> {
|
|
111
|
+
render(<ActionMenu activated={activated} disable={disable} />);
|
|
112
|
+
clickWithMouse(trigger());
|
|
113
|
+
await settle();
|
|
114
|
+
expect(popup()).not.toBeNull();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe("Menu behaviour", () => {
|
|
118
|
+
// AE5.
|
|
119
|
+
it("activates the second item with ArrowDown twice then Enter, and closes", async () => {
|
|
120
|
+
const activated: string[] = [];
|
|
121
|
+
await openActionMenu(activated);
|
|
122
|
+
|
|
123
|
+
key("ArrowDown");
|
|
124
|
+
key("ArrowDown");
|
|
125
|
+
key("Enter");
|
|
126
|
+
await settle();
|
|
127
|
+
|
|
128
|
+
expect(activated).toEqual(["group"]);
|
|
129
|
+
expect(popup()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not activate a disabled item, and the arrow keys reach past it", async () => {
|
|
133
|
+
const activated: string[] = [];
|
|
134
|
+
await openActionMenu(activated, "group");
|
|
135
|
+
|
|
136
|
+
const [, disabled, last] = items();
|
|
137
|
+
expect(disabled.getAttribute("data-disabled")).not.toBeNull();
|
|
138
|
+
expect(disabled.getAttribute("aria-disabled")).toBe("true");
|
|
139
|
+
|
|
140
|
+
// Base UI keeps a disabled item in the roving sequence (the ARIA menu
|
|
141
|
+
// pattern: a disabled command stays discoverable). So the guarantee is not
|
|
142
|
+
// that the highlight skips it, it is that nothing runs when it is aimed at
|
|
143
|
+
// directly, by key or by pointer.
|
|
144
|
+
key("ArrowDown");
|
|
145
|
+
key("ArrowDown");
|
|
146
|
+
expect(disabled.getAttribute("data-highlighted")).not.toBeNull();
|
|
147
|
+
key("Enter", disabled);
|
|
148
|
+
act(() => disabled.click());
|
|
149
|
+
await settle();
|
|
150
|
+
|
|
151
|
+
expect(activated).toEqual([]);
|
|
152
|
+
expect(popup()).not.toBeNull();
|
|
153
|
+
|
|
154
|
+
// And the item after it is still reachable, so a disabled row is not a wall.
|
|
155
|
+
key("ArrowDown");
|
|
156
|
+
key("Enter");
|
|
157
|
+
await settle();
|
|
158
|
+
|
|
159
|
+
expect(activated).toEqual(["delete"]);
|
|
160
|
+
expect(last.isConnected).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("renders a shortcut hint that assistive tech does not read as a label", async () => {
|
|
164
|
+
await openActionMenu();
|
|
165
|
+
|
|
166
|
+
const hint = one<HTMLElement>('[role="menuitem"] span[aria-hidden="true"]', "shortcut hint");
|
|
167
|
+
|
|
168
|
+
expect(hint.textContent).toBe("⌘]");
|
|
169
|
+
expect(items()[0].textContent).toContain("Bring to front");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("closes on an outside press that a parent stops in the bubble phase", async () => {
|
|
173
|
+
// U3's finding, on the shipped component: the canvas overlay swallows
|
|
174
|
+
// bubble-phase pointer events, and Base UI's dismissal listens in capture.
|
|
175
|
+
const swallow = (event: React.SyntheticEvent) => event.stopPropagation();
|
|
176
|
+
render(
|
|
177
|
+
<div onPointerDown={swallow} onMouseDown={swallow}>
|
|
178
|
+
<div data-testid="overlay" aria-label="Composition canvas">
|
|
179
|
+
overlay
|
|
180
|
+
</div>
|
|
181
|
+
<Menu trigger={<button data-testid="trigger">Actions</button>} aria-label="Actions">
|
|
182
|
+
<MenuItem>Bring to front</MenuItem>
|
|
183
|
+
</Menu>
|
|
184
|
+
</div>,
|
|
185
|
+
);
|
|
186
|
+
act(() => trigger().click());
|
|
187
|
+
await settle();
|
|
188
|
+
expect(popup()).not.toBeNull();
|
|
189
|
+
|
|
190
|
+
// Witness: a bubble-phase document listener is the thing the overlay
|
|
191
|
+
// starves. If it starts firing, the press stopped being swallowed and this
|
|
192
|
+
// test would pass for a reason that says nothing about capture-phase
|
|
193
|
+
// dismissal.
|
|
194
|
+
let sawBubblePress = false;
|
|
195
|
+
const witness = () => {
|
|
196
|
+
sawBubblePress = true;
|
|
197
|
+
};
|
|
198
|
+
document.addEventListener("pointerdown", witness);
|
|
199
|
+
press(one('[data-testid="overlay"]', "overlay"));
|
|
200
|
+
document.removeEventListener("pointerdown", witness);
|
|
201
|
+
|
|
202
|
+
expect(sawBubblePress).toBe(false);
|
|
203
|
+
expect(popup()).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("MenuRadioGroup", () => {
|
|
208
|
+
it("selects one item and unselects the rest, and reports aria-checked", async () => {
|
|
209
|
+
function SpeedMenu(): React.JSX.Element {
|
|
210
|
+
const [rate, setRate] = useState(1);
|
|
211
|
+
return (
|
|
212
|
+
<Menu trigger={<button data-testid="trigger">{rate}x</button>} aria-label="Playback speed">
|
|
213
|
+
<MenuRadioGroup value={rate} onValueChange={(next) => setRate(next as number)}>
|
|
214
|
+
{[0.5, 1, 2].map((speed) => (
|
|
215
|
+
<MenuRadioItem key={speed} value={speed}>
|
|
216
|
+
{speed}x
|
|
217
|
+
</MenuRadioItem>
|
|
218
|
+
))}
|
|
219
|
+
</MenuRadioGroup>
|
|
220
|
+
</Menu>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
render(<SpeedMenu />);
|
|
224
|
+
act(() => trigger().click());
|
|
225
|
+
await settle();
|
|
226
|
+
|
|
227
|
+
const radios = () => [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
|
|
228
|
+
const checked = () => radios().filter((el) => el.getAttribute("aria-checked") === "true");
|
|
229
|
+
|
|
230
|
+
expect(radios()).toHaveLength(3);
|
|
231
|
+
expect(checked().map((el) => el.textContent)).toEqual(["1x"]);
|
|
232
|
+
|
|
233
|
+
act(() => radios()[2].click());
|
|
234
|
+
await settle();
|
|
235
|
+
|
|
236
|
+
expect(checked().map((el) => el.textContent)).toEqual(["2x"]);
|
|
237
|
+
expect(trigger().textContent).toBe("2x");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("ContextMenu", () => {
|
|
242
|
+
it("opens on a right click over its area and closes when an item runs", async () => {
|
|
243
|
+
const activated: string[] = [];
|
|
244
|
+
render(
|
|
245
|
+
<ContextMenu
|
|
246
|
+
aria-label="Clip actions"
|
|
247
|
+
trigger={
|
|
248
|
+
<div data-testid="trigger">
|
|
249
|
+
<span>clip</span>
|
|
250
|
+
</div>
|
|
251
|
+
}
|
|
252
|
+
>
|
|
253
|
+
<MenuItem onClick={() => activated.push("split")}>Split</MenuItem>
|
|
254
|
+
</ContextMenu>,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// The trigger renders the caller's element and keeps its children.
|
|
258
|
+
expect(trigger().textContent).toBe("clip");
|
|
259
|
+
expect(popup()).toBeNull();
|
|
260
|
+
|
|
261
|
+
act(() => {
|
|
262
|
+
trigger().dispatchEvent(
|
|
263
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, composed: true }),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
await settle();
|
|
267
|
+
expect(popup()).not.toBeNull();
|
|
268
|
+
|
|
269
|
+
act(() => items()[0].click());
|
|
270
|
+
await settle();
|
|
271
|
+
|
|
272
|
+
expect(activated).toEqual(["split"]);
|
|
273
|
+
expect(popup()).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("Popover", () => {
|
|
278
|
+
it("leaves the keys of a text field inside it alone", async () => {
|
|
279
|
+
// The Menu owns ArrowDown and typeahead; a Popover must not, or the rename
|
|
280
|
+
// field in AssetContextMenu would lose its own caret keys.
|
|
281
|
+
render(
|
|
282
|
+
<Popover trigger={<button data-testid="trigger">Rename</button>} aria-label="Rename asset">
|
|
283
|
+
<input data-testid="rename" defaultValue="scene1" />
|
|
284
|
+
</Popover>,
|
|
285
|
+
);
|
|
286
|
+
act(() => trigger().click());
|
|
287
|
+
await settle();
|
|
288
|
+
|
|
289
|
+
const input = one<HTMLInputElement>('[data-testid="rename"]', "rename field");
|
|
290
|
+
act(() => input.focus());
|
|
291
|
+
expect(document.activeElement).toBe(input);
|
|
292
|
+
|
|
293
|
+
key("ArrowDown");
|
|
294
|
+
key("ArrowUp");
|
|
295
|
+
key("s");
|
|
296
|
+
await settle();
|
|
297
|
+
|
|
298
|
+
expect(document.activeElement).toBe(input);
|
|
299
|
+
expect(document.querySelector('[role="menu"]')).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("hotkey classification", () => {
|
|
304
|
+
it("classifies menu rows and popover contents the way the menus they replace were", async () => {
|
|
305
|
+
// `typingTarget` and `playbackShortcuts` match exact role strings; Base UI renders
|
|
306
|
+
// a `<div>` with those roles, so the roles alone must carry the classification.
|
|
307
|
+
render(
|
|
308
|
+
<>
|
|
309
|
+
<Menu trigger={<button data-testid="trigger">Actions</button>} aria-label="Actions">
|
|
310
|
+
<MenuItem>Split</MenuItem>
|
|
311
|
+
<MenuRadioGroup value={1}>
|
|
312
|
+
<MenuRadioItem value={1}>1x</MenuRadioItem>
|
|
313
|
+
</MenuRadioGroup>
|
|
314
|
+
</Menu>
|
|
315
|
+
</>,
|
|
316
|
+
);
|
|
317
|
+
act(() => trigger().click());
|
|
318
|
+
await settle();
|
|
319
|
+
|
|
320
|
+
const row = one('[role="menuitem"]', "menu item");
|
|
321
|
+
const radio = one('[role="menuitemradio"]', "radio item");
|
|
322
|
+
|
|
323
|
+
for (const el of [row, radio]) {
|
|
324
|
+
expect(isTypingTarget(el)).toBe(false);
|
|
325
|
+
expect(shouldIgnorePlaybackShortcutTarget(el)).toBe(true);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("treats a field inside a Popover as somewhere the user is typing", async () => {
|
|
330
|
+
render(
|
|
331
|
+
<Popover trigger={<button data-testid="trigger">Rename</button>} aria-label="Rename asset">
|
|
332
|
+
<input data-testid="rename" defaultValue="scene1" />
|
|
333
|
+
</Popover>,
|
|
334
|
+
);
|
|
335
|
+
act(() => trigger().click());
|
|
336
|
+
await settle();
|
|
337
|
+
|
|
338
|
+
const input = one('[data-testid="rename"]', "rename field");
|
|
339
|
+
|
|
340
|
+
expect(isTypingTarget(input)).toBe(true);
|
|
341
|
+
expect(shouldIgnorePlaybackShortcutTarget(input)).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Class resolution is covered by `styles/tokenGate.test.ts`; this checks what the compiled rule
|
|
347
|
+
* does (AE4).
|
|
348
|
+
*/
|
|
349
|
+
describe("open motion", () => {
|
|
350
|
+
let compileStudioCss: (candidates: string[]) => string;
|
|
351
|
+
|
|
352
|
+
beforeAll(async () => {
|
|
353
|
+
const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
|
|
354
|
+
base: STYLES_DIR,
|
|
355
|
+
// Tailwind's signature wants a promise; the shared resolver is sync.
|
|
356
|
+
loadStylesheet: async (id, base) => loadStylesheet(id, base),
|
|
357
|
+
});
|
|
358
|
+
compileStudioCss = (candidates) => compiled.build(candidates);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// AE4.
|
|
362
|
+
it("names a motion token that zeroes itself under reduced motion", async () => {
|
|
363
|
+
// The zero-duration case lives in the `duration-open` utility itself
|
|
364
|
+
// (theme.css), not in a `motion-reduce:` class beside it, so a caller
|
|
365
|
+
// cannot use the token and forget the reduced-motion half. The menu's job
|
|
366
|
+
// is to name the token; the stylesheet's job is the media query.
|
|
367
|
+
await openActionMenu();
|
|
368
|
+
|
|
369
|
+
expect([...popup()!.classList]).toContain("duration-open");
|
|
370
|
+
expect(compileStudioCss(["duration-open"])).toMatch(
|
|
371
|
+
/\.duration-open \{[\s\S]*?prefers-reduced-motion: reduce[\s\S]*?transition-duration: 0ms/,
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu and ContextMenu on Base UI. Dismissal is plain Base UI (capture-phase listeners survive the overlay's
|
|
3
|
+
* `stopPropagation`); `trigger` is rendered, not wrapped; `container` exists for shadow roots.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
|
|
7
|
+
import { Menu as BaseMenu } from "@base-ui/react/menu";
|
|
8
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactElement, ReactNode } from "react";
|
|
9
|
+
import { cn } from "./cn";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Base UI lets `className` be a function of the part's state. Studio's parts
|
|
13
|
+
* merge a string with `cn`, so the string form is the one they accept.
|
|
14
|
+
*/
|
|
15
|
+
type StyledProps<T extends ElementType> = Omit<ComponentPropsWithoutRef<T>, "className"> & {
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Forces the settled open look for a gallery shot; CSS-only, open state stays controlled. */
|
|
20
|
+
export type PopupPreviewState = "open";
|
|
21
|
+
|
|
22
|
+
/** Where the portal puts the popup. `null` keeps it inline, next to its trigger. */
|
|
23
|
+
type PortalContainer = ComponentPropsWithoutRef<typeof BaseMenu.Portal>["container"];
|
|
24
|
+
|
|
25
|
+
/** Matches Tooltip's gap from its trigger, and its viewport margin. */
|
|
26
|
+
const SIDE_OFFSET = 6;
|
|
27
|
+
const VIEWPORT_MARGIN = 8;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Chrome shared by every floating panel (`Popover` too); callers add their own shadow token.
|
|
31
|
+
* `data-starting-style`/`data-ending-style` are Base UI's transition attributes.
|
|
32
|
+
*/
|
|
33
|
+
export const popupSurface = cn(
|
|
34
|
+
"rounded-lg border border-border-input bg-surface",
|
|
35
|
+
"origin-[var(--transform-origin)] outline-hidden",
|
|
36
|
+
"transition-[opacity,transform] ease-out-quint duration-open",
|
|
37
|
+
"data-[starting-style]:opacity-0 data-[starting-style]:scale-95",
|
|
38
|
+
"data-[ending-style]:opacity-0 data-[ending-style]:scale-95",
|
|
39
|
+
"data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The popup's own layer: menus sit above panel chrome and below a modal. */
|
|
43
|
+
const POPUP_LAYER = "z-200";
|
|
44
|
+
|
|
45
|
+
const menuPopup = cn(popupSurface, "min-w-36 p-1 shadow-menu");
|
|
46
|
+
|
|
47
|
+
/** One row. `data-highlighted` is set by keyboard and pointer alike, so the seen row is the Enter row. */
|
|
48
|
+
const itemBase = cn(
|
|
49
|
+
"flex cursor-default select-none items-center justify-between gap-6 rounded-sm px-2 py-1.5",
|
|
50
|
+
"text-step-11 whitespace-nowrap text-text-1",
|
|
51
|
+
"outline-hidden transition-colors ease-out-quint duration-hover",
|
|
52
|
+
"data-[highlighted]:bg-hover data-[highlighted]:text-text-0",
|
|
53
|
+
"data-[preview-state=hover]:bg-hover data-[preview-state=hover]:text-text-0",
|
|
54
|
+
"data-[disabled]:pointer-events-none data-[disabled]:opacity-40",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const itemDanger = cn("text-danger data-[highlighted]:bg-danger/15 data-[highlighted]:text-danger");
|
|
58
|
+
|
|
59
|
+
export type MenuItemTone = "default" | "danger";
|
|
60
|
+
|
|
61
|
+
interface PositionedProps {
|
|
62
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
63
|
+
align?: "start" | "center" | "end";
|
|
64
|
+
sideOffset?: number;
|
|
65
|
+
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
66
|
+
container?: PortalContainer;
|
|
67
|
+
/** Names the popup for assistive tech. A menu with no name is unlabelled. */
|
|
68
|
+
"aria-label"?: string;
|
|
69
|
+
className?: string;
|
|
70
|
+
"data-preview-state"?: PopupPreviewState;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface MenuProps
|
|
74
|
+
extends PositionedProps, Omit<ComponentPropsWithoutRef<typeof BaseMenu.Root>, "children"> {
|
|
75
|
+
/** A single element. It becomes the trigger; no wrapper is added around it. */
|
|
76
|
+
trigger: ReactElement;
|
|
77
|
+
/** The items: `MenuItem`, `MenuRadioGroup`, `MenuSeparator`. */
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A menu opened by its trigger. Uncontrolled by default; pass `open` and
|
|
83
|
+
* `onOpenChange` for a menu whose state lives in a store.
|
|
84
|
+
*/
|
|
85
|
+
export function Menu({
|
|
86
|
+
trigger,
|
|
87
|
+
children,
|
|
88
|
+
side = "bottom",
|
|
89
|
+
align = "start",
|
|
90
|
+
sideOffset = SIDE_OFFSET,
|
|
91
|
+
container,
|
|
92
|
+
className,
|
|
93
|
+
"aria-label": ariaLabel,
|
|
94
|
+
"data-preview-state": previewState,
|
|
95
|
+
...root
|
|
96
|
+
}: MenuProps) {
|
|
97
|
+
return (
|
|
98
|
+
<BaseMenu.Root {...root}>
|
|
99
|
+
<BaseMenu.Trigger render={trigger} />
|
|
100
|
+
<BaseMenu.Portal container={container}>
|
|
101
|
+
<BaseMenu.Positioner
|
|
102
|
+
side={side}
|
|
103
|
+
align={align}
|
|
104
|
+
sideOffset={sideOffset}
|
|
105
|
+
collisionPadding={VIEWPORT_MARGIN}
|
|
106
|
+
className={POPUP_LAYER}
|
|
107
|
+
>
|
|
108
|
+
<BaseMenu.Popup
|
|
109
|
+
aria-label={ariaLabel}
|
|
110
|
+
data-preview-state={previewState}
|
|
111
|
+
className={cn(menuPopup, className)}
|
|
112
|
+
>
|
|
113
|
+
{children}
|
|
114
|
+
</BaseMenu.Popup>
|
|
115
|
+
</BaseMenu.Positioner>
|
|
116
|
+
</BaseMenu.Portal>
|
|
117
|
+
</BaseMenu.Root>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ContextMenuProps
|
|
122
|
+
extends PositionedProps, Omit<ComponentPropsWithoutRef<typeof BaseContextMenu.Root>, "children"> {
|
|
123
|
+
/**
|
|
124
|
+
* The right-clickable area, as the caller's own element. Its children are
|
|
125
|
+
* kept: the trigger renders that element rather than wrapping it.
|
|
126
|
+
*/
|
|
127
|
+
trigger: ReactElement;
|
|
128
|
+
children: ReactNode;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Opened by right click or long press at the pointer, so `side` and `align` are not offered. */
|
|
132
|
+
export function ContextMenu({
|
|
133
|
+
trigger,
|
|
134
|
+
children,
|
|
135
|
+
container,
|
|
136
|
+
className,
|
|
137
|
+
"aria-label": ariaLabel,
|
|
138
|
+
"data-preview-state": previewState,
|
|
139
|
+
...root
|
|
140
|
+
}: Omit<ContextMenuProps, "side" | "align" | "sideOffset">) {
|
|
141
|
+
return (
|
|
142
|
+
<BaseContextMenu.Root {...root}>
|
|
143
|
+
<BaseContextMenu.Trigger render={trigger} />
|
|
144
|
+
<BaseContextMenu.Portal container={container}>
|
|
145
|
+
<BaseContextMenu.Positioner collisionPadding={VIEWPORT_MARGIN} className={POPUP_LAYER}>
|
|
146
|
+
<BaseContextMenu.Popup
|
|
147
|
+
aria-label={ariaLabel}
|
|
148
|
+
data-preview-state={previewState}
|
|
149
|
+
className={cn(menuPopup, className)}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</BaseContextMenu.Popup>
|
|
153
|
+
</BaseContextMenu.Positioner>
|
|
154
|
+
</BaseContextMenu.Portal>
|
|
155
|
+
</BaseContextMenu.Root>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Keyboard shortcut hint, mono and tabular; exported for hints that are not a plain string. */
|
|
160
|
+
export function MenuShortcut({ className, ...props }: StyledProps<"span">) {
|
|
161
|
+
return (
|
|
162
|
+
<span
|
|
163
|
+
className={cn("shrink-0 font-mono text-step-10 tabular-nums text-text-4", className)}
|
|
164
|
+
aria-hidden="true"
|
|
165
|
+
{...props}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface MenuItemProps extends StyledProps<typeof BaseMenu.Item> {
|
|
171
|
+
/** Rendered as a dim mono hint on the trailing edge. Decorative. */
|
|
172
|
+
shortcut?: string;
|
|
173
|
+
/** `danger` for a destructive action (Delete, Remove). */
|
|
174
|
+
tone?: MenuItemTone;
|
|
175
|
+
"data-preview-state"?: "hover";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One action. `disabled` items are skipped by the arrow keys, not just dimmed. */
|
|
179
|
+
export function MenuItem({ shortcut, tone, className, children, ...props }: MenuItemProps) {
|
|
180
|
+
return (
|
|
181
|
+
<BaseMenu.Item className={cn(itemBase, tone === "danger" && itemDanger, className)} {...props}>
|
|
182
|
+
<span className="truncate">{children}</span>
|
|
183
|
+
{shortcut ? <MenuShortcut>{shortcut}</MenuShortcut> : null}
|
|
184
|
+
</BaseMenu.Item>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Single-choice group; selection is `aria-checked` on each item, which the dot indicator draws from. */
|
|
189
|
+
export function MenuRadioGroup(props: ComponentPropsWithoutRef<typeof BaseMenu.RadioGroup>) {
|
|
190
|
+
return <BaseMenu.RadioGroup {...props} />;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface MenuRadioItemProps extends StyledProps<typeof BaseMenu.RadioItem> {
|
|
194
|
+
"data-preview-state"?: "hover";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One choice in a `MenuRadioGroup`. The dot renders only while it is checked. */
|
|
198
|
+
export function MenuRadioItem({ className, children, ...props }: MenuRadioItemProps) {
|
|
199
|
+
return (
|
|
200
|
+
<BaseMenu.RadioItem className={cn(itemBase, className)} {...props}>
|
|
201
|
+
<span className="truncate">{children}</span>
|
|
202
|
+
{/* Fixed box so a checked and an unchecked row keep the same width. */}
|
|
203
|
+
<span className="flex size-3 shrink-0 items-center justify-center">
|
|
204
|
+
<BaseMenu.RadioItemIndicator className="size-1.5 rounded-full bg-accent" />
|
|
205
|
+
</span>
|
|
206
|
+
</BaseMenu.RadioItem>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A hairline between two groups of items. */
|
|
211
|
+
export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
|
|
212
|
+
return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
|
|
213
|
+
}
|