diff --git a/apps/frontend/src/components/DashboardScreen.tsx b/apps/frontend/src/components/DashboardScreen.tsx index 17c1be26..7090cd99 100644 --- a/apps/frontend/src/components/DashboardScreen.tsx +++ b/apps/frontend/src/components/DashboardScreen.tsx @@ -1,3 +1,4 @@ +import { ControlTooltip } from "@/components/ui/tooltip"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -546,16 +547,18 @@ export function DashboardVersionBadge({ if (!latestRelease) { return ( - - {versionLabel} - + + + {versionLabel} + + ); } @@ -573,7 +576,6 @@ export function DashboardVersionBadge({ "gap-1.5 border-primary/40 bg-primary/10 text-primary transition-colors hover:bg-primary/15 focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:outline-none", )} aria-label={`${updateLabel}. View release notes.`} - title={updateLabel} data-dashboard-version-badge data-dashboard-update-available > @@ -824,17 +826,18 @@ export default function DashboardScreen({ Sources {renderViewerFilterPicker()} - + + +
@@ -855,67 +858,71 @@ export default function DashboardScreen({ Sources {renderViewerFilterPicker()} - - - - - - - - - - {!showViewerFilterControl && "Disconnect"} - + + + + + + + + + + + + + + + + + {!showViewerFilterControl && "Disconnect"} + +
diff --git a/apps/frontend/src/components/MobilePwaInstallNudge.tsx b/apps/frontend/src/components/MobilePwaInstallNudge.tsx index 9559ac54..665ef72b 100644 --- a/apps/frontend/src/components/MobilePwaInstallNudge.tsx +++ b/apps/frontend/src/components/MobilePwaInstallNudge.tsx @@ -1,3 +1,4 @@ +import { ControlTooltip } from "@/components/ui/tooltip"; import { Download, Share, Smartphone, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { @@ -99,15 +100,16 @@ export function MobilePwaInstallNudgeCard({ : "Open it like an app with a faster, full-screen experience."}

- + + +
diff --git a/apps/frontend/src/components/editor/EditorControls.tsx b/apps/frontend/src/components/editor/EditorControls.tsx index dae877a4..2c2d4d9a 100644 --- a/apps/frontend/src/components/editor/EditorControls.tsx +++ b/apps/frontend/src/components/editor/EditorControls.tsx @@ -1,9 +1,4 @@ -import { - useMemo, - type CSSProperties, - type ReactElement, - type ReactNode, -} from "react"; +import { useMemo, type CSSProperties, type ReactNode } from "react"; import { Camera, Pause, @@ -15,11 +10,7 @@ import { ZoomIn, ZoomOut, } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { ControlTooltip } from "@/components/ui/tooltip"; import { Drawer, DrawerContent, @@ -67,31 +58,6 @@ interface EditorControlsProperties { onFitSelection: () => void; } -function ControlTooltip({ - label, - disabled = false, - children, -}: { - label: string; - disabled?: boolean; - children: ReactElement; -}) { - return ( - - - {disabled ? ( - - {children} - - ) : ( - children - )} - - {label} - - ); -} - export function EditorControls({ variant = "desktop", playbackSourcePanel, diff --git a/apps/frontend/src/components/editor/EditorExportDialog.tsx b/apps/frontend/src/components/editor/EditorExportDialog.tsx index 12ec80be..00925f8d 100644 --- a/apps/frontend/src/components/editor/EditorExportDialog.tsx +++ b/apps/frontend/src/components/editor/EditorExportDialog.tsx @@ -1,4 +1,5 @@ -import { Download } from "lucide-react"; +import { Download, FileText } from "lucide-react"; +import { BouncyAccordion } from "@/components/ui/bouncy-accordion"; import type { ExportFormat, ExportResolution, @@ -185,20 +186,24 @@ export function EditorExportDialog({ hlsSourceLabel={hlsSourceLabel} /> -
- - Advanced filename settings - -
- -
-
+ , + description: ( + + ), + }, + ]} + />
diff --git a/apps/frontend/src/components/editor/EditorMediaRange.tsx b/apps/frontend/src/components/editor/EditorMediaRange.tsx index a7d39c13..ce7857dd 100644 --- a/apps/frontend/src/components/editor/EditorMediaRange.tsx +++ b/apps/frontend/src/components/editor/EditorMediaRange.tsx @@ -1,3 +1,4 @@ +import { ControlTooltip } from "@/components/ui/tooltip"; import { RangeScrollbar, Timeline, @@ -39,11 +40,12 @@ export function EditorMediaRange({ engine }: { engine: TimelineEngine }) { className="editor-media-range-row" style={{ top: rect.y, height: rect.height }} > - + + + - - - - + + + + + + + + + + ); diff --git a/apps/frontend/src/components/editor/EditorScreen.tsx b/apps/frontend/src/components/editor/EditorScreen.tsx index c4efebd2..c45b80a1 100644 --- a/apps/frontend/src/components/editor/EditorScreen.tsx +++ b/apps/frontend/src/components/editor/EditorScreen.tsx @@ -1,3 +1,4 @@ +import { ConfirmationDialog } from "@/components/ui/confirmation-dialog"; import { lazy, Suspense, @@ -152,6 +153,7 @@ function EditorScreenContent({ const [editorPropertiesOpenSections, setEditorPropertiesOpenSections] = useState(loadEditorPropertiesOpenSections); const [exportDialogMounted, setExportDialogMounted] = useState(false); + const subtitleTrackTriggerReference = useRef(null); const { subtitleTracks, selectedSubtitleTrack, @@ -170,6 +172,9 @@ function EditorScreenContent({ clippedSubtitleCues, subtitleExportSummary, handleSelectedSubtitleTrackChange, + subtitleTrackChangePending, + confirmSubtitleTrackChange, + cancelSubtitleTrackChange, selectedSubtitleCue, handleSelectedSubtitleTextCommit, handleSelectedSubtitleStartCommit, @@ -604,6 +609,8 @@ function EditorScreenContent({ providerId={session.source.providerId} subtitleTracks={subtitleTracks} selectedSubtitleTrackKey={selectedSubtitleTrackKey} + subtitleTrackChangePending={subtitleTrackChangePending} + subtitleTrackTriggerRef={subtitleTrackTriggerReference} onSelectedSubtitleTrackKeyChange={handleSelectedSubtitleTrackChange} subtitlesEnabled={subtitleEnabled} onSubtitlesEnabledChange={setSubtitleEnabled} @@ -649,6 +656,15 @@ function EditorScreenContent({ return (
+ ; onSelectedSubtitleTrackKeyChange: (value: string) => void; subtitlesEnabled: boolean; onSubtitlesEnabledChange: (value: boolean) => void; @@ -85,6 +93,8 @@ export function EditorSubtitlePanel({ providerId, subtitleTracks, selectedSubtitleTrackKey, + subtitleTrackChangePending, + subtitleTrackTriggerRef, onSelectedSubtitleTrackKeyChange, subtitlesEnabled, onSubtitlesEnabledChange, @@ -207,13 +217,21 @@ export function EditorSubtitlePanel({ onValueChange={onSelectedSubtitleTrackKeyChange} > - + { + // The confirmation owns focus while it is open. + if (subtitleTrackChangePending) { + event.preventDefault(); + } + }} + > Subtitle Tracks No subtitles diff --git a/apps/frontend/src/components/editor/EditorTimeline.tsx b/apps/frontend/src/components/editor/EditorTimeline.tsx index 7d4ed3f2..58ded085 100644 --- a/apps/frontend/src/components/editor/EditorTimeline.tsx +++ b/apps/frontend/src/components/editor/EditorTimeline.tsx @@ -1,3 +1,4 @@ +import { ControlTooltip } from "@/components/ui/tooltip"; import { CanvasRenderer, Timeline, @@ -43,35 +44,41 @@ function TrackHeaderColumn({ muted, onMutedChange }: EditorTimelineProperties) { {(header: UseTimelineTrackHeaderResult) => (
{track.id === EDITOR_MEDIA_TRACK_ID ? ( - + + ) : ( - + + )} {header.label} diff --git a/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx b/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx index 35e5ab2b..43d733f0 100644 --- a/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx +++ b/apps/frontend/src/components/editor/EditorViewportScrollbar.tsx @@ -1,3 +1,4 @@ +import { ControlTooltip } from "@/components/ui/tooltip"; import { RangeScrollbar, Timeline, @@ -37,92 +38,95 @@ function ZoomHandle({ }; return ( - ) => { - const delta = { - ArrowLeft: -10, - ArrowRight: 10, - PageUp: -120, - PageDown: 120, - }[event.key]; - if (delta === undefined) { - return; + + ) => { + const delta = { + ArrowLeft: -10, + ArrowRight: 10, + PageUp: -120, + PageDown: 120, + }[event.key]; + if (delta === undefined) { + return; + } + event.preventDefault(); + event.stopPropagation(); + control.onValueChange( + viewportRangeAfterZoomDrag({ + range: { + start: control.viewStartSeconds, + end: control.viewEndSeconds, + }, + side, + deltaPixels: delta, + minSpan: control.range.minSpan, + duration: control.totalDurationSeconds, + }), + { reason: "handle-keyboard", side }, + ); + }} + onPointerDown={(event: PointerEvent) => { + // Preserve the library's accessible handle while replacing its + // full-duration linear pointer sensitivity with proportional zoom. + event.preventDefault(); + event.stopPropagation(); + if ( + (event.pointerType !== "touch" && event.button !== 0) || + drag.current + ) { + return; + } + event.currentTarget.focus(); + drag.current = { + pointerId: event.pointerId, + clientX: event.clientX, range: { start: control.viewStartSeconds, end: control.viewEndSeconds, }, - side, - deltaPixels: delta, - minSpan: control.range.minSpan, - duration: control.totalDurationSeconds, - }), - { reason: "handle-keyboard", side }, - ); - }} - onPointerDown={(event: PointerEvent) => { - // Preserve the library's accessible handle while replacing its - // full-duration linear pointer sensitivity with proportional zoom. - event.preventDefault(); - event.stopPropagation(); - if ( - (event.pointerType !== "touch" && event.button !== 0) || - drag.current - ) { - return; - } - event.currentTarget.focus(); - drag.current = { - pointerId: event.pointerId, - clientX: event.clientX, - range: { - start: control.viewStartSeconds, - end: control.viewEndSeconds, - }, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }} - onPointerMove={(event: PointerEvent) => { - const active = drag.current; - if (!active || active.pointerId !== event.pointerId) { - return; - } - control.onValueChange( - viewportRangeAfterZoomDrag({ - range: active.range, - side, - deltaPixels: event.clientX - active.clientX, - minSpan: control.range.minSpan, - duration: control.totalDurationSeconds, - }), - { reason: "handle-drag", side }, - ); - }} - onPointerUp={finishDrag} - onPointerCancel={finishDrag} - onLostPointerCapture={finishDrag} - /> + }; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event: PointerEvent) => { + const active = drag.current; + if (!active || active.pointerId !== event.pointerId) { + return; + } + control.onValueChange( + viewportRangeAfterZoomDrag({ + range: active.range, + side, + deltaPixels: event.clientX - active.clientX, + minSpan: control.range.minSpan, + duration: control.totalDurationSeconds, + }), + { reason: "handle-drag", side }, + ); + }} + onPointerUp={finishDrag} + onPointerCancel={finishDrag} + onLostPointerCapture={finishDrag} + /> + ); } diff --git a/apps/frontend/src/components/editor/useEditorSubtitles.ts b/apps/frontend/src/components/editor/useEditorSubtitles.ts index 12c7b43e..b9968c2d 100644 --- a/apps/frontend/src/components/editor/useEditorSubtitles.ts +++ b/apps/frontend/src/components/editor/useEditorSubtitles.ts @@ -90,6 +90,9 @@ export function useEditorSubtitles({ const [selectedSubtitleTrackKey, setSelectedSubtitleTrackKey] = useState( initialSubtitleSelection.key, ); + const [pendingSubtitleTrackKey, setPendingSubtitleTrackKey] = useState< + string | null + >(null); const [importedSubtitleTrackKey, setImportedSubtitleTrackKey] = useState< string | null >(initialDraft?.subtitles.importedTrackKey ?? null); @@ -241,20 +244,8 @@ export function useEditorSubtitles({ setSubtitleEnabled(subtitleTrackSupportsBurnIn(preferredSubtitleTrack)); }, [session.selectedSubtitleTrack, subtitleTracks]); - const handleSelectedSubtitleTrackChange = useCallback( + const applySubtitleTrackChange = useCallback( (value: string) => { - if ( - value !== "none" && - value !== importedSubtitleTrackKey && - subtitleCues.length > 0 && - globalThis.window !== undefined && - !globalThis.confirm( - "Changing subtitle tracks will replace your customized subtitle cues. Continue?", - ) - ) { - return; - } - subtitleTrackSelectionChangedByUserReference.current = true; setSelectedSubtitleTrackKey(value); clearSubtitleError(); @@ -272,15 +263,33 @@ export function useEditorSubtitles({ Boolean(nextTrack && subtitleTrackSupportsBurnIn(nextTrack)), ); }, - [ - clearSubtitleError, - importedSubtitleTrackKey, - resetSubtitleCues, - subtitleCues.length, - subtitleTracks, - ], + [clearSubtitleError, resetSubtitleCues, subtitleTracks], ); + const handleSelectedSubtitleTrackChange = useCallback( + (value: string) => { + if ( + value !== "none" && + value !== importedSubtitleTrackKey && + subtitleCues.length > 0 + ) { + setPendingSubtitleTrackKey(value); + return; + } + + applySubtitleTrackChange(value); + }, + [applySubtitleTrackChange, importedSubtitleTrackKey, subtitleCues.length], + ); + + function confirmSubtitleTrackChange() { + if (pendingSubtitleTrackKey === null) { + return; + } + applySubtitleTrackChange(pendingSubtitleTrackKey); + setPendingSubtitleTrackKey(null); + } + const handleSelectedSubtitleTextCommit = useCallback( (text: string) => { if (!selectedSubtitleClip) { @@ -385,6 +394,9 @@ export function useEditorSubtitles({ clippedSubtitleCues, subtitleExportSummary, handleSelectedSubtitleTrackChange, + subtitleTrackChangePending: pendingSubtitleTrackKey !== null, + confirmSubtitleTrackChange, + cancelSubtitleTrackChange: () => setPendingSubtitleTrackKey(null), selectedSubtitleCue, handleSelectedSubtitleTextCommit, handleSelectedSubtitleStartCommit, diff --git a/apps/frontend/src/components/frontendWorkflow.test.ts b/apps/frontend/src/components/frontendWorkflow.test.ts index 8aec3a2f..c3bba962 100644 --- a/apps/frontend/src/components/frontendWorkflow.test.ts +++ b/apps/frontend/src/components/frontendWorkflow.test.ts @@ -412,30 +412,36 @@ void test("renders dashboard version badge as a release link when an update is a void test("renders dashboard dev version badge with disabled update checks", () => { const markup = renderToStaticMarkup( - createElement(DashboardVersionBadge, { - versionLabel: "dev", - latestRelease: null, - releaseChecksDisabledReason: - "Local development build; release update checks are disabled", - }), + createElement( + TooltipProvider, + null, + createElement(DashboardVersionBadge, { + versionLabel: "dev", + latestRelease: null, + releaseChecksDisabledReason: + "Local development build; release update checks are disabled", + }), + ), ); assert.match(markup, /data-dashboard-version-badge/); assert.match(markup, /data-dashboard-release-check-disabled="true"/); assert.match(markup, />dev { const markup = renderToStaticMarkup( - createElement(MobilePwaInstallNudgeCard, { - mode: "native", - onDismiss: () => {}, - onInstall: () => {}, - }), + createElement( + TooltipProvider, + null, + createElement(MobilePwaInstallNudgeCard, { + mode: "native", + onDismiss: () => {}, + onInstall: () => {}, + }), + ), ); assert.match(markup, /Add Cliparr to your home screen/); @@ -445,11 +451,15 @@ void test("renders mobile PWA install nudge for native install state", () => { void test("hides mobile PWA install nudge by default", () => { const markup = renderToStaticMarkup( - createElement(MobilePwaInstallNudgeCard, { - mode: "hidden", - onDismiss: () => {}, - onInstall: () => {}, - }), + createElement( + TooltipProvider, + null, + createElement(MobilePwaInstallNudgeCard, { + mode: "hidden", + onDismiss: () => {}, + onInstall: () => {}, + }), + ), ); assert.equal(markup, ""); @@ -457,13 +467,17 @@ void test("hides mobile PWA install nudge by default", () => { void test("does not render dashboard PWA nudge in default server markup", () => { const markup = renderToStaticMarkup( - createElement(DashboardScreen, { - activeViewTransitionSessionId: null, - onSelectSession: () => {}, - onOpenLocalVideo: () => {}, - onOpenSources: () => {}, - onDisconnect: () => {}, - }), + createElement( + TooltipProvider, + null, + createElement(DashboardScreen, { + activeViewTransitionSessionId: null, + onSelectSession: () => {}, + onOpenLocalVideo: () => {}, + onOpenSources: () => {}, + onDisconnect: () => {}, + }), + ), ); assert.doesNotMatch(markup, /Add Cliparr to your home screen/); @@ -471,13 +485,17 @@ void test("does not render dashboard PWA nudge in default server markup", () => void test("reserves dashboard playback card space before sessions load", () => { const markup = renderToStaticMarkup( - createElement(DashboardScreen, { - activeViewTransitionSessionId: null, - onSelectSession: () => {}, - onOpenLocalVideo: () => {}, - onOpenSources: () => {}, - onDisconnect: () => {}, - }), + createElement( + TooltipProvider, + null, + createElement(DashboardScreen, { + activeViewTransitionSessionId: null, + onSelectSession: () => {}, + onOpenLocalVideo: () => {}, + onOpenSources: () => {}, + onDisconnect: () => {}, + }), + ), ); assert.match(markup, /data-dashboard-loading-grid/); @@ -492,13 +510,17 @@ void test("reserves dashboard playback card space before sessions load", () => { void test("reserves dashboard version badge space before health loads", () => { const markup = renderToStaticMarkup( - createElement(DashboardScreen, { - activeViewTransitionSessionId: null, - onSelectSession: () => {}, - onOpenLocalVideo: () => {}, - onOpenSources: () => {}, - onDisconnect: () => {}, - }), + createElement( + TooltipProvider, + null, + createElement(DashboardScreen, { + activeViewTransitionSessionId: null, + onSelectSession: () => {}, + onOpenLocalVideo: () => {}, + onOpenSources: () => {}, + onDisconnect: () => {}, + }), + ), ); assert.match(markup, /data-dashboard-version-badge/); @@ -507,7 +529,13 @@ void test("reserves dashboard version badge space before health loads", () => { void test("renders mobile PWA install nudge on the initial eligible browser pass", () => { withMobilePwaBrowserEnvironment(() => { - const markup = renderToStaticMarkup(createElement(MobilePwaInstallNudge)); + const markup = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(MobilePwaInstallNudge), + ), + ); assert.match(markup, /Add Cliparr to your home screen/); assert.match(markup, /data-pwa-install-mode="ios"/); diff --git a/apps/frontend/src/components/sources/SourcesDialog.tsx b/apps/frontend/src/components/sources/SourcesDialog.tsx index a74a8c4c..554594ce 100644 --- a/apps/frontend/src/components/sources/SourcesDialog.tsx +++ b/apps/frontend/src/components/sources/SourcesDialog.tsx @@ -1,6 +1,7 @@ import { useRef } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { DialogWindow } from "@/components/ui/dialog"; +import { ConfirmationDialog } from "@/components/ui/confirmation-dialog"; import { SourceCard, SourcesConnectSection, @@ -70,7 +71,10 @@ export default function SourcesDialog({ saveSourceEdits, toggleSourceEnabled, checkSource, - deleteSource, + sourceToRemove, + requestSourceRemoval, + cancelSourceRemoval, + confirmSourceRemoval, updateDraftName, updateDraftBaseUrl, } = useSourcesState({ @@ -195,7 +199,7 @@ export default function SourcesDialog({ onSave={() => saveSourceEdits(source)} onToggleEnabled={() => toggleSourceEnabled(source)} onRefresh={() => checkSource(source)} - onRemove={() => deleteSource(source)} + onRemove={() => requestSourceRemoval(source)} /> ))} @@ -212,6 +216,14 @@ export default function SourcesDialog({ portalClassName="p-4 sm:p-6" popupClassName="h-full max-w-6xl rounded-lg" > + void confirmSourceRemoval()} + /> - - - {children} - - - {message} - - ); -} - interface SourceCounts { all: number; enabled: number; @@ -274,7 +247,7 @@ export function SourcesDialogHeader({ {showConnectPanel ? "Hide" : "Add Source"} )} - + - - + + - + - +
{source.enabled ? "Enabled" : "Disabled"} - + void onToggleEnabled()} /> - +
- + - - + + - +
); diff --git a/apps/frontend/src/components/sources/useSourcesState.ts b/apps/frontend/src/components/sources/useSourcesState.ts index 8ea956f8..16f09945 100644 --- a/apps/frontend/src/components/sources/useSourcesState.ts +++ b/apps/frontend/src/components/sources/useSourcesState.ts @@ -53,6 +53,9 @@ export function useSourcesState({ const [error, setError] = useState(""); const [feedback, setFeedback] = useState(null); const [showAddSource, setShowAddSource] = useState(false); + const [sourceToRemove, setSourceToRemove] = useState( + null, + ); const latestLoadRequestIdReference = useRef(0); const isOpenReference = useRef(isOpen); const sourceLogger = useMemo( @@ -285,13 +288,12 @@ export function useSourcesState({ ); } - async function deleteSource(source: MediaSource) { - const confirmed = globalThis.confirm( - `Remove ${source.name}? It can be reconnected later.`, - ); - if (!confirmed) { + async function confirmSourceRemoval() { + const source = sourceToRemove; + if (!source || !isOpenReference.current) { return; } + setSourceToRemove(null); await runSourceAction( source, @@ -387,6 +389,7 @@ export function useSourcesState({ useEffect(() => { if (!isOpen) { setShowAddSource(false); + setSourceToRemove(null); return; } @@ -464,7 +467,10 @@ export function useSourcesState({ saveSourceEdits, toggleSourceEnabled, checkSource, - deleteSource, + sourceToRemove, + requestSourceRemoval: setSourceToRemove, + cancelSourceRemoval: () => setSourceToRemove(null), + confirmSourceRemoval, updateDraftName, updateDraftBaseUrl, }; diff --git a/apps/frontend/src/components/ui/bouncy-accordion.tsx b/apps/frontend/src/components/ui/bouncy-accordion.tsx new file mode 100644 index 00000000..77804935 --- /dev/null +++ b/apps/frontend/src/components/ui/bouncy-accordion.tsx @@ -0,0 +1,179 @@ +/* + * Bouncy Accordion adapted from https://beui.dev/r/bouncy-accordion/raw + * MIT License — Copyright (c) 2026 Saurabh Chauhan + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import { ChevronDown } from "lucide-react"; +import { motion, useReducedMotion, type Transition } from "motion/react"; +import { useId, useState, type ReactNode } from "react"; +import { cliparrMotionTransitions } from "@/lib/motionPresets"; +import { cn } from "@/lib/utilities"; + +interface BouncyAccordionItem { + id: string; + title: ReactNode; + description: ReactNode; + icon?: ReactNode; + disabled?: boolean; +} + +interface BouncyAccordionProperties { + items: readonly BouncyAccordionItem[]; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + collapsible?: boolean; + className?: string; +} + +const rowTransition = { + type: "spring", + duration: 0.55, + bounce: 0.38, +} satisfies Transition; + +const contentOpenTransition = { + type: "spring", + duration: 0.58, + bounce: 0.32, +} satisfies Transition; + +const contentCloseTransition = { + type: "spring", + duration: 0.46, + bounce: 0.26, +} satisfies Transition; + +const chevronTransition = { + type: "spring", + duration: 0.42, + bounce: 0.28, +} satisfies Transition; + +export function BouncyAccordion({ + items, + value, + defaultValue = null, + onValueChange, + collapsible = true, + className, +}: BouncyAccordionProperties) { + const reducedMotion = useReducedMotion(); + const baseId = useId(); + const [internalValue, setInternalValue] = useState(defaultValue); + const activeValue = value === undefined ? internalValue : value; + const activeIndex = items.findIndex((item) => item.id === activeValue); + + function toggleItem(id: string) { + if (activeValue === id && !collapsible) { + return; + } + const nextValue = activeValue === id ? null : id; + if (value === undefined) { + setInternalValue(nextValue); + } + onValueChange?.(nextValue); + } + + return ( +
+ {items.map((item, index) => { + const open = item.id === activeValue; + const contentTransition = open + ? contentOpenTransition + : contentCloseTransition; + const previousIsOpen = activeIndex === index - 1; + const startsGroup = open || index === 0 || previousIsOpen; + const endsGroup = + open || index === items.length - 1 || activeIndex === index + 1; + const contentId = `${baseId}-${item.id}-content`; + const triggerId = `${baseId}-${item.id}-trigger`; + + return ( + 0 && (open || previousIsOpen) && "mt-3", + item.disabled && "opacity-60", + )} + > + + + + {item.description} + + + + ); + })} +
+ ); +} diff --git a/apps/frontend/src/components/ui/confirmation-dialog.tsx b/apps/frontend/src/components/ui/confirmation-dialog.tsx new file mode 100644 index 00000000..aec21279 --- /dev/null +++ b/apps/frontend/src/components/ui/confirmation-dialog.tsx @@ -0,0 +1,56 @@ +import { useRef, type ComponentProps } from "react"; +import { DialogFooter, DialogWindow } from "@/components/ui/dialog"; +import { + primaryButtonClasses, + secondaryButtonClasses, +} from "@/components/ui/control-styles"; + +export function ConfirmationDialog({ + open, + title, + description, + confirmLabel, + onConfirm, + onCancel, + finalFocus, +}: { + open: boolean; + title: string; + description: string; + confirmLabel: string; + onConfirm: () => void; + onCancel: () => void; + finalFocus?: ComponentProps["finalFocus"]; +}) { + const cancelReference = useRef(null); + + return ( + + + + + + + ); +} diff --git a/apps/frontend/src/components/ui/tooltip.tsx b/apps/frontend/src/components/ui/tooltip.tsx index 6c26fb5f..6c80b94e 100644 --- a/apps/frontend/src/components/ui/tooltip.tsx +++ b/apps/frontend/src/components/ui/tooltip.tsx @@ -52,4 +52,64 @@ function TooltipContent({ ); } +interface ControlTooltipProperties { + label?: string | null; + disabled?: boolean; + children: React.ReactElement; + side?: React.ComponentProps["side"]; +} + +export function ControlTooltip({ label, ...props }: ControlTooltipProperties) { + return label ? ( + + ) : ( + props.children + ); +} + +function LabeledControlTooltip({ + label, + disabled = false, + children, + side = "bottom", +}: ControlTooltipProperties & { label: string }) { + const [open, setOpen] = React.useState(false); + const nestedTriggerReference = React.useRef(false); + + function trackTrigger(event: React.SyntheticEvent) { + const target = event.target; + const nested = + target instanceof Element && + target.closest('[data-slot="tooltip-trigger"]') !== event.currentTarget; + nestedTriggerReference.current = nested; + if (nested) { + setOpen(false); + } + } + + return ( + { + setOpen(nextOpen && !nestedTriggerReference.current); + }} + > + + {disabled ? ( + + {children} + + ) : ( + children + )} + + {label} + + ); +} + export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; diff --git a/apps/www/src/components/BaseLayout.astro b/apps/www/src/components/BaseLayout.astro index 87c25394..9e443ed6 100644 --- a/apps/www/src/components/BaseLayout.astro +++ b/apps/www/src/components/BaseLayout.astro @@ -1,5 +1,6 @@ --- import { ClientRouter, fade } from "astro:transitions"; +import SiteTooltips from "@/components/SiteTooltips.astro"; import Footer from "@/components/Footer.astro"; import Header from "@/components/Header.astro"; import { site } from "@/data/product"; @@ -169,7 +170,7 @@ const structuredDataItems = [ "aria-pressed", theme === "dark" ? "true" : "false", ); - button.setAttribute("title", `Switch to ${nextTheme} theme`); + button.setAttribute("data-tooltip", `Switch to ${nextTheme} theme`); button.setAttribute("data-theme-toggle-state", theme); button.setAttribute("data-theme-source", source); }); @@ -243,5 +244,6 @@ const structuredDataItems = [
+ diff --git a/apps/www/src/components/CommandBlock.astro b/apps/www/src/components/CommandBlock.astro index 8fcb8d66..4015a41c 100644 --- a/apps/www/src/components/CommandBlock.astro +++ b/apps/www/src/components/CommandBlock.astro @@ -93,7 +93,7 @@ const groupId = `command-${hashCommandBlockId( aria-label="Copy command" class="command-copy-button" data-command-copy - title="Copy command" + data-tooltip="Copy command" type="button" > { button.setAttribute("aria-label", "Copy command"); - button.title = "Copy command"; + button.dataset.tooltip = "Copy command"; delete button.dataset.copyState; }, 1600); } @@ -325,11 +325,11 @@ const groupId = `command-${hashCommandBlockId( void copyCommand(item).catch(() => { item.dataset.copyState = "failed"; item.setAttribute("aria-label", "Copy failed"); - item.title = "Copy failed"; + item.dataset.tooltip = "Copy failed"; window.setTimeout(() => { item.setAttribute("aria-label", "Copy command"); - item.title = "Copy command"; + item.dataset.tooltip = "Copy command"; delete item.dataset.copyState; }, 1600); }); diff --git a/apps/www/src/components/Header.astro b/apps/www/src/components/Header.astro index c3917f67..3b9323c8 100644 --- a/apps/www/src/components/Header.astro +++ b/apps/www/src/components/Header.astro @@ -34,7 +34,7 @@ const mobileMenuId = "site-mobile-menu"; type="button" data-theme-toggle aria-label="Toggle color theme" - title="Toggle color theme" + data-tooltip="Toggle color theme" class="focus-ring inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" >
+ + diff --git a/apps/www/src/components/convert/ConvertPwaInstallPrompt.tsx b/apps/www/src/components/convert/ConvertPwaInstallPrompt.tsx index bb220153..526fab48 100644 --- a/apps/www/src/components/convert/ConvertPwaInstallPrompt.tsx +++ b/apps/www/src/components/convert/ConvertPwaInstallPrompt.tsx @@ -90,7 +90,7 @@ export function ConvertPwaInstallPromptView({ disabled={prompting} className="focus-ring inline-flex h-8 items-center justify-center gap-2 rounded-md border border-border bg-background px-3 text-xs font-semibold uppercase tracking-[var(--tracking-caps-sm)] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60" aria-label="Install Cliparr Convert" - title="Install Cliparr Convert" + data-tooltip="Install Cliparr Convert" data-convert-pwa-install-mode="native" data-convert-pwa-form-factor="desktop" > @@ -130,7 +130,7 @@ export function ConvertPwaInstallPromptView({ onClick={onDismiss} className="focus-ring inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" aria-label="Dismiss install prompt" - title="Dismiss" + data-tooltip="Dismiss" > diff --git a/apps/www/src/pages/blog/index.astro b/apps/www/src/pages/blog/index.astro index b9e8c1ca..8b3390f1 100644 --- a/apps/www/src/pages/blog/index.astro +++ b/apps/www/src/pages/blog/index.astro @@ -34,7 +34,7 @@ const posts = ((await getCollection("blog")) as BlogEntry[]).toSorted( class="focus-ring mt-1 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-card text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground" href="/blog/rss.xml" aria-label="Subscribe to the Cliparr blog RSS feed" - title="RSS feed" + data-tooltip="RSS feed" >