diff --git a/cypress/e2e/frontend/test_1_runQuery.cy.js b/cypress/e2e/frontend/test_1_runQuery.cy.js index e33a0c9fbf..1ee601aac7 100644 --- a/cypress/e2e/frontend/test_1_runQuery.cy.js +++ b/cypress/e2e/frontend/test_1_runQuery.cy.js @@ -53,7 +53,8 @@ describe("Run query", () => { cy.get('[data-test-id="project-items-list"]').as("executionList"); cy.get('@executionList').find('[data-test-id="project-item-delete-button"]').click(); - cy.get('@executionList').contains('Anfrage jetzt löschen').click(); + // the confirmation menu renders in a portal, outside the list + cy.contains('Anfrage jetzt löschen').click(); cy.get('@leftPaneContainer').contains('Keine Anfragen / Formulare gefunden') }); @@ -158,7 +159,7 @@ describe("Reference list", () => { // Clear editor cy.get('@queryEditor').find('svg[data-icon="trash"]').click() - cy.get('@queryEditor').find('button[data-test-id="confirm"]').click() + cy.get('[data-test-id="confirm"]').click() cy.get('@queryEditor').find('[data-test-id="text-initial"]') }) diff --git a/frontend/package.json b/frontend/package.json index d09382b43f..d1bdc1da14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,7 +30,6 @@ "@paralleldrive/cuid2": "^3.3.0", "@react-keycloak-fork/web": "^4.0.3", "@redux-devtools/extension": "^4.0.0", - "@tippyjs/react": "^4.2.6", "apache-arrow": "^21.2.0", "axios": "^1.19.0", "chance": "^1.1.13", @@ -72,7 +71,6 @@ "remark-gfm": "^4.0.1", "resize-observer-polyfill": "^1.5.1", "tailwind-variants": "^3.3.1", - "tippy.js": "^6.3.7", "typesafe-actions": "^5.1.0" }, "devDependencies": { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c438704bab..b305d9b577 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@redux-devtools/extension': specifier: ^4.0.0 version: 4.0.0(redux@5.0.1) - '@tippyjs/react': - specifier: ^4.2.6 - version: 4.2.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) apache-arrow: specifier: ^21.2.0 version: 21.2.0 @@ -158,9 +155,6 @@ importers: tailwind-variants: specifier: ^3.3.1 version: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3) - tippy.js: - specifier: ^6.3.7 - version: 6.3.7 typesafe-actions: specifier: ^5.1.0 version: 5.1.0 @@ -887,9 +881,6 @@ packages: '@paralleldrive/cuid2@3.3.0': resolution: {integrity: sha512-OqiFvSOF0dBSesELYY2CAMa4YINvlLpvKOz/rv6NeZEqiyttlHgv98Juwv4Ch+GrEV7IZ8jfI2VcEoYUjXXCjw==} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rc-component/context@1.4.0': resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} peerDependencies: @@ -1360,12 +1351,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tippyjs/react@4.2.6': - resolution: {integrity: sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw==} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -3229,9 +3214,6 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tippy.js@6.3.7: - resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3961,8 +3943,6 @@ snapshots: bignumber.js: 9.3.1 error-causes: 3.0.2 - '@popperjs/core@2.11.8': {} - '@rc-component/context@1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 @@ -4301,12 +4281,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tippyjs/react@4.2.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - tippy.js: 6.3.7 - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -6442,10 +6416,6 @@ snapshots: tinyspy@4.0.4: {} - tippy.js@6.3.7: - dependencies: - '@popperjs/core': 2.11.8 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 diff --git a/frontend/src/index.css b/frontend/src/index.css index 16ab40e7bd..b6457dc326 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -124,26 +124,6 @@ } } -/* @tippyjs/react sets inline styles, hence the specificity */ -div[data-tippy-root] { - max-width: 700px; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.18); - border-radius: var(--radius); - - > div { - box-shadow: none; - /* biome-ignore lint/complexity/noImportantStyles: beats the inline style tippy sets */ - max-width: inherit !important; - width: 100%; - padding: 0; - } - - .tippy-content { - padding: 0px; - box-shadow: none; - } -} - /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still diff --git a/frontend/src/js/button/DownloadButton.tsx b/frontend/src/js/button/DownloadButton.tsx index a5e731f654..4147ff571c 100644 --- a/frontend/src/js/button/DownloadButton.tsx +++ b/frontend/src/js/button/DownloadButton.tsx @@ -31,7 +31,7 @@ const fileTypeToFileIcon: Record = { JSON: { icon: faFileCode, color: "var(--color-filetype-json)" }, }; -function getFileIcon(url: string): FileIcon { +export function getFileIcon(url: string): FileIcon { if (url.includes(".")) { const ext = getEnding(url); diff --git a/frontend/src/js/editor-v2/EditorV2.tsx b/frontend/src/js/editor-v2/EditorV2.tsx index db3769c033..3c160611bb 100644 --- a/frontend/src/js/editor-v2/EditorV2.tsx +++ b/frontend/src/js/editor-v2/EditorV2.tsx @@ -22,7 +22,7 @@ import type { DragItemConceptTreeNode, DragItemQuery, } from "../standard-query-editor/types"; -import { ConfirmableTooltip } from "../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../ui-components/ConfirmMenu"; import Dropzone from "../ui-components/Dropzone"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; import { EDITOR_DROP_TYPES, HOTKEYS } from "./config"; @@ -432,7 +432,7 @@ export function EditorV2({ )} - @@ -440,7 +440,7 @@ export function EditorV2({ style={{ marginLeft: "20px", height: "32.5px" }} icon={faTrash} /> - + {t("editorV2.clear")} diff --git a/frontend/src/js/entity-history/Navigation.tsx b/frontend/src/js/entity-history/Navigation.tsx index bd26bd341e..4c02ecdea0 100644 --- a/frontend/src/js/entity-history/Navigation.tsx +++ b/frontend/src/js/entity-history/Navigation.tsx @@ -20,7 +20,7 @@ import { tv } from "tailwind-variants"; import type { SelectOptionT } from "../api/types"; import type { StateT } from "../app/reducers"; import IconButton from "../button/IconButton"; -import { ConfirmableTooltip } from "../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../ui-components/ConfirmMenu"; import { Tooltip, TooltipTrigger, @@ -157,9 +157,8 @@ export const Navigation = memo( {backButtonWarning} {!empty && ( - {t("history.settings.reset")} - + )} {!empty && ( diff --git a/frontend/src/js/external-forms/FormsNavigation.tsx b/frontend/src/js/external-forms/FormsNavigation.tsx index 93cbf8b603..8c6faecb01 100644 --- a/frontend/src/js/external-forms/FormsNavigation.tsx +++ b/frontend/src/js/external-forms/FormsNavigation.tsx @@ -6,7 +6,7 @@ import { tv } from "tailwind-variants"; import type { StateT } from "../app/reducers"; import IconButton from "../button/IconButton"; import { useActiveLang } from "../localization/useActiveLang"; -import { ConfirmableTooltip } from "../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../ui-components/ConfirmMenu"; import InputSelect from "../ui-components/InputSelect/InputSelect"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; @@ -73,7 +73,7 @@ const FormsNavigation = ({ onReset }: { onReset: () => void }) => { }} /> - @@ -82,7 +82,7 @@ const FormsNavigation = ({ onReset }: { onReset: () => void }) => { frame icon={faTrash} /> - + {t("externalForms.common.clear")} diff --git a/frontend/src/js/header/HelpMenu.tsx b/frontend/src/js/header/HelpMenu.tsx index c564662c86..a131d057f3 100644 --- a/frontend/src/js/header/HelpMenu.tsx +++ b/frontend/src/js/header/HelpMenu.tsx @@ -4,89 +4,68 @@ import { faPaperPlane, faQuestion, } from "@fortawesome/free-solid-svg-icons"; -import { useMemo } from "react"; +import { MenuTrigger } from "react-aria-components"; import { useTranslation } from "react-i18next"; -import { tv } from "tailwind-variants"; import { useAbout } from "../app/About"; import IconButton from "../button/IconButton"; -import WithTooltip from "../ui-components/WithTooltip"; - -const list = tv({ base: ["flex flex-col", "gap-[2px]", "p-2"] }); +import FaIcon from "../icon/FaIcon"; +import { Menu, MenuItem, menuItemIcon } from "../ui-components/Menu"; interface Props { contactEmail?: string; manualUrl?: string; } -// Skidding makes Dropdown align the right edge with the button, -// might need to adjust this when adding more content. -const dropdownOffset: [number, number] = [-47, 5]; // [skidding, distance] / default [0, 10] - export const HelpMenu = ({ contactEmail, manualUrl }: Props) => { const { t } = useTranslation(); const { setOpen } = useAbout(); - const Dropdown = useMemo( - () => ( -
- + + { + if (key === "version") setOpen(true); + }} + > + - - {t("common.contact")} - - - + + + {t("common.contact")} + + - - {t("common.manual")} - - - setOpen(true)} - > + + + + {t("common.manual")} + + + + + {t("common.version")} - -
- ), - [t, manualUrl, contactEmail, setOpen], - ); - return ( - - - + + + ); }; diff --git a/frontend/src/js/previous-queries/list/DeleteProjectItemButton.tsx b/frontend/src/js/previous-queries/list/DeleteProjectItemButton.tsx index 0e8cbb9c02..2df56fd682 100644 --- a/frontend/src/js/previous-queries/list/DeleteProjectItemButton.tsx +++ b/frontend/src/js/previous-queries/list/DeleteProjectItemButton.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import IconButton from "../../button/IconButton"; -import { ConfirmableTooltip } from "../../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../../ui-components/ConfirmMenu"; import { Tooltip, TooltipTrigger } from "../../ui-components/Tooltip"; import { useRemoveFormConfig, useRemoveQuery } from "./actions"; import { isFormConfig } from "./helpers"; @@ -32,18 +32,14 @@ export const DeleteProjectItemButton = ({ item }: { item: ProjectItemT }) => { return ( - + - + {t("common.delete")} ); diff --git a/frontend/src/js/query-node-editor/ResetAllSettingsButton.tsx b/frontend/src/js/query-node-editor/ResetAllSettingsButton.tsx index bcfa9fbc95..23cb88a9f5 100644 --- a/frontend/src/js/query-node-editor/ResetAllSettingsButton.tsx +++ b/frontend/src/js/query-node-editor/ResetAllSettingsButton.tsx @@ -2,7 +2,7 @@ import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { useTranslation } from "react-i18next"; import IconButton from "../button/IconButton"; -import { ConfirmableTooltip } from "../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../ui-components/ConfirmMenu"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; const ResetAllSettingsButton = ({ @@ -22,21 +22,17 @@ const ResetAllSettingsButton = ({ ); - // tippy needs the button itself as its child, the tooltip goes around both return compact ? ( - + {trigger} - + {text} ) : ( - + {trigger} - + ); }; diff --git a/frontend/src/js/query-runner/DownloadResultsDropdownButton.tsx b/frontend/src/js/query-runner/DownloadResultsDropdownButton.tsx index b1ccd56264..861bf314c3 100644 --- a/frontend/src/js/query-runner/DownloadResultsDropdownButton.tsx +++ b/frontend/src/js/query-runner/DownloadResultsDropdownButton.tsx @@ -1,11 +1,16 @@ import { faCaretDown, faDownload } from "@fortawesome/free-solid-svg-icons"; -import { memo, useEffect, useMemo, useState } from "react"; +import { memo, useContext, useEffect, useMemo, useState } from "react"; +import { MenuTrigger } from "react-aria-components"; +import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; import type { ResultUrlWithLabel } from "../api/types"; -import DownloadButton from "../button/DownloadButton"; +import { AuthTokenContext } from "../authorization/AuthTokenProvider"; +import DownloadButton, { getFileIcon } from "../button/DownloadButton"; import IconButton from "../button/IconButton"; -import WithTooltip from "../ui-components/WithTooltip"; +import FaIcon from "../icon/FaIcon"; +import { Menu, MenuItem, menuItemIcon } from "../ui-components/Menu"; +import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; import { getUserSettings, storeUserSettings } from "../user/userSettings"; const frame = tv({ @@ -20,10 +25,6 @@ const frame = tv({ }, }); -const list = tv({ - base: ["flex flex-col", "gap-px", "p-2", "max-h-[60vh]", "overflow-y-auto"], -}); - const downloadButton = tv({ base: ["[&_button]:w-full", "[&_button]:px-[14px] [&_button]:py-2"], }); @@ -32,17 +33,6 @@ const dropdownOpenButton = tv({ base: "px-2 py-[9px]" }); const separator = tv({ base: ["h-[33px] w-px", "bg-gray-500"] }); -const popperOptions = { - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 20, - }, - }, - ], -}; - interface FileChoice { label: string; ending: string; @@ -81,6 +71,8 @@ const DownloadResultsDropdownButton = ({ tiny?: boolean; tooltip?: string; }) => { + const { t } = useTranslation(); + const { authToken } = useContext(AuthTokenContext); const [fileChoice, setFileChoice] = useState(() => { const initial = getInitialEndingChoice(resultUrls); return { label: initial.label, ending: getEnding(initial.url) }; @@ -101,29 +93,6 @@ const DownloadResultsDropdownButton = ({ return truncate(fileChoice.label); }, [fileChoice]); - const dropdown = useMemo(() => { - return ( -
- {resultUrls.map((resultUrl) => { - const ending = getEnding(resultUrl.url); - - return ( - setFileChoice({ label: resultUrl.label, ending })} - bgHover - showColoredIcon - > - {truncate(resultUrl.label)} - - ); - })} -
- ); - }, [resultUrls]); - return (
{!tiny && ( @@ -139,21 +108,46 @@ const DownloadResultsDropdownButton = ({
)} - - + + - - + { + const chosen = resultUrls.find(({ url }) => url === key); + if (chosen) { + setFileChoice({ + label: chosen.label, + ending: getEnding(chosen.url), + }); + } + }} + > + {resultUrls.map((resultUrl) => { + const { icon, color } = getFileIcon(resultUrl.url); + + return ( + + + + + {truncate(resultUrl.label)} + + ); + })} + + + {tooltip} +
); }; diff --git a/frontend/src/js/standard-query-editor/QueryClearButton.tsx b/frontend/src/js/standard-query-editor/QueryClearButton.tsx index 241d42c8d7..077313359c 100644 --- a/frontend/src/js/standard-query-editor/QueryClearButton.tsx +++ b/frontend/src/js/standard-query-editor/QueryClearButton.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; import IconButton from "../button/IconButton"; -import { ConfirmableTooltip } from "../ui-components/ConfirmableTooltip"; +import { ConfirmMenu } from "../ui-components/ConfirmMenu"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; import { clearQuery } from "./actions"; @@ -16,12 +16,12 @@ const QueryClearButton = ({ className }: { className?: string }) => { return (
- - + {t("queryEditor.clear")}
diff --git a/frontend/src/js/ui-components/ConfirmMenu.tsx b/frontend/src/js/ui-components/ConfirmMenu.tsx new file mode 100644 index 0000000000..479c4e2a02 --- /dev/null +++ b/frontend/src/js/ui-components/ConfirmMenu.tsx @@ -0,0 +1,46 @@ +import type { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { faCheck } from "@fortawesome/free-solid-svg-icons"; +import type { ReactNode } from "react"; +import { MenuTrigger } from "react-aria-components"; +import { useTranslation } from "react-i18next"; + +import FaIcon from "../icon/FaIcon"; + +import { Menu, MenuItem, menuItemIcon } from "./Menu"; + +/** + * Asks for confirmation before calling onConfirm: a menu with a single item. + * The child is the trigger, a button built on BasicButton. + */ +export const ConfirmMenu = ({ + children, + confirmationIcon, + confirmationText, + placement, + onConfirm, + red, +}: { + children: ReactNode; + confirmationText?: string; + confirmationIcon?: IconProp; + placement?: "top" | "bottom" | "left" | "right"; + onConfirm: () => void; + red?: boolean; +}) => { + const { t } = useTranslation(); + const label = confirmationText || t("common.confirm"); + + return ( + + {children} + + + + + + {label} + + + + ); +}; diff --git a/frontend/src/js/ui-components/ConfirmableTooltip.tsx b/frontend/src/js/ui-components/ConfirmableTooltip.tsx deleted file mode 100644 index d5759891f1..0000000000 --- a/frontend/src/js/ui-components/ConfirmableTooltip.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { IconProp } from "@fortawesome/fontawesome-svg-core"; -import { faCheck } from "@fortawesome/free-solid-svg-icons"; -import { type ReactElement, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { tv } from "tailwind-variants"; -import type { Instance } from "tippy.js"; - -import IconButton from "../button/IconButton"; - -import WithTooltip from "./WithTooltip"; - -const confirmButton = tv({ base: ["w-full", "px-[14px] py-2"] }); - -const offset = [0, 5] as [number, number]; -export const ConfirmableTooltip = ({ - children, - confirmationIcon, - confirmationText, - placement, - onConfirm, - red, -}: { - children: ReactElement; - confirmationText?: string; - confirmationIcon?: IconProp; - placement?: "top" | "bottom" | "left" | "right"; - onConfirm: () => void; - red?: boolean; -}) => { - const tippyRef = useRef(null); - const { t } = useTranslation(); - const dropdown = useMemo(() => { - return ( -
- { - onConfirm(); - - // https://github.com/atomiks/tippyjs-react/issues/324 - // @ts-ignore TODO: Find a better way to get the tippy instance / to hide it - const tippyInstance = tippyRef.current?._tippy as Instance; - if (tippyInstance) { - tippyInstance.hide(); - } - }} - small - bgHover - red={red} - data-test-id="confirm" - > - {confirmationText || t("common.confirm")} - -
- ); - }, [t, confirmationText, confirmationIcon, onConfirm, red]); - - return ( - - {children} - - ); -}; diff --git a/frontend/src/js/ui-components/Menu.stories.tsx b/frontend/src/js/ui-components/Menu.stories.tsx new file mode 100644 index 0000000000..a438bcb2f0 --- /dev/null +++ b/frontend/src/js/ui-components/Menu.stories.tsx @@ -0,0 +1,87 @@ +import { + faBook, + faEllipsisV, + faPaperPlane, + faTrash, +} from "@fortawesome/free-solid-svg-icons"; +import type { Meta, StoryObj } from "@storybook/react"; +import { MenuTrigger } from "react-aria-components"; + +import IconButton from "../button/IconButton"; +import { TransparentButton } from "../button/TransparentButton"; +import FaIcon from "../icon/FaIcon"; + +import { ConfirmMenu } from "./ConfirmMenu"; +import { Menu, MenuItem, menuItemIcon } from "./Menu"; +import { Tooltip, TooltipTrigger } from "./Tooltip"; + +export default { + title: "UiComponents/Menu", + component: Menu, + parameters: { layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + console.log(key)}> + + + + + A link item + + + + + + An action item + + + + + + A disabled item + + + + + + A dangerous item + + + + ), +}; + +export const Confirm: Story = { + render: () => ( +
+ {}}> + Clear + + {}} + > + + +
+ ), +}; + +export const WithTooltipOnTrigger: Story = { + render: () => ( + + {}}> + + + Delete + + ), +}; diff --git a/frontend/src/js/ui-components/Menu.tsx b/frontend/src/js/ui-components/Menu.tsx new file mode 100644 index 0000000000..41de0d215d --- /dev/null +++ b/frontend/src/js/ui-components/Menu.tsx @@ -0,0 +1,88 @@ +import { + type PopoverProps, + Menu as RacMenu, + MenuItem as RacMenuItem, + type MenuItemProps as RacMenuItemProps, + type MenuProps as RacMenuProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +import { Popover } from "./Popover"; + +const menu = tv({ + base: [ + "flex flex-col", + "gap-[2px]", + "p-2", + "max-h-[60vh]", + "overflow-y-auto", + "outline-none", + ], +}); + +const menuItem = tv({ + base: [ + "flex items-center", + "gap-[10px]", + "rounded", + "px-[15px] py-2", + // explicit: through the portal an item would inherit body's line-height and light weight + "text-sm leading-none font-normal", + "text-gray-800", + "whitespace-nowrap", + "cursor-pointer", + "outline-none", + "opacity-75", + "data-focused:opacity-100 data-focused:bg-gray-50", + "data-disabled:cursor-not-allowed data-disabled:opacity-40", + "transition-[opacity,background-color] duration-100", + ], + variants: { + // the svg selector beats the colour FaIcon still sets on itself + danger: { true: "text-red [&_svg]:text-red" }, + }, +}); + +/** Fixed-width box so icons of different widths keep the labels aligned. */ +export const menuItemIcon = tv({ + base: ["inline-flex justify-center", "w-4 shrink-0"], +}); + +/** + * A list of actions or links that opens next to its trigger, composed the way + * react-aria does it. The popover is part of the Menu: + * + * + * + * …}> + * + * + * + * + * + * MenuTrigger comes from react-aria-components; buttons built on BasicButton + * are its trigger without further wiring. Items focus on hover, arrow keys + * move between them, the menu closes after an action. `placement` positions + * the menu relative to the trigger (default below, start-aligned). + */ +export const Menu = ({ + className, + placement, + ...props +}: Omit, "className"> & { + className?: string; + placement?: PopoverProps["placement"]; +}) => ( + + + +); + +export const MenuItem = ({ + className, + danger, + ...props +}: Omit & { + className?: string; + danger?: boolean; +}) => ; diff --git a/frontend/src/js/ui-components/Popover.tsx b/frontend/src/js/ui-components/Popover.tsx new file mode 100644 index 0000000000..142128d590 --- /dev/null +++ b/frontend/src/js/ui-components/Popover.tsx @@ -0,0 +1,29 @@ +import { + Popover as RacPopover, + type PopoverProps as RacPopoverProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +const popover = tv({ + base: [ + "z-[9999]", + "rounded", + "bg-white", + "shadow-[0_0_8px_rgba(0,0,0,0.18)]", + "data-entering:animate-fade-in", + "data-exiting:animate-fade-out", + ], +}); + +/** + * Container for click-triggered overlays, positioned next to its trigger and + * dismissed on Escape or a click outside. Menu renders it itself, so callers + * don't need it; it is exported for further overlay components. + */ +export const Popover = ({ + className, + offset = 5, + ...props +}: Omit & { className?: string }) => ( + +); diff --git a/frontend/src/js/ui-components/WithTooltip.tsx b/frontend/src/js/ui-components/WithTooltip.tsx deleted file mode 100644 index 6eea9ac777..0000000000 --- a/frontend/src/js/ui-components/WithTooltip.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import Tippy, { type TippyProps } from "@tippyjs/react"; -import { memo, type ReactElement, type Ref, useMemo } from "react"; -import { tv } from "tailwind-variants"; -import "tippy.js/dist/tippy.css"; -import "tippy.js/themes/light.css"; - -const text = tv({ - base: [ - "max-w-[400px]", - "text-left", - "text-base", - "font-normal", - "px-[14px] py-2", - "[&_p]:text-gray-800 [&_h3]:text-gray-800 [&_h4]:text-gray-800", - "[&_p]:leading-[1.3] [&_h3]:leading-[1.3] [&_h4]:leading-[1.3]", - "[&_p]:mt-2 [&_h3]:mt-2 [&_h4]:mt-2", - "[&_p]:text-sm [&_h3]:text-sm [&_li]:text-sm", - "[&_ul]:my-[6px] [&_ul]:pl-4", - "[&_li]:leading-[1.3] [&_li]:mb-[5px]", - ], - variants: { - wide: { true: "max-w-[700px]" }, - }, -}); - -interface Props { - className?: string; - text?: string; - html?: ReactElement; - lazy?: boolean; - wide?: boolean; - children?: ReactElement; - interactive?: boolean; - trigger?: string; - arrow?: TippyProps["arrow"]; - offset?: TippyProps["offset"]; - hideOnClick?: TippyProps["hideOnClick"]; - popperOptions?: TippyProps["popperOptions"]; - - // Some others are possible in @tippyjs/react, but those should be enough - // default: "auto" - placement?: "auto" | "top" | "bottom" | "left" | "right"; -} - -// Show and hide duration -const shortDuration = [100, 100] as [number, number]; - -const WithTooltip = ({ - ref, - className, - children, - text: textProp, - html, - lazy, - wide, - placement, - interactive, - trigger, - arrow, - offset, - hideOnClick, - popperOptions, -}: Props & { ref?: Ref }) => { - const content = useMemo(() => { - return textProp ? ( -
- ) : ( - html - ); - }, [wide, textProp, html]); - - const delay = useMemo( - () => (lazy ? ([1000, 0] as [number, number]) : 0), - [lazy], - ); - - if (!textProp && !html) return <>{children}; - - return ( - - {children} - - ); -}; - -export default memo(WithTooltip); diff --git a/frontend/src/localization/de.json b/frontend/src/localization/de.json index d085c27036..7d56153b37 100644 --- a/frontend/src/localization/de.json +++ b/frontend/src/localization/de.json @@ -134,6 +134,7 @@ }, "common": { "version": "Version", + "help": "Hilfe", "manual": "Handbuch", "contact": "Kontakt", "confirm": "Bestätigen", diff --git a/frontend/src/localization/en.json b/frontend/src/localization/en.json index 25bb13802e..bc890d23b1 100644 --- a/frontend/src/localization/en.json +++ b/frontend/src/localization/en.json @@ -134,6 +134,7 @@ }, "common": { "version": "Version", + "help": "Help", "manual": "Manual", "contact": "Contact", "confirm": "Confirm",