From 4d0b67a633900bac795c23dfcd92cef01bbaa363 Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Thu, 3 Sep 2026 13:11:09 +0200 Subject: [PATCH 1/4] refactor(frontend): Button with intent and size replaces the text buttons --- frontend/src/js/authorization/LoginPage.tsx | 16 ++- frontend/src/js/button/BadgeToggleButton.tsx | 13 +- frontend/src/js/button/DestroyButton.tsx | 20 --- frontend/src/js/button/PrimaryButton.tsx | 23 ---- frontend/src/js/button/SelectFileButton.tsx | 25 ---- frontend/src/js/button/TransparentButton.tsx | 25 ---- .../js/concept-trees/ConceptTreeSearchBox.tsx | 12 +- .../src/js/entity-history/SearchEntities.tsx | 12 +- .../src/js/error-fallback/ErrorFallback.tsx | 18 +-- .../FormConceptCopyModal.tsx | 16 +-- .../form-concept-group/FormConceptGroup.tsx | 12 +- .../form/fields/DisclosureListField.tsx | 13 +- frontend/src/js/modal/DeleteModal.tsx | 12 +- frontend/src/js/modal/Modal.tsx | 7 +- frontend/src/js/preview/Preview.tsx | 7 +- .../previous-queries/list/AddFolderModal.tsx | 15 ++- .../upload/CSVColumnPicker.tsx | 40 +++--- .../js/query-node-editor/ResetAndClose.tsx | 7 +- .../UploadFilterListModal.tsx | 11 +- .../src/js/query-runner/QueryRunnerButton.tsx | 34 +++--- .../ExpandPreviousQueryModal.tsx | 12 +- .../src/js/ui-components/Button.stories.tsx | 84 +++++++++++++ frontend/src/js/ui-components/Button.tsx | 114 ++++++++++++++++++ .../ui-components/DropzoneWithFileInput.tsx | 17 ++- .../src/js/ui-components/Icon.stories.tsx | 7 +- frontend/src/js/ui-components/ImportModal.tsx | 24 ++-- .../ui-components/InputDate/CustomHeader.tsx | 14 +-- .../InputMultiSelect/MenuActionBar.tsx | 14 +-- .../src/js/ui-components/Menu.stories.tsx | 5 +- .../src/js/ui-components/Tooltip.stories.tsx | 20 +-- .../UploadConceptListModal.tsx | 8 +- 31 files changed, 383 insertions(+), 274 deletions(-) delete mode 100644 frontend/src/js/button/DestroyButton.tsx delete mode 100644 frontend/src/js/button/PrimaryButton.tsx delete mode 100644 frontend/src/js/button/SelectFileButton.tsx delete mode 100644 frontend/src/js/button/TransparentButton.tsx create mode 100644 frontend/src/js/ui-components/Button.stories.tsx create mode 100644 frontend/src/js/ui-components/Button.tsx diff --git a/frontend/src/js/authorization/LoginPage.tsx b/frontend/src/js/authorization/LoginPage.tsx index 65f7d8eab9..69b2b4f488 100644 --- a/frontend/src/js/authorization/LoginPage.tsx +++ b/frontend/src/js/authorization/LoginPage.tsx @@ -5,8 +5,8 @@ import { useNavigate } from "react-router"; import { tv } from "tailwind-variants"; import { usePostLogin } from "../api/api"; import { useAppTheme } from "../app-theme-context"; -import PrimaryButton from "../button/PrimaryButton"; import ErrorMessage from "../error-message/ErrorMessage"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; import InputPlain from "../ui-components/InputPlain/InputPlain"; @@ -115,18 +115,16 @@ const LoginPage = () => { disabled: loading, }} /> - - + {t("login.submit")} - + diff --git a/frontend/src/js/button/BadgeToggleButton.tsx b/frontend/src/js/button/BadgeToggleButton.tsx index ae8ad3f3ce..82bf3c0e94 100644 --- a/frontend/src/js/button/BadgeToggleButton.tsx +++ b/frontend/src/js/button/BadgeToggleButton.tsx @@ -3,10 +3,10 @@ import { useHotkeys } from "react-hotkeys-hook"; import { tv } from "tailwind-variants"; -import BasicButton from "./BasicButton"; +import { Button } from "../ui-components/Button"; const badgeToggleButton = tv({ - base: ["rounded", "px-1 py-px", "text-sm", "font-bold", "whitespace-nowrap"], + base: ["h-auto px-1 py-px", "text-sm", "font-bold"], variants: { active: { true: [ @@ -51,13 +51,16 @@ export const BadgeToggleButton = ({ useHotkeys(hotkey || "", onClick, { enabled: !!hotkey }, [hotkey, onClick]); return ( - {!active && "+ "} {children} {hotkey && {hotkey}} - + ); }; diff --git a/frontend/src/js/button/DestroyButton.tsx b/frontend/src/js/button/DestroyButton.tsx deleted file mode 100644 index d9328c4f18..0000000000 --- a/frontend/src/js/button/DestroyButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { ComponentProps } from "react"; - -import { tv } from "tailwind-variants"; - -import { TransparentButton } from "./TransparentButton"; - -const destroyButton = tv({ - base: [ - "text-red hover:text-white active:text-white focus:text-white", - "border-2 border-red", - "hover:bg-red active:bg-red focus:bg-red", - ], -}); - -export const DestroyButton = ({ - className, - ...props -}: ComponentProps) => ( - -); diff --git a/frontend/src/js/button/PrimaryButton.tsx b/frontend/src/js/button/PrimaryButton.tsx deleted file mode 100644 index 34ece7cc0e..0000000000 --- a/frontend/src/js/button/PrimaryButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Ref } from "react"; - -import { tv } from "tailwind-variants"; - -import BasicButton, { type BasicButtonProps } from "./BasicButton"; - -const primaryButton = tv({ - base: [ - "text-white", - "bg-primary-500 bg-clip-padding", - "border border-primary-500", - "hover:opacity-90", - ], -}); - -const PrimaryButton = ({ - className, - ...props -}: BasicButtonProps & { ref?: Ref }) => ( - -); - -export default PrimaryButton; diff --git a/frontend/src/js/button/SelectFileButton.tsx b/frontend/src/js/button/SelectFileButton.tsx deleted file mode 100644 index 15eee1b7fc..0000000000 --- a/frontend/src/js/button/SelectFileButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Ref } from "react"; - -import { tv } from "tailwind-variants"; - -import BasicButton, { type BasicButtonProps } from "./BasicButton"; - -const selectFileButton = tv({ - base: [ - "flex items-center", - "gap-[5px]", - "bg-transparent", - "border-0", - "text-[11px]", - "text-gray-500", - "font-light", - "hover:underline", - ], -}); - -export const SelectFileButton = ({ - className, - ...props -}: BasicButtonProps & { ref?: Ref }) => ( - -); diff --git a/frontend/src/js/button/TransparentButton.tsx b/frontend/src/js/button/TransparentButton.tsx deleted file mode 100644 index 27519c7fb6..0000000000 --- a/frontend/src/js/button/TransparentButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Ref } from "react"; - -import { tv } from "tailwind-variants"; - -import BasicButton, { type BasicButtonProps } from "./BasicButton"; - -const transparentButton = tv({ - base: [ - "rounded", - "bg-transparent hover:bg-gray-50 focus:bg-gray-50", - "border border-gray-500 focus:border-green", - "text-gray-800", - ], - variants: { - light: { true: "border-gray-100 text-gray-500" }, - }, -}); - -export const TransparentButton = ({ - className, - light, - ...props -}: BasicButtonProps & { light?: boolean; ref?: Ref }) => ( - -); diff --git a/frontend/src/js/concept-trees/ConceptTreeSearchBox.tsx b/frontend/src/js/concept-trees/ConceptTreeSearchBox.tsx index 629f322a41..097e1245a4 100644 --- a/frontend/src/js/concept-trees/ConceptTreeSearchBox.tsx +++ b/frontend/src/js/concept-trees/ConceptTreeSearchBox.tsx @@ -2,12 +2,11 @@ import { useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { tv } from "tailwind-variants"; - import type { StateT } from "../app/reducers"; -import { TransparentButton } from "../button/TransparentButton"; import AnimatedDots from "../common/components/AnimatedDots"; import ConceptTreesOpenButtons from "../concept-trees-open/ConceptTreesOpenButtons"; import SearchBar from "../search-bar/SearchBar"; +import { Button } from "../ui-components/Button"; import { clearSearchQuery, @@ -90,15 +89,16 @@ const ConceptTreeSearchBox = ({ className }: { className?: string }) => { ? t("conceptTreeList.showingMismatches") : t("conceptTreeList.showingMatchesOnly")} - {showMismatches ? t("conceptTreeList.showMatchesOnly") : t("conceptTreeList.showMismatches")} - + ) diff --git a/frontend/src/js/entity-history/SearchEntities.tsx b/frontend/src/js/entity-history/SearchEntities.tsx index 011d2ca2a4..31679ac5d8 100644 --- a/frontend/src/js/entity-history/SearchEntities.tsx +++ b/frontend/src/js/entity-history/SearchEntities.tsx @@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import { tv } from "tailwind-variants"; - import { usePostPrefixForSuggestions, usePostResolveEntities, @@ -11,7 +10,6 @@ import { import { transformFilterValueToApi } from "../api/apiHelper"; import type { TableT } from "../api/types"; import type { StateT } from "../app/reducers"; -import PrimaryButton from "../button/PrimaryButton"; import { getConceptById } from "../concept-trees/globalTreeStoreHelper"; import { useDatasetId } from "../dataset/selectors"; import { isMultiSelectFilter, resetFilters } from "../model/filter"; @@ -22,6 +20,7 @@ import type { BigMultiSelectFilterWithValueType, MultiSelectFilterWithValueType, } from "../standard-query-editor/types"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; import type { LoadingPayload } from "./LoadHistoryDropzone"; @@ -246,14 +245,15 @@ export const SearchEntitiesComponent = ({ onSwitchFilterMode={noop} onLoadFilterSuggestions={loadFilterSuggestions} /> - {loading && } {t("history.searchEntitiesButton")} - + ); }; diff --git a/frontend/src/js/error-fallback/ErrorFallback.tsx b/frontend/src/js/error-fallback/ErrorFallback.tsx index 004b61caaf..c08c19f8a5 100644 --- a/frontend/src/js/error-fallback/ErrorFallback.tsx +++ b/frontend/src/js/error-fallback/ErrorFallback.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - -import { TransparentButton } from "../button/TransparentButton"; +import { Button } from "../ui-components/Button"; const root = tv({ base: [ @@ -36,20 +35,25 @@ const ErrorFallback = ({ {allowFullRefresh && ( <>

{t("error.reloadDescription")}

- window.location.reload()} + onPress={() => window.location.reload()} > {t("error.reload")} - + )} {onReset && ( <>

{t("error.resetDescription")}

- + )} diff --git a/frontend/src/js/external-forms/form-concept-group/FormConceptCopyModal.tsx b/frontend/src/js/external-forms/form-concept-group/FormConceptCopyModal.tsx index e7b51b17f3..a6f872aa42 100644 --- a/frontend/src/js/external-forms/form-concept-group/FormConceptCopyModal.tsx +++ b/frontend/src/js/external-forms/form-concept-group/FormConceptCopyModal.tsx @@ -2,13 +2,11 @@ import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - import type { SelectOptionT } from "../../api/types"; -import PrimaryButton from "../../button/PrimaryButton"; -import { TransparentButton } from "../../button/TransparentButton"; import { exists } from "../../common/helpers/exists"; import { useActiveLang } from "../../localization/useActiveLang"; import Modal from "../../modal/Modal"; +import { Button } from "../../ui-components/Button"; import InputCheckbox from "../../ui-components/InputCheckbox"; import InputSelect from "../../ui-components/InputSelect/InputSelect"; import { useVisibleConceptListFields } from "../stateSelectors"; @@ -163,12 +161,16 @@ const FormConceptCopyModal = ({ )}
- + +
); diff --git a/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx b/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx index e656ec79e2..8c5e054af1 100644 --- a/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx +++ b/frontend/src/js/external-forms/form-concept-group/FormConceptGroup.tsx @@ -1,10 +1,8 @@ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - import { usePostPrefixForSuggestions } from "../../api/api"; import type { SelectorResultType } from "../../api/types"; -import { TransparentButton } from "../../button/TransparentButton"; import { DNDType } from "../../common/constants/dndTypes"; import { exists } from "../../common/helpers/exists"; import { @@ -16,6 +14,7 @@ import { nodeHasNonDefaultSettings, } from "../../model/node"; import type { DragItemConceptTreeNode } from "../../standard-query-editor/types"; +import { Button } from "../../ui-components/Button"; import { isMovedObject, type PossibleDroppableObject, @@ -309,13 +308,14 @@ const FormConceptGroup = (props: Props) => { <> {props.label} {allowExtendedCopying && ( - setIsCopyModalOpen(true)} + size="sm" + onPress={() => setIsCopyModalOpen(true)} > {t("externalForms.common.concept.copyFrom")} - + )} } diff --git a/frontend/src/js/external-forms/form/fields/DisclosureListField.tsx b/frontend/src/js/external-forms/form/fields/DisclosureListField.tsx index 9c783ede1f..561a56bebc 100644 --- a/frontend/src/js/external-forms/form/fields/DisclosureListField.tsx +++ b/frontend/src/js/external-forms/form/fields/DisclosureListField.tsx @@ -8,9 +8,9 @@ import { type ComponentProps, useEffect, useState } from "react"; import { useFieldArray } from "react-hook-form"; import { tv } from "tailwind-variants"; import IconButton from "../../../button/IconButton"; -import { TransparentButton } from "../../../button/TransparentButton"; import { exists } from "../../../common/helpers/exists"; import { usePrevious } from "../../../common/helpers/usePrevious"; +import { Button } from "../../../ui-components/Button"; import { Icon } from "../../../ui-components/Icon"; import InfoTooltip from "../../../ui-components/InfoTooltip"; import type { DisclosureListField as DisclosureListFieldT } from "../../config-types"; @@ -222,10 +222,11 @@ export const DisclosureListField = ({ /> ))} {field.creatable && ( - { + )} ); diff --git a/frontend/src/js/modal/DeleteModal.tsx b/frontend/src/js/modal/DeleteModal.tsx index ca15f36a8d..14a01725f2 100644 --- a/frontend/src/js/modal/DeleteModal.tsx +++ b/frontend/src/js/modal/DeleteModal.tsx @@ -1,9 +1,7 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - -import { DestroyButton } from "../button/DestroyButton"; -import { TransparentButton } from "../button/TransparentButton"; +import { Button } from "../ui-components/Button"; import Modal from "./Modal"; @@ -31,10 +29,12 @@ const DeleteModal = ({
{description &&

{description}

}
- + +
diff --git a/frontend/src/js/modal/Modal.tsx b/frontend/src/js/modal/Modal.tsx index d99e245313..51ab8ad5d2 100644 --- a/frontend/src/js/modal/Modal.tsx +++ b/frontend/src/js/modal/Modal.tsx @@ -2,10 +2,9 @@ import { type ReactNode, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - -import { TransparentButton } from "../button/TransparentButton"; import { useClickOutside } from "../common/helpers/useClickOutside"; import { Heading3 } from "../headings/Headings"; +import { Button } from "../ui-components/Button"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; const root = tv({ @@ -95,9 +94,9 @@ const Modal = ({ {headline} {doneButton && ( - + {t("common.closeEsc")} )} diff --git a/frontend/src/js/preview/Preview.tsx b/frontend/src/js/preview/Preview.tsx index 0df5912053..5ba9bc850a 100644 --- a/frontend/src/js/preview/Preview.tsx +++ b/frontend/src/js/preview/Preview.tsx @@ -4,10 +4,9 @@ import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { tv } from "tailwind-variants"; - import type { PreviewStatistics, SecondaryId } from "../api/types"; import type { StateT } from "../app/reducers"; -import { TransparentButton } from "../button/TransparentButton"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; import { closePreview } from "./actions"; import Charts from "./Charts"; @@ -94,9 +93,9 @@ export default function Preview() {
- + Ergebnisvorschau { inputProps={{ autoFocus: true }} />
- +
diff --git a/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx b/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx index 4a338f9ba3..2034ce85a6 100644 --- a/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx +++ b/frontend/src/js/previous-queries/upload/CSVColumnPicker.tsx @@ -14,11 +14,10 @@ import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; import type { QueryUploadConfigT, UploadQueryResponseT } from "../../api/types"; import IconButton from "../../button/IconButton"; -import PrimaryButton from "../../button/PrimaryButton"; -import { TransparentButton } from "../../button/TransparentButton"; import { parseCSV, toCSV } from "../../file/csv"; import { useActiveLang } from "../../localization/useActiveLang"; import ScrollableList from "../../scrollable-list/ScrollableList"; +import { Button } from "../../ui-components/Button"; import { Icon } from "../../ui-components/Icon"; import InputSelect from "../../ui-components/InputSelect/InputSelect"; import { Tooltip, TooltipTrigger } from "../../ui-components/Tooltip"; @@ -350,20 +349,25 @@ const CSVColumnPicker = ({ {uploadResult && (uploadResult.unreadableDate.length > 0 || uploadResult.unresolvedId.length > 0) && ( - - {" "} + )} {uploadResult && ( - {loading ? ( @@ -371,21 +375,23 @@ const CSVColumnPicker = ({ )}{" "} {t("uploadQueryResultsModal.uploadAgain")} - + )} {uploadResult ? ( - {t("common.done")} - + ) : ( - {loading ? ( @@ -393,7 +399,7 @@ const CSVColumnPicker = ({ )}{" "} {t("uploadQueryResultsModal.upload")} - + )}
diff --git a/frontend/src/js/query-node-editor/ResetAndClose.tsx b/frontend/src/js/query-node-editor/ResetAndClose.tsx index ac71f7442e..96a054a2a9 100644 --- a/frontend/src/js/query-node-editor/ResetAndClose.tsx +++ b/frontend/src/js/query-node-editor/ResetAndClose.tsx @@ -1,8 +1,7 @@ import { memo } from "react"; import { useTranslation } from "react-i18next"; - -import { TransparentButton } from "../button/TransparentButton"; import type { NodeResetConfig } from "../model/node"; +import { Button } from "../ui-components/Button"; import { Tooltip, TooltipTrigger } from "../ui-components/Tooltip"; import ResetAllSettingsButton from "./ResetAllSettingsButton"; @@ -31,9 +30,9 @@ const ResetAndClose = ({ /> )} - + {t("common.saveAndCloseEsc")} diff --git a/frontend/src/js/query-node-editor/UploadFilterListModal.tsx b/frontend/src/js/query-node-editor/UploadFilterListModal.tsx index 5bc9e4f9a0..123560c065 100644 --- a/frontend/src/js/query-node-editor/UploadFilterListModal.tsx +++ b/frontend/src/js/query-node-editor/UploadFilterListModal.tsx @@ -7,9 +7,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; import type { PostFilterResolveResponseT } from "../api/types"; -import PrimaryButton from "../button/PrimaryButton"; import Modal from "../modal/Modal"; import ScrollableList from "../scrollable-list/ScrollableList"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; import InputCheckbox from "../ui-components/InputCheckbox"; @@ -132,15 +132,16 @@ const UploadFilterListModal = ({ /> )} - { + ); diff --git a/frontend/src/js/query-runner/QueryRunnerButton.tsx b/frontend/src/js/query-runner/QueryRunnerButton.tsx index 0b8befaa49..67d38f574f 100644 --- a/frontend/src/js/query-runner/QueryRunnerButton.tsx +++ b/frontend/src/js/query-runner/QueryRunnerButton.tsx @@ -3,11 +3,16 @@ import type { Ref } from "react"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; -import BasicButton from "../button/BasicButton"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; const left = tv({ - base: ["px-[15px]", "transition-[color,background-color] duration-100"], + base: [ + "self-stretch", + "flex items-center", + "px-[15px]", + "transition-[color,background-color] duration-100", + ], variants: { running: { true: ["bg-white", "border-r border-primary-500"], @@ -21,24 +26,15 @@ const runnerLabel = tv({ "px-[15px]", "bg-white group-hover/runner:bg-gray-50", "text-gray-800", - "leading-[2.5]", + "self-stretch", + "flex items-center", "whitespace-nowrap", "transition-[background-color] duration-100", ], }); const button = tv({ - base: [ - "group/runner", - "inline-flex flex-row items-center", - "m-0 p-0", - "overflow-hidden", - "outline-none", - "rounded", - "border border-primary-500", - "text-sm", - "leading-[2.5]", - ], + base: ["group/runner", "p-0", "overflow-hidden", "border-primary-500"], }); function getIcon(loading: boolean, running: boolean) { @@ -67,11 +63,11 @@ const QueryRunnerButton = ({ return (
- @@ -81,7 +77,7 @@ const QueryRunnerButton = ({ /> {label} - +
); }; diff --git a/frontend/src/js/standard-query-editor/ExpandPreviousQueryModal.tsx b/frontend/src/js/standard-query-editor/ExpandPreviousQueryModal.tsx index 8f7d782c80..14dac56e8b 100644 --- a/frontend/src/js/standard-query-editor/ExpandPreviousQueryModal.tsx +++ b/frontend/src/js/standard-query-editor/ExpandPreviousQueryModal.tsx @@ -1,9 +1,7 @@ import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - -import PrimaryButton from "../button/PrimaryButton"; -import { TransparentButton } from "../button/TransparentButton"; import Modal from "../modal/Modal"; +import { Button } from "../ui-components/Button"; const description = tv({ base: ["max-w-[400px]", "mb-5"], @@ -36,12 +34,12 @@ const ExpandPreviousQueryModal = ({ {t("expandPreviousQueryModal.description")}

- + +
diff --git a/frontend/src/js/ui-components/Button.stories.tsx b/frontend/src/js/ui-components/Button.stories.tsx new file mode 100644 index 0000000000..27ed210452 --- /dev/null +++ b/frontend/src/js/ui-components/Button.stories.tsx @@ -0,0 +1,84 @@ +import { faCheck, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; + +import { Button } from "./Button"; +import { Icon } from "./Icon"; +import { Tooltip, TooltipTrigger } from "./Tooltip"; + +export default { + title: "UiComponents/Button", + component: Button, + parameters: { layout: "centered" }, +} as Meta; + +type Story = StoryObj; + +const intents = ["primary", "secondary", "tertiary", "danger"] as const; +const sizes = ["sm", "md", "lg"] as const; + +export const Intents: Story = { + render: () => ( +
+ {intents.map((intent) => ( + + ))} + +
+ ), +}; + +export const Sizes: Story = { + render: () => ( +
+ {sizes.map((size) => ( +
+ + + + +
+ ))} +
+ ), +}; + +const Pressed = () => { + const [pressed, setPressed] = useState(true); + return ( +
+ + + + A pressed icon button + +
+ ); +}; + +export const PressedState: Story = { render: () => }; diff --git a/frontend/src/js/ui-components/Button.tsx b/frontend/src/js/ui-components/Button.tsx new file mode 100644 index 0000000000..1e6d9bf5f7 --- /dev/null +++ b/frontend/src/js/ui-components/Button.tsx @@ -0,0 +1,114 @@ +import { + Children, + type CSSProperties, + isValidElement, + type ReactNode, + type Ref, +} from "react"; +import { + Button as RacButton, + type ButtonProps as RacButtonProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +import { Icon } from "./Icon"; + +const button = tv({ + base: [ + "inline-flex items-center justify-center", + "shrink-0", + "rounded", + "border", + "leading-none font-normal whitespace-nowrap", + "cursor-pointer", + "transition-[color,background-color,border-color,opacity] duration-100", + "disabled:cursor-not-allowed disabled:opacity-40", + "aria-pressed:text-primary-500", + ], + variants: { + intent: { + primary: [ + "bg-primary-500 text-white border-primary-500", + "hover:opacity-90", + ], + secondary: [ + "bg-transparent text-gray-800 border-gray-500", + "hover:bg-gray-50", + ], + tertiary: [ + "bg-transparent text-gray-800 border-transparent", + "hover:bg-gray-50", + ], + danger: [ + "bg-transparent text-red border-red", + "hover:bg-red hover:text-white", + ], + }, + // every size is a fixed height, so text and icon-only buttons line up + size: { + sm: "h-6 px-2 gap-[5px] text-xs", + md: "h-[30px] px-[15px] gap-[10px] text-sm", + lg: "h-9 px-[18px] gap-[10px] text-base", + }, + // an icon-only button is a square + iconOnly: { true: "px-0" }, + }, + compoundVariants: [ + { iconOnly: true, size: "sm", class: "w-6" }, + { iconOnly: true, size: "md", class: "w-[30px]" }, + { iconOnly: true, size: "lg", class: "w-9" }, + ], + defaultVariants: { intent: "secondary", size: "md" }, +}); + +export interface ButtonProps + extends Omit { + /** what the button does in its context; the look follows */ + intent?: "primary" | "secondary" | "tertiary" | "danger"; + size?: "sm" | "md" | "lg"; + className?: string; + style?: CSSProperties; + children?: ReactNode; + ref?: Ref; +} + +const isIconOnly = (children: ReactNode) => { + const items = Children.toArray(children); + return ( + items.length > 0 && + items.every((child) => isValidElement(child) && child.type === Icon) + ); +}; + +/** + * The button. react-aria's Button underneath, so it is the trigger that + * TooltipTrigger, MenuTrigger and friends expect, handles press via + * `onPress`, and exposes `data-hovered`, `data-pressed`, `data-focus-visible`. + * + * An icon goes in as a child and takes the button's text color: + * + * + * + * + * A button whose only children are icons is square; give it an `aria-label`. + * A pressed state (toggles) is `aria-pressed`, which colors the button. + */ +export const Button = ({ + intent, + size, + className, + children, + ...props +}: ButtonProps) => ( + + {children} + +); diff --git a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx index 9d22086079..399eb4e2d1 100644 --- a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx +++ b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx @@ -4,8 +4,8 @@ import type { DropTargetMonitor } from "react-dnd"; import { NativeTypes } from "react-dnd-html5-backend"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; +import { Button } from "./Button"; -import { SelectFileButton } from "../button/SelectFileButton"; import Dropzone, { type ChildArgs, type PossibleDroppableObject, @@ -32,7 +32,12 @@ const dropzone = tv({ }); const selectFileButton = tv({ - base: "absolute", + base: [ + "absolute", + "h-auto px-0", + "text-[11px] text-gray-500 font-light", + "hover:underline hover:bg-transparent", + ], variants: { outside: { true: "-top-[26px] -right-[12px]", @@ -144,16 +149,18 @@ const DropzoneWithFileInput = < /> )} {showImportButton && onImportLines && ( - setImportModalOpen(true)} + onPress={() => setImportModalOpen(true)} > {t("common.import")} - + )} {onSelectFile && ( danger text - + red icon button diff --git a/frontend/src/js/ui-components/ImportModal.tsx b/frontend/src/js/ui-components/ImportModal.tsx index d41a79aeb8..88ee86c281 100644 --- a/frontend/src/js/ui-components/ImportModal.tsx +++ b/frontend/src/js/ui-components/ImportModal.tsx @@ -1,20 +1,13 @@ import { faFile, faPaste } from "@fortawesome/free-solid-svg-icons"; -import { - type ChangeEvent, - type MouseEvent, - useEffect, - useRef, - useState, -} from "react"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { NativeTypes } from "react-dnd-html5-backend"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - import IconButton from "../button/IconButton"; -import PrimaryButton from "../button/PrimaryButton"; import { getUniqueFileRows } from "../common/helpers/fileHelper"; import Modal from "../modal/Modal"; +import { Button } from "./Button"; import DropzoneWithFileInput, { type DragItemFile, @@ -75,9 +68,7 @@ export const ImportModal = ({ const fileInputRef = useRef(null); - const onSubmitClick = (e: MouseEvent) => { - e.stopPropagation(); - + const onSubmitClick = () => { const lines = textInput .split("\n") .map((line) => line.trim()) @@ -181,12 +172,13 @@ export const ImportModal = ({ {t("importModal.paste")} )} - {t("importModal.submit")} - + {options.map((option) => ( - onSelect(option.value as number)} + onPress={() => onSelect(option.value as number)} > {option.label} - + ))} diff --git a/frontend/src/js/ui-components/InputMultiSelect/MenuActionBar.tsx b/frontend/src/js/ui-components/InputMultiSelect/MenuActionBar.tsx index fd0316ce66..54d934195b 100644 --- a/frontend/src/js/ui-components/InputMultiSelect/MenuActionBar.tsx +++ b/frontend/src/js/ui-components/InputMultiSelect/MenuActionBar.tsx @@ -1,8 +1,7 @@ import { useTranslation } from "react-i18next"; import { tv } from "tailwind-variants"; - -import { TransparentButton } from "../../button/TransparentButton"; import { exists } from "../../common/helpers/exists"; +import { Button } from "../Button"; const row = tv({ base: [ @@ -33,13 +32,14 @@ const MenuActionBar = ({ optionsCount, total, onInsertAllClick }: Props) => { total !== optionsCount && t("inputMultiSelect.ofTotal", { count: total })}

- {t("inputMultiSelect.insertAll")} - + ); }; diff --git a/frontend/src/js/ui-components/Menu.stories.tsx b/frontend/src/js/ui-components/Menu.stories.tsx index b1ee3670da..5fc274d13b 100644 --- a/frontend/src/js/ui-components/Menu.stories.tsx +++ b/frontend/src/js/ui-components/Menu.stories.tsx @@ -6,9 +6,8 @@ import { } 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 { Button } from "./Button"; import { ConfirmMenu } from "./ConfirmMenu"; import { Icon } from "./Icon"; import { Menu, MenuItem } from "./Menu"; @@ -49,7 +48,7 @@ export const Confirm: Story = { render: () => (
{}}> - Clear + Delete - Cancel + Discards your changes - Save + Saves and closes the editor - Disabled + Disabled buttons show no tooltip
@@ -49,7 +49,9 @@ export const Placements: Story = {
{(["top", "bottom", "left", "right"] as const).map((placement) => ( - {placement} + Placed at {placement} ))} @@ -67,7 +69,7 @@ export const Timing: Story = { - Further info + Explains a larger surface: long warm-up @@ -83,7 +85,7 @@ export const Timing: Story = { export const RichContent: Story = { render: () => ( - Rich content +

Headline

diff --git a/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx b/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx index b9da121590..65fa4fc6a4 100644 --- a/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx +++ b/frontend/src/js/upload-concept-list-modal/UploadConceptListModal.tsx @@ -14,7 +14,6 @@ import { import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import { tv } from "tailwind-variants"; - import { usePostConceptsListToResolve, usePostFilterValuesResolve, @@ -29,10 +28,10 @@ import type { SelectOptionT, } from "../api/types"; import type { StateT } from "../app/reducers"; -import PrimaryButton from "../button/PrimaryButton"; import Modal from "../modal/Modal"; import { nodeIsElement } from "../model/node"; import ScrollableList from "../scrollable-list/ScrollableList"; +import { Button } from "../ui-components/Button"; import { Icon } from "../ui-components/Icon"; import InputCheckbox from "../ui-components/InputCheckbox"; import InputPlain from "../ui-components/InputPlain/InputPlain"; @@ -571,7 +570,8 @@ const UploadConceptListModal = ({ label={t("uploadConceptListModal.includeUnresolved")} /> )} - + )} From 60c29b07b2c4ccf7935b45061af79de328d15e65 Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Thu, 3 Sep 2026 14:11:57 +0200 Subject: [PATCH 2/4] link intent; the import buttons are text links without icon --- .../src/js/ui-components/Button.stories.tsx | 2 +- frontend/src/js/ui-components/Button.tsx | 9 ++++++- .../ui-components/DropzoneWithFileInput.tsx | 26 ++++--------------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/frontend/src/js/ui-components/Button.stories.tsx b/frontend/src/js/ui-components/Button.stories.tsx index 27ed210452..f699054816 100644 --- a/frontend/src/js/ui-components/Button.stories.tsx +++ b/frontend/src/js/ui-components/Button.stories.tsx @@ -14,7 +14,7 @@ export default { type Story = StoryObj; -const intents = ["primary", "secondary", "tertiary", "danger"] as const; +const intents = ["primary", "secondary", "tertiary", "danger", "link"] as const; const sizes = ["sm", "md", "lg"] as const; export const Intents: Story = { diff --git a/frontend/src/js/ui-components/Button.tsx b/frontend/src/js/ui-components/Button.tsx index 1e6d9bf5f7..2a9aa9d35d 100644 --- a/frontend/src/js/ui-components/Button.tsx +++ b/frontend/src/js/ui-components/Button.tsx @@ -43,6 +43,12 @@ const button = tv({ "bg-transparent text-red border-red", "hover:bg-red hover:text-white", ], + // reads as a text link, aligns with text: no padding, no box + link: [ + "bg-transparent border-transparent px-0", + "text-gray-500", + "hover:text-gray-800 hover:underline", + ], }, // every size is a fixed height, so text and icon-only buttons line up size: { @@ -64,7 +70,7 @@ const button = tv({ export interface ButtonProps extends Omit { /** what the button does in its context; the look follows */ - intent?: "primary" | "secondary" | "tertiary" | "danger"; + intent?: "primary" | "secondary" | "tertiary" | "danger" | "link"; size?: "sm" | "md" | "lg"; className?: string; style?: CSSProperties; @@ -92,6 +98,7 @@ const isIconOnly = (children: ReactNode) => { * * A button whose only children are icons is square; give it an `aria-label`. * A pressed state (toggles) is `aria-pressed`, which colors the button. + * `link` is for a button that reads as a text link. */ export const Button = ({ intent, diff --git a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx index 399eb4e2d1..7e9517cf1f 100644 --- a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx +++ b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx @@ -1,4 +1,3 @@ -import { faFileImport } from "@fortawesome/free-solid-svg-icons"; import { type ReactNode, type Ref, useRef, useState } from "react"; import type { DropTargetMonitor } from "react-dnd"; import { NativeTypes } from "react-dnd-html5-backend"; @@ -10,7 +9,6 @@ import Dropzone, { type ChildArgs, type PossibleDroppableObject, } from "./Dropzone"; -import { Icon } from "./Icon"; import { ImportModal } from "./ImportModal"; export interface DragItemFile { @@ -31,25 +29,16 @@ const dropzone = tv({ }, }); -const selectFileButton = tv({ - base: [ - "absolute", - "h-auto px-0", - "text-[11px] text-gray-500 font-light", - "hover:underline hover:bg-transparent", - ], +const importButton = tv({ + base: "absolute", variants: { outside: { - true: "-top-[26px] -right-[12px]", + true: "-top-[30px] -right-[12px]", false: "top-[3px] right-0", }, }, }); -const importIcon = tv({ - base: ["h-[10px]", "pr-[3px]"], -}); - interface PropsT { children: (args: ChildArgs) => ReactNode; onSelectFile?: (file: File) => void; @@ -150,15 +139,10 @@ const DropzoneWithFileInput = < )} {showImportButton && onImportLines && ( )} From 5be9f227a034b237b8f7733253fcd36ac19e5e35 Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Thu, 3 Sep 2026 14:12:21 +0200 Subject: [PATCH 3/4] dialog footer buttons are md --- frontend/src/js/modal/Modal.tsx | 2 +- frontend/src/js/preview/Preview.tsx | 2 +- frontend/src/js/query-node-editor/ResetAndClose.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/js/modal/Modal.tsx b/frontend/src/js/modal/Modal.tsx index 51ab8ad5d2..f53f897b31 100644 --- a/frontend/src/js/modal/Modal.tsx +++ b/frontend/src/js/modal/Modal.tsx @@ -94,7 +94,7 @@ const Modal = ({ {headline} {doneButton && ( - {t("common.closeEsc")} diff --git a/frontend/src/js/preview/Preview.tsx b/frontend/src/js/preview/Preview.tsx index 5ba9bc850a..fd44479c63 100644 --- a/frontend/src/js/preview/Preview.tsx +++ b/frontend/src/js/preview/Preview.tsx @@ -93,7 +93,7 @@ export default function Preview() {

- Ergebnisvorschau diff --git a/frontend/src/js/query-node-editor/ResetAndClose.tsx b/frontend/src/js/query-node-editor/ResetAndClose.tsx index 96a054a2a9..7d25570311 100644 --- a/frontend/src/js/query-node-editor/ResetAndClose.tsx +++ b/frontend/src/js/query-node-editor/ResetAndClose.tsx @@ -30,7 +30,7 @@ const ResetAndClose = ({ /> )} - {t("common.saveAndCloseEsc")} From 21bd5a6e248421899c6074384f1208edf5fdaab0 Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Thu, 3 Sep 2026 18:37:03 +0200 Subject: [PATCH 4/4] import links: small text, inside the dropzone's right edge --- frontend/src/js/ui-components/DropzoneWithFileInput.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx index 7e9517cf1f..0533c6b33c 100644 --- a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx +++ b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx @@ -29,12 +29,13 @@ const dropzone = tv({ }, }); +// a small text link at the dropzone's top right corner, or above it const importButton = tv({ - base: "absolute", + base: "absolute text-xs", variants: { outside: { - true: "-top-[30px] -right-[12px]", - false: "top-[3px] right-0", + true: "-top-[30px] right-0", + false: "top-[3px] right-2", }, }, });