diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 30b61fcf6..07765cf11 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,19 +6,19 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | -| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | -| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | -| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | +| Shortcut | Description | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | +| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed | +| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu | ## Widget advanced view -Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press -`Shift` + `Enter`, or use its menu, to keep the advanced view open. +Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl` +on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu. ## Board editing diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index d7485127f..bcf9b92fa 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with: Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions. Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board -stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to +stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 5c1407572..92d709e3c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -210,7 +210,8 @@ Containers as destinations before the widget is created, so the common flow does Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the -selection. A move is applied only when every selected item fits in the destination. +selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every +selected item fits in the destination. The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test, diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the @@ -329,8 +330,9 @@ This will disable the status check for apps. Widgets expose their routine controls without requiring edit mode: - Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets - remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view - open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead. + remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to + keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place + instead. - When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do not use this widget menu, and available controls follow your board and integration permissions. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index efd9be8fb..8d41652ee 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,11 +1,13 @@ "use client"; import { Box, Paper, Stack, Text } from "@mantine/core"; +import { IconBulb } from "@tabler/icons-react"; import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { useScopedI18n } from "@homarr/translation/client"; +import { FloatingTip } from "@homarr/ui"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardEmptyState } from "~/components/board/board-empty-state"; @@ -33,6 +35,7 @@ export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); const tPreview = useScopedI18n("board.setting.section.layout.preview"); + const tTips = useScopedI18n("tips"); const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); @@ -129,6 +132,16 @@ export const ClientBoard = () => { + , variant: "light" }} + > + {tTips("multiSelectApps")} + diff --git a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx index d3fad372a..766b166b5 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx @@ -139,6 +139,49 @@ describe("BoardAdvancedFocusProvider preview interaction", () => { expect(focus.active).toBeNull(); }); + test("promotes a Shift preview to a persistent view with Shift+Control", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + expect(focus.active?.activation).toBe("preview"); + + await act(async () => + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Control", ctrlKey: true, shiftKey: true, cancelable: true }), + ), + ); + expect(focus.active?.activation).toBe("manual"); + + await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" }))); + expect(focus.active?.activation).toBe("manual"); + }); + + test("promotes a Shift preview to a persistent view with Shift+Meta", async () => { + await startPreview(); + await act(async () => vi.advanceTimersByTime(500)); + + await act(async () => + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Meta", metaKey: true, shiftKey: true, cancelable: true }), + ), + ); + expect(focus.active?.activation).toBe("manual"); + }); + + test("opens a persistent view when the keep-open modifiers are held before the delay", async () => { + const source = host.querySelector("[data-testid='source']"); + if (!source) throw new Error("Expected source fixture"); + await act(async () => { + focus.hover("widget-1", source); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true })); + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Shift", ctrlKey: true, shiftKey: true, cancelable: true }), + ); + }); + + await act(async () => vi.advanceTimersByTime(500)); + expect(focus.active?.activation).toBe("manual"); + }); + test("cancels the hold feedback and activation when the source is left early", async () => { await startPreview(); diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index bc2028961..cc218e4c4 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -55,6 +55,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { const activeRef = useRef(null); const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null); const shiftHeldRef = useRef(false); + const keepOpenHeldRef = useRef(false); const hoverTimerRef = useRef | null>(null); const previewLeaveTimerRef = useRef | null>(null); const closeTimerRef = useRef | null>(null); @@ -151,10 +152,31 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = null; setIsHoldPending(false); - open(hovered.itemId, hovered.source, { activation: "preview" }); + const activation = keepOpenHeldRef.current ? "manual" : "preview"; + open(hovered.itemId, hovered.source, { + activation, + autofocusClose: activation === "manual", + restoreFocusTarget: hovered.source, + }); }, HOVER_DELAY_MS); }, [cancelHoverTimer, open]); + const promotePreviewToManual = useCallback(() => { + const current = activeRef.current; + if (!current || current.activation !== "preview") return false; + cancelHoverTimer(); + cancelPreviewLeaveTimer(); + cancelCloseTimer(); + updateActive({ + ...current, + activation: "manual", + autofocusClose: true, + restorePreviewFocus: false, + phase: "visible", + }); + return true; + }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive]); + const hover = useCallback( (itemId: string, source: HTMLElement) => { hoveredRef.current = { itemId, source }; @@ -268,19 +290,37 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { return; } if (isEditMode) return; - if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return; + if (event.repeat || isEditableTarget(event.target)) return; + + const isKeepOpenModifier = event.key === "Control" || event.key === "Meta"; + const hasKeepOpenShortcut = event.shiftKey && (event.ctrlKey || event.metaKey); + if (isKeepOpenModifier && hasKeepOpenShortcut) { + event.preventDefault(); + keepOpenHeldRef.current = true; + if (!promotePreviewToManual()) startHoverTimer(); + return; + } + + if (event.key !== "Shift") return; if (activeRef.current?.activation === "manual") return; shiftHeldRef.current = true; + keepOpenHeldRef.current = hasKeepOpenShortcut; startHoverTimer(); }; const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === "Control" || event.key === "Meta") { + keepOpenHeldRef.current = false; + return; + } if (event.key !== "Shift") return; shiftHeldRef.current = false; + keepOpenHeldRef.current = false; cancelHoverTimer(); if (activeRef.current?.activation === "preview") close(false); }; const handleBlur = () => { shiftHeldRef.current = false; + keepOpenHeldRef.current = false; cancelHoverTimer(); if (activeRef.current?.activation === "preview") close(false); }; @@ -296,7 +336,15 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { cancelPreviewLeaveTimer(); cancelCloseTimer(); }; - }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]); + }, [ + cancelCloseTimer, + cancelHoverTimer, + cancelPreviewLeaveTimer, + close, + isEditMode, + promotePreviewToManual, + startHoverTimer, + ]); useEffect(() => { if (isEditMode && activeRef.current) close(false); diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index 56ad965e0..9b0f08d23 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -18,10 +18,11 @@ export interface CreateItemInput { integrationIds?: string[]; targetSectionId?: string; advancedOptions?: BoardItemAdvancedOptions; + size?: { width: number; height: number }; } export const createItemCallback = - ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) => + ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions, size }: CreateItemInput) => (previous: Board): Board => { const firstCanvasSection = previous.sections .filter( @@ -40,10 +41,10 @@ export const createItemCallback = if (!targetSection) return previous; - let itemLayouts = createItemLayouts(previous, targetSection, kind); + let itemLayouts = createItemLayouts(previous, targetSection, kind, size); if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) { targetSection = firstCanvasSection; - itemLayouts = createItemLayouts(previous, targetSection, kind); + itemLayouts = createItemLayouts(previous, targetSection, kind, size); } if (itemLayouts.length === 0) return previous; const widget = { @@ -74,9 +75,10 @@ const createItemLayouts = ( board: Board, currentSection: EmptySection | ContainerSection, kind: WidgetKind, + requestedSize?: { width: number; height: number }, ): ItemLayout[] => { const layouts = getBoardLayouts(board); - const itemSize = getWidgetItemSize(kind); + const itemSize = requestedSize ?? getWidgetItemSize(kind); return layouts.flatMap((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 368f86207..abb982fa1 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -382,7 +382,7 @@ const LoadedBoardItemContent = ({ className={itemContentClasses.advancedFocusTrigger} aria-expanded={isAdvanced} aria-controls={isAdvanced ? advancedViewId : undefined} - aria-keyshortcuts="Shift+Enter" + aria-keyshortcuts="Shift+Control Shift+Meta" data-advanced-focus-trigger onClick={openAdvancedView} > diff --git a/apps/nextjs/src/components/board/selection/board-item-clipboard.ts b/apps/nextjs/src/components/board/selection/board-item-clipboard.ts new file mode 100644 index 000000000..e88c083c4 --- /dev/null +++ b/apps/nextjs/src/components/board/selection/board-item-clipboard.ts @@ -0,0 +1,63 @@ +import { z } from "zod/v4"; + +import { widgetKinds } from "@homarr/definitions"; +import { itemAdvancedOptionsSchema } from "@homarr/validation/shared"; + +import type { Item } from "~/app/[locale]/boards/_types"; + +const clipboardPrefix = "homarr-board-items:v1\n"; + +const clipboardPayloadSchema = z.object({ + version: z.literal(1), + items: z + .array( + z.object({ + kind: z.enum(widgetKinds), + options: z.record(z.string(), z.unknown()), + integrationIds: z.array(z.string()), + advancedOptions: itemAdvancedOptionsSchema, + size: z.object({ + width: z.number().int().min(1).max(64), + height: z.number().int().min(1).max(64), + }), + }), + ) + .min(1) + .max(100), +}); + +export type BoardItemClipboardPayload = z.infer; + +export const serializeBoardItemsForClipboard = (items: readonly Item[], layoutId: string): string => { + const clipboardItems = items.flatMap((item) => { + const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); + if (!layout) return []; + + return [ + { + kind: item.kind, + options: item.options, + integrationIds: item.integrationIds, + advancedOptions: item.advancedOptions, + size: { width: layout.width, height: layout.height }, + }, + ]; + }); + + if (clipboardItems.length === 0) throw new Error("No selected items are available in the current layout"); + + return `${clipboardPrefix}${JSON.stringify({ version: 1, items: clipboardItems })}`; +}; + +export const isBoardItemClipboardText = (value: string): boolean => value.startsWith(clipboardPrefix); + +export const parseBoardItemClipboard = (value: string): BoardItemClipboardPayload | null => { + if (!isBoardItemClipboardText(value)) return null; + + try { + const result = clipboardPayloadSchema.safeParse(JSON.parse(value.slice(clipboardPrefix.length))); + return result.success ? result.data : null; + } catch { + return null; + } +}; diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx index c786faff1..8097aae9f 100644 --- a/apps/nextjs/src/components/board/selection/board-selection-context.tsx +++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx @@ -8,19 +8,28 @@ import { notifications } from "@mantine/notifications"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { usePersistBoard } from "@homarr/boards/updater"; +import { createId } from "@homarr/common"; import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types"; +import { createItemCallback } from "~/components/board/items/actions/create-item"; import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position"; import { playPopSound, playTrashSound } from "../audio/board-sounds"; +import { + isBoardItemClipboardText, + parseBoardItemClipboard, + serializeBoardItemsForClipboard, +} from "./board-item-clipboard"; interface BoardSelectionContextValue { selectedItemIds: Set; toggleSelectItem: (id: string) => void; clearSelection: () => void; isSelected: (id: string) => boolean; + copySelectedItems: () => Promise; + pasteItems: () => Promise; removeSelectedItems: () => void; moveSelectedItemsToSection: (sectionId: string) => void; } @@ -37,7 +46,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => { const tSelection = useScopedI18n("item.selection"); const persistBoard = useCallback( - (updater: (previous: typeof board) => typeof board) => { + (updater: (previous: typeof board) => typeof board) => updateAndPersistBoard(updater, { onError: () => { showErrorNotification({ @@ -45,8 +54,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => { message: tSelection("saveErrorMessage"), }); }, - }); - }, + }), [tSelection, updateAndPersistBoard], ); @@ -90,6 +98,95 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => { const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]); + const copySelectedItems = useCallback(async () => { + const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id)); + if (selectedItems.length === 0) return; + + try { + const value = serializeBoardItemsForClipboard(selectedItems, currentLayoutId); + await navigator.clipboard.writeText(value); + showSuccessNotification({ + title: t("common.success"), + message: tSelection("copied", { count: selectedItems.length }), + }); + } catch { + showErrorNotification({ + title: t("common.error"), + message: tSelection("copyError"), + }); + } + }, [board.items, currentLayoutId, selectedItemIds, t, tSelection]); + + const pasteItemsFromText = useCallback( + (value: string) => { + const payload = parseBoardItemClipboard(value); + if (!payload) { + showErrorNotification({ title: t("common.error"), message: tSelection("pasteInvalid") }); + return; + } + + const pastedIds = payload.items.map(() => createId()); + let didPasteAll = false; + persistBoard((previous) => { + let next = previous; + try { + payload.items.forEach((item, index) => { + const id = pastedIds[index]; + if (!id) throw new Error("Missing pasted item ID"); + next = createItemCallback({ ...item, id })(next); + }); + } catch { + return previous; + } + + didPasteAll = next.items.length === previous.items.length + payload.items.length; + return didPasteAll ? next : previous; + }); + + if (!didPasteAll) { + showErrorNotification({ title: t("common.error"), message: tSelection("pasteUnavailable") }); + return; + } + + setSelectedItemIds(new Set(pastedIds)); + playPopSound(); + showSuccessNotification({ + title: t("common.success"), + message: tSelection("pasted", { count: pastedIds.length }), + }); + }, + [persistBoard, t, tSelection], + ); + + const pasteItems = useCallback(async () => { + try { + pasteItemsFromText(await navigator.clipboard.readText()); + } catch { + showErrorNotification({ title: t("common.error"), message: tSelection("pasteError") }); + } + }, [pasteItemsFromText, t, tSelection]); + + useEffect(() => { + if (!isEditMode) return; + + const handlePaste = (event: ClipboardEvent) => { + const target = event.target; + if ( + target instanceof Element && + target.closest("input, textarea, select, [contenteditable='true'], [role='textbox']") + ) + return; + + const value = event.clipboardData?.getData("text/plain") ?? ""; + if (!isBoardItemClipboardText(value)) return; + event.preventDefault(); + pasteItemsFromText(value); + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [isEditMode, pasteItemsFromText]); + const removeSelectedItems = useCallback(() => { if (selectedItemIds.size === 0) return; @@ -252,10 +349,21 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => { toggleSelectItem, clearSelection, isSelected, + copySelectedItems, + pasteItems, removeSelectedItems, moveSelectedItemsToSection, }), - [clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem], + [ + clearSelection, + copySelectedItems, + isSelected, + moveSelectedItemsToSection, + pasteItems, + removeSelectedItems, + selectedItemIds, + toggleSelectItem, + ], ); return {children}; diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx index 0f9bbc3c4..c1d2df844 100644 --- a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx +++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx @@ -15,7 +15,7 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react"; +import { IconCheck, IconClipboard, IconCopy, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; @@ -31,7 +31,14 @@ const MAX_AVATARS_DISPLAYED = 5; export const BoardSelectionToolbar = () => { const [isEditMode] = useEditMode(); - const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection(); + const { + selectedItemIds, + clearSelection, + copySelectedItems, + pasteItems, + removeSelectedItems, + moveSelectedItemsToSection, + } = useBoardSelection(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); @@ -201,14 +208,32 @@ export const BoardSelectionToolbar = () => { - - {t("app.action.select.multiSelectTip")} - - {/* Actions */} + + + + + + {placementOptions.length > 1 && ( diff --git a/packages/modals-collection/src/apps/app-select-modal.spec.tsx b/packages/modals-collection/src/apps/app-select-modal.spec.tsx deleted file mode 100644 index 797910440..000000000 --- a/packages/modals-collection/src/apps/app-select-modal.spec.tsx +++ /dev/null @@ -1,438 +0,0 @@ -// @vitest-environment jsdom - -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { MantineProvider } from "@mantine/core"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { AppSelectModal } from "./app-select-modal"; - -const AppSelectModalComponent = AppSelectModal.component; - -const sampleApps = [ - { - id: "app-1", - name: "Sonarr", - description: "Manage TV series downloads", - iconUrl: "https://example.com/sonarr.png", - href: "http://sonarr.local", - }, - { - id: "app-2", - name: "Radarr", - description: "Manage movie collections", - iconUrl: "https://example.com/radarr.png", - href: "http://radarr.local", - }, - { - id: "app-3", - name: "Plex", - description: "Stream your media collection", - iconUrl: "https://example.com/plex.png", - href: "http://plex.local", - }, -]; - -const mockUseQuery = vi.fn(() => ({ - data: sampleApps, - isPending: false, -})); - -const mockOpenQuickAddAppModal = vi.fn(); - -vi.mock("@homarr/api/client", () => ({ - clientApi: { - app: { - selectable: { - useQuery: () => mockUseQuery(), - }, - }, - }, -})); - -vi.mock("@homarr/modals", async () => { - const actual = await vi.importActual>("@homarr/modals"); - return { - ...actual, - useModalAction: () => ({ - openModal: mockOpenQuickAddAppModal, - }), - }; -}); - -vi.mock("@homarr/translation/client", () => ({ - useI18n: () => (key: string, values?: { count?: number }) => { - const count = values?.count ?? 0; - const translations: Record = { - "app.action.select.search": "Search apps", - "app.action.select.title": "Select application", - "app.action.select.selected": "Selected", - "app.action.select.toggle": "Click to select", - "app.action.select.noResults": "No apps found", - "app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps", - "app.action.select.selectedCount": `${count} selected`, - "app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`, - "app.action.select.customApplication": "Custom application", - "app.action.select.application": "Application", - "app.action.create.title": "Add custom app", - "app.action.create.description": "Configure custom URL and icon", - "common.action.discard": "Discard", - "common.action.add": "Add", - }; - return translations[key] ?? key; - }, -})); - -let root: Root; -let host: HTMLDivElement; - -beforeEach(() => { - (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - window.matchMedia = vi.fn().mockImplementation(() => ({ - matches: false, - media: "", - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - unobserve() {} - disconnect() {} - }, - ); - host = document.createElement("div"); - document.body.append(host); - root = createRoot(host); - vi.clearAllMocks(); - mockUseQuery.mockReturnValue({ - data: sampleApps, - isPending: false, - }); -}); - -afterEach(async () => { - await act(async () => root.unmount()); - host.remove(); -}); - -describe("AppSelectModal", () => { - describe("single selection mode", () => { - it("renders app cards with name, description, and icon", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - expect(host.textContent).toContain("Sonarr"); - expect(host.textContent).toContain("Radarr"); - expect(host.textContent).toContain("Plex"); - expect(host.textContent).toContain("Manage TV series downloads"); - }); - - it("selects an app and closes modal when card is clicked", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); - expect(sonarrCard).toBeDefined(); - - await act(async () => { - sonarrCard?.click(); - }); - - expect(onSelect).toHaveBeenCalledWith(sampleApps[0]); - expect(closeModal).toHaveBeenCalledOnce(); - }); - }); - - describe("multi selection mode", () => { - it("toggles multiple app selections and submits batch on button click", async () => { - const closeModal = vi.fn(); - const onSelectMany = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); - const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr")); - - // Select Sonarr - await act(async () => { - sonarrCard?.click(); - }); - - // Modal stays open, 1 app selected - expect(closeModal).not.toHaveBeenCalled(); - expect(host.textContent).toContain("1 app selected"); - - // Select Radarr - await act(async () => { - radarrCard?.click(); - }); - - expect(host.textContent).toContain("2 apps selected"); - - // Submit multi select - const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)")); - expect(submitBtn).toBeDefined(); - - await act(async () => { - submitBtn?.click(); - }); - - expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]); - expect(closeModal).toHaveBeenCalledOnce(); - }); - - it("unselects an app when clicked a second time", async () => { - const closeModal = vi.fn(); - const onSelectMany = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); - - // Select Sonarr - await act(async () => { - sonarrCard?.click(); - }); - expect(host.textContent).toContain("1 app selected"); - - // Unselect Sonarr - await act(async () => { - sonarrCard?.click(); - }); - expect(host.textContent).not.toContain("1 app selected"); - }); - - it("clears selection when Discard button is clicked", async () => { - const closeModal = vi.fn(); - const onSelectMany = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr")); - await act(async () => { - sonarrCard?.click(); - }); - expect(host.textContent).toContain("1 app selected"); - - const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Discard"), - ); - await act(async () => { - discardBtn?.click(); - }); - - expect(host.textContent).not.toContain("app selected"); - }); - }); - - describe("search and filtering", () => { - it("filters apps in real-time by search query (case-insensitive)", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const searchInput = host.querySelector("input"); - expect(searchInput).not.toBeNull(); - - // Type "plex" - await act(async () => { - if (searchInput) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call(searchInput, "plex"); - searchInput.dispatchEvent(new Event("input", { bubbles: true })); - searchInput.dispatchEvent(new Event("change", { bubbles: true })); - } - }); - - expect(host.textContent).toContain("Plex"); - expect(host.textContent).not.toContain("Sonarr"); - expect(host.textContent).not.toContain("Radarr"); - }); - - it("selects the single matching app when Enter key is pressed in search input", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const searchInput = host.querySelector("input"); - await act(async () => { - if (searchInput) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call(searchInput, "sonarr"); - searchInput.dispatchEvent(new Event("input", { bubbles: true })); - searchInput.dispatchEvent(new Event("change", { bubbles: true })); - } - }); - - await act(async () => { - searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - }); - - expect(onSelect).toHaveBeenCalledWith(sampleApps[0]); - expect(closeModal).toHaveBeenCalledOnce(); - }); - - it("displays empty state message when search yields no results", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const searchInput = host.querySelector("input"); - await act(async () => { - if (searchInput) { - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call(searchInput, "non-matching-query"); - searchInput.dispatchEvent(new Event("input", { bubbles: true })); - searchInput.dispatchEvent(new Event("change", { bubbles: true })); - } - }); - - expect(host.textContent).toContain("No apps found"); - }); - }); - - describe("custom app creation card", () => { - it("renders custom app creation card when withCreate is true", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - expect(host.textContent).toContain("Add custom app"); - expect(host.textContent).toContain("Configure custom URL and icon"); - }); - - it("omits custom app creation card when withCreate is false", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - expect(host.textContent).not.toContain("Add custom app"); - }); - - it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => { - const closeModal = vi.fn(); - const onSelect = vi.fn(); - - await act(async () => - root.render( - - - , - ), - ); - - const createCard = Array.from(host.querySelectorAll("button")).find((btn) => - btn.textContent?.includes("Add custom app"), - ); - expect(createCard).toBeDefined(); - - await act(async () => { - createCard?.click(); - }); - - expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce(); - - // Trigger the onClose callback passed to QuickAddAppModal - const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void }; - const newCustomApp = { - id: "custom-app-1", - name: "Custom Home Assistant", - description: "Local instance", - iconUrl: "https://example.com/ha.png", - href: "http://ha.local", - }; - - await act(async () => { - options.onClose(newCustomApp); - }); - - expect(onSelect).toHaveBeenCalledWith(newCustomApp); - expect(closeModal).toHaveBeenCalledOnce(); - }); - }); -}); diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx index ab2b5a63f..3ae968dba 100644 --- a/packages/modals-collection/src/apps/app-select-modal.tsx +++ b/packages/modals-collection/src/apps/app-select-modal.tsx @@ -13,13 +13,13 @@ import { Text, ThemeIcon, } from "@mantine/core"; -import { IconPlus, IconSearch } from "@tabler/icons-react"; +import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { selectGridCols, SelectableCard } from "@homarr/ui"; +import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui"; import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal"; @@ -88,7 +88,18 @@ export const AppSelectModal = createModal(({ actions, inner return ( - {/* Top Search Input & Multi-Select Tip */} + , variant: "light" }} + > + {t("tips.multiSelectApps")} + + + {/* Top Search Input */} (({ actions, inner } }} /> - {multiSelect && ( - - - {t("app.action.select.multiSelectTip")} - - {selectedAppIds.size > 0 && ( - - {t("app.action.select.selectedCount", { count: selectedAppIds.size })} - - )} - - )} {/* Scrollable Container with App Cards */} @@ -207,13 +206,6 @@ const AppCard = ({ selected={isSelected} icon={{app.name}} title={app.name} - topRight={ - isSelected ? ( - - {t("app.action.select.selected")} - - ) : null - } description={app.description} footerLeft={ diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ce842242d..7be12cbcb 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -822,7 +822,6 @@ "toggle": "Click to select", "selected": "Selected", "multiple": "Add {count}", - "multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once", "selectedCount": "{count, plural, one {# selected} other {# selected}}", "appsSelected": "{count, plural, one {# app selected} other {# apps selected}}", "customApplication": "Custom application", @@ -836,6 +835,10 @@ "add": "Add an app" } }, + "tips": { + "dismiss": "Dismiss tip", + "multiSelectApps": "Tip: Hold Ctrl / ⌘ to select multiple apps at once" + }, "customWidget": { "betaBanner": { "title": "Custom Widgets are in Beta", @@ -2786,6 +2789,15 @@ "selection": { "summary": "{count, plural, one {# selected item} other {# selected items}}", "selected": "{count, plural, one {# selected} other {# selected}}", + "copy": "Copy", + "copied": "{count, plural, one {# item copied to the clipboard} other {# items copied to the clipboard}}", + "copyError": "The selected items could not be copied. Your browser may not allow clipboard access.", + "paste": "Paste", + "pasteShortcut": "Paste copied items here (Ctrl / ⌘ + V)", + "pasted": "{count, plural, one {# item pasted} other {# items pasted}}", + "pasteError": "The clipboard could not be read. Your browser may not allow clipboard access.", + "pasteInvalid": "The clipboard does not contain valid Homarr board items.", + "pasteUnavailable": "The copied items do not fit on this board.", "removed": "{count, plural, one {# item removed} other {# items removed}}", "move": "Move", "moveTo": "Move selected items", diff --git a/packages/ui/src/components/floating-tip/floating-tip.module.css b/packages/ui/src/components/floating-tip/floating-tip.module.css new file mode 100644 index 000000000..7be4e304c --- /dev/null +++ b/packages/ui/src/components/floating-tip/floating-tip.module.css @@ -0,0 +1,17 @@ +.root { + position: fixed; + inset-inline: var(--mantine-spacing-md); + bottom: var(--floating-tip-bottom); + z-index: var(--floating-tip-z-index); + display: flex; + justify-content: center; + pointer-events: none; +} + +.alert { + width: fit-content; + max-width: min(var(--floating-tip-max-width), calc(100vw - 2 * var(--mantine-spacing-md))); + pointer-events: auto; + box-shadow: var(--mantine-shadow-xl); + backdrop-filter: blur(12px); +} diff --git a/packages/ui/src/components/floating-tip/floating-tip.module.d.css.ts b/packages/ui/src/components/floating-tip/floating-tip.module.d.css.ts new file mode 100644 index 000000000..728589900 --- /dev/null +++ b/packages/ui/src/components/floating-tip/floating-tip.module.d.css.ts @@ -0,0 +1,5 @@ +declare const classes: { + readonly root: string; + readonly alert: string; +}; +export default classes; diff --git a/packages/ui/src/components/floating-tip/floating-tip.spec.tsx b/packages/ui/src/components/floating-tip/floating-tip.spec.tsx new file mode 100644 index 000000000..f45e5ca3a --- /dev/null +++ b/packages/ui/src/components/floating-tip/floating-tip.spec.tsx @@ -0,0 +1,96 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { FloatingTip } from "./floating-tip"; + +let root: Root; +let host: HTMLDivElement; + +beforeEach(() => { + vi.useFakeTimers(); + window.localStorage.clear(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + document.body.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.useRealTimers(); +}); + +describe("FloatingTip", () => { + it("shows after the configured delay and auto-dismisses", async () => { + await act(async () => + root.render( + + + Select several apps + + , + ), + ); + + expect(document.body.textContent).not.toContain("Select several apps"); + + await act(async () => vi.advanceTimersByTime(2_000)); + expect(document.body.textContent).toContain("Select several apps"); + + await act(async () => vi.advanceTimersByTime(3_000)); + expect(document.body.textContent).not.toContain("Select several apps"); + }); + + it("provides an accessible close button and remembers dismissal", async () => { + const onDismiss = vi.fn(); + const renderTip = (opened: boolean) => + root.render( + + + Select several apps + + , + ); + + await act(async () => renderTip(true)); + await act(async () => vi.advanceTimersByTime(0)); + + const closeButton = document.body.querySelector('button[aria-label="Dismiss tip"]'); + expect(closeButton).not.toBeNull(); + await act(async () => closeButton?.click()); + + expect(onDismiss).toHaveBeenCalledOnce(); + expect(window.localStorage.getItem("homarr:floating-tip:selection")).toBe("dismissed"); + + await act(async () => renderTip(false)); + await act(async () => renderTip(true)); + await act(async () => vi.advanceTimersByTime(0)); + expect(document.body.textContent).not.toContain("Select several apps"); + }); +}); diff --git a/packages/ui/src/components/floating-tip/floating-tip.tsx b/packages/ui/src/components/floating-tip/floating-tip.tsx new file mode 100644 index 000000000..f18637928 --- /dev/null +++ b/packages/ui/src/components/floating-tip/floating-tip.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Box, createVarsResolver, factory, Portal, rem, Transition, useProps, useStyles } from "@mantine/core"; +import type { AlertProps, BoxProps, ElementProps, Factory, MantineTransition, StylesApiProps } from "@mantine/core"; + +import classes from "./floating-tip.module.css"; + +export type FloatingTipStylesNames = "root" | "alert"; +export type FloatingTipCssVariables = { + root: "--floating-tip-bottom" | "--floating-tip-max-width" | "--floating-tip-z-index"; +}; + +interface FloatingTipBaseProps + extends BoxProps, StylesApiProps, Omit, "title"> { + /** Controls whether this tip cycle should run. */ + opened: boolean; + /** Delay before the tip appears, in milliseconds. */ + showDelay?: number; + /** Time the tip remains visible, in milliseconds. Set to `null` to keep it open. */ + dismissAfter?: number | null; + /** Enter and exit animation duration, in milliseconds. */ + transitionDuration?: number; + /** Mantine transition used to show and hide the tip. */ + transition?: MantineTransition; + /** Distance from the bottom edge of the viewport. */ + bottomOffset?: number | string; + /** Maximum width of the alert. */ + maxWidth?: number | string; + /** Stack order of the floating tip. */ + zIndex?: number; + /** Props forwarded to the underlying Mantine Alert. */ + alertProps?: Omit< + AlertProps, + "children" | "classNames" | "closeButtonLabel" | "onClose" | "styles" | "withCloseButton" + >; + /** Called when the user closes the tip. */ + onDismiss?: () => void; +} + +type FloatingTipCloseProps = + | { closable: true; closeButtonLabel: string } + | { closable?: false; closeButtonLabel?: never }; + +type FloatingTipPersistenceProps = + | { rememberDismissal: true; storageKey: string } + | { rememberDismissal?: false; storageKey?: never }; + +export type FloatingTipProps = FloatingTipBaseProps & FloatingTipCloseProps & FloatingTipPersistenceProps; + +export type FloatingTipFactory = Factory<{ + props: FloatingTipProps; + ref: HTMLDivElement; + stylesNames: FloatingTipStylesNames; + vars: FloatingTipCssVariables; +}>; + +const defaultProps = { + showDelay: 0, + dismissAfter: null, + transitionDuration: 200, + transition: "slide-up", + bottomOffset: 24, + maxWidth: 520, + zIndex: 1000, + closable: false, + rememberDismissal: false, +} satisfies Partial; + +const toCssSize = (value: number | string | undefined) => (typeof value === "number" ? rem(value) : value); + +const varsResolver = createVarsResolver((_theme, { bottomOffset, maxWidth, zIndex }) => ({ + root: { + "--floating-tip-bottom": toCssSize(bottomOffset), + "--floating-tip-max-width": toCssSize(maxWidth), + "--floating-tip-z-index": zIndex?.toString(), + }, +})); + +const getDismissalStorageKey = (storageKey: string) => `homarr:floating-tip:${storageKey}`; + +export const FloatingTip = factory((_props) => { + const props = useProps("FloatingTip", defaultProps, _props); + const { + ref, + classNames, + className, + style, + styles, + unstyled, + vars, + attributes, + opened, + showDelay, + dismissAfter, + transitionDuration, + transition, + bottomOffset: _bottomOffset, + maxWidth: _maxWidth, + zIndex: _zIndex, + closable, + closeButtonLabel, + rememberDismissal, + storageKey, + alertProps, + onDismiss, + children, + ...others + } = props; + const [visible, setVisible] = useState(false); + const showTimerRef = useRef(null); + const dismissTimerRef = useRef(null); + + const clearTimers = useCallback(() => { + if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current); + if (dismissTimerRef.current !== null) window.clearTimeout(dismissTimerRef.current); + showTimerRef.current = null; + dismissTimerRef.current = null; + }, []); + + useEffect(() => { + clearTimers(); + setVisible(false); + + if (!opened) return; + + if (rememberDismissal) { + try { + if (window.localStorage.getItem(getDismissalStorageKey(storageKey)) === "dismissed") return; + } catch { + // Storage can be unavailable in private browsing or hardened environments. + } + } + + showTimerRef.current = window.setTimeout(() => { + setVisible(true); + showTimerRef.current = null; + + if (dismissAfter !== null) { + dismissTimerRef.current = window.setTimeout(() => { + setVisible(false); + dismissTimerRef.current = null; + }, dismissAfter); + } + }, showDelay); + + return clearTimers; + }, [clearTimers, dismissAfter, opened, rememberDismissal, showDelay, storageKey]); + + const handleDismiss = () => { + clearTimers(); + setVisible(false); + + if (rememberDismissal) { + try { + window.localStorage.setItem(getDismissalStorageKey(storageKey), "dismissed"); + } catch { + // The tip still closes for this render even when persistence is unavailable. + } + } + + onDismiss?.(); + }; + + const getStyles = useStyles({ + name: "FloatingTip", + classes, + props, + className, + style, + classNames, + styles, + unstyled, + vars, + attributes, + varsResolver, + }); + + return ( + + + {(transitionStyle) => { + const rootStyles = getStyles("root"); + + return ( + + + {children} + + + ); + }} + + + ); +}); + +FloatingTip.displayName = "@homarr/ui/FloatingTip"; +FloatingTip.classes = classes; diff --git a/packages/ui/src/components/floating-tip/index.ts b/packages/ui/src/components/floating-tip/index.ts new file mode 100644 index 000000000..dc462cc0f --- /dev/null +++ b/packages/ui/src/components/floating-tip/index.ts @@ -0,0 +1,7 @@ +export { FloatingTip } from "./floating-tip"; +export type { + FloatingTipCssVariables, + FloatingTipFactory, + FloatingTipProps, + FloatingTipStylesNames, +} from "./floating-tip"; diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index e2f8d28dc..941412a72 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -20,6 +20,7 @@ export type { SelectableCardCssVariables, SelectableCardFactory, } from "./selectable-card"; +export * from "./floating-tip"; export { MaskedImage } from "./masked-image"; export { MaskedOrNormalImage } from "./masked-or-normal-image"; export { LanguageIcon } from "./language-icon";