From 9228b261b10fe805947b82ea173440a45f45cc9e Mon Sep 17 00:00:00 2001 From: Kai Rollmann Date: Thu, 27 Aug 2026 17:49:01 +0200 Subject: [PATCH] refactor(frontend): ui-components on tailwind-variants third styled() slice: inputs, selects, date pickers, dropzones, toggles --- frontend/src/index.css | 19 ++ frontend/src/js/ui-components/BaseInput.tsx | 112 ++++---- .../src/js/ui-components/CurrencyInput.tsx | 72 ++--- frontend/src/js/ui-components/Dropzone.tsx | 85 +++--- .../ui-components/DropzoneWithFileInput.tsx | 79 +++--- .../src/js/ui-components/EditableTagsForm.tsx | 33 ++- .../src/js/ui-components/EditableText.tsx | 54 ++-- .../src/js/ui-components/EditableTextForm.tsx | 36 ++- frontend/src/js/ui-components/ImportModal.tsx | 64 ++--- .../src/js/ui-components/InputCheckbox.tsx | 79 +++--- .../ui-components/InputDate/CustomHeader.tsx | 79 +++--- .../js/ui-components/InputDate/InputDate.tsx | 67 ++--- .../src/js/ui-components/InputDateRange.tsx | 145 +++++----- .../InputMultiSelect/InputMultiSelect.tsx | 7 +- .../InputMultiSelect/LoadMoreSentinel.tsx | 16 +- .../InputMultiSelect/MenuActionBar.tsx | 33 ++- .../InputMultiSelect/SelectedItem.tsx | 43 ++- .../ui-components/InputPlain/InputPlain.tsx | 19 +- frontend/src/js/ui-components/InputRange.tsx | 33 +-- .../InputSelect/InputSelectComponents.tsx | 247 +++++++++++------- .../InputSelect/SelectListOption.tsx | 57 ++-- .../InputTextarea/InputTextarea.tsx | 51 ++-- frontend/src/js/ui-components/Label.tsx | 47 +++- frontend/src/js/ui-components/Labeled.tsx | 25 +- .../ui-components/SelectEmptyPlaceholder.tsx | 22 +- .../src/js/ui-components/ToggleButton.tsx | 85 +++--- .../src/js/ui-components/TooManyValues.tsx | 29 +- .../js/ui-components/VerticalToggleButton.tsx | 72 +++-- 28 files changed, 842 insertions(+), 868 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index a2056fbc15..767de12ae0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -143,3 +143,22 @@ div[data-tippy-root] { border-color: var(--color-gray-200, currentcolor); } } + +/* react-datepicker inside InputDate */ +.conquery-datepicker { + .react-datepicker-wrapper { + position: absolute; + top: 0; + bottom: 0; + z-index: -1; + } + .react-datepicker-popper[data-placement^="bottom"] { + padding-top: 4px; + } + .react-datepicker-popper[data-placement^="top"] { + padding-bottom: 0; + } + .react-datepicker__day--selected { + background: var(--color-primary-500); + } +} diff --git a/frontend/src/js/ui-components/BaseInput.tsx b/frontend/src/js/ui-components/BaseInput.tsx index 987c8bd80e..26790c007e 100644 --- a/frontend/src/js/ui-components/BaseInput.tsx +++ b/frontend/src/js/ui-components/BaseInput.tsx @@ -1,4 +1,3 @@ -import styled from "@emotion/styled"; import { faCheck, faExclamationTriangle, @@ -11,6 +10,7 @@ import { useCallback, } from "react"; import { useTranslation } from "react-i18next"; +import { tv } from "tailwind-variants"; import type { CurrencyConfigT } from "../api/types"; import IconButton from "../button/IconButton"; @@ -21,56 +21,41 @@ import WithTooltip from "../tooltip/WithTooltip"; import CurrencyInput from "./CurrencyInput"; -const Root = styled("div")` - position: relative; -`; - -const Input = styled("input")<{ large?: boolean; disabled?: boolean }>` - outline: 0; - width: 100%; - opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; - - border: 1px solid ${({ theme }) => theme.col.grayMediumLight}; - padding: ${({ large }) => - large ? "10px 30px 10px 14px" : "6px 30px 6px 10px"}; - font-size: ${({ theme, large }) => (large ? theme.font.lg : theme.font.sm)}; - border-radius: ${({ theme }) => theme.borderRadius}; - font-weight: 400; -`; - -const SignalIcon = styled(FaIcon)` - position: absolute; - top: 8px; - right: 35px; - opacity: 0.8; -`; - -const GreenIcon = styled(SignalIcon)` - color: ${({ theme }) => theme.col.green}; -`; -const RedIcon = styled(FaIcon)` - color: ${({ theme }) => theme.col.red}; - opacity: 0.8; -`; -const AbsoluteWrap = styled("div")` - position: absolute; - top: 5px; - right: 35px; -`; - -const ClearZoneIconButton = styled(IconButton)` - position: absolute; - top: 0; - right: 10px; - cursor: pointer; - height: 100%; - display: flex; - align-items: center; - - &:hover { - color: ${({ theme }) => theme.col.red}; - } -`; +const root = tv({ base: "relative" }); + +const input = tv({ + base: [ + "outline-0", + "w-full", + "border border-gray-400", + "rounded", + "py-[6px] pr-[30px] pl-[10px]", + "text-sm", + "font-normal", + ], + variants: { + large: { true: "py-[10px] pr-[30px] pl-[14px] text-xl" }, + disabled: { true: "opacity-50" }, + }, +}); + +const greenIcon = tv({ + base: ["absolute top-2 right-[35px]", "opacity-80", "text-green"], +}); + +const redIcon = tv({ base: ["opacity-80", "text-red"] }); + +const absoluteWrap = tv({ base: "absolute top-[5px] right-[35px]" }); + +const clearZoneIconButton = tv({ + base: [ + "absolute top-0 right-[10px]", + "h-full", + "flex items-center", + "cursor-pointer", + "hover:text-red", + ], +}); interface InputProps { autoFocus?: boolean; @@ -160,7 +145,7 @@ const BaseInput = ({ const isCurrencyInput = money && !!currencyConfig; return ( - +
{isCurrencyInput ? ( ) : ( - - {valid && !invalid && } + {valid && !invalid && ( + + )} {invalid && ( - - - +
+ +
)} - )} - +
); }; diff --git a/frontend/src/js/ui-components/CurrencyInput.tsx b/frontend/src/js/ui-components/CurrencyInput.tsx index a7d54d460e..ed06ee3d29 100644 --- a/frontend/src/js/ui-components/CurrencyInput.tsx +++ b/frontend/src/js/ui-components/CurrencyInput.tsx @@ -1,22 +1,28 @@ -import styled from "@emotion/styled"; import { useEffect, useState } from "react"; -import { NumericFormat } from "react-number-format"; +import { + type InputAttributes, + NumericFormat, + type NumericFormatProps, +} from "react-number-format"; +import { tv } from "tailwind-variants"; import type { CurrencyConfigT } from "../api/types"; import { isEmpty } from "../common/helpers/commonHelper"; import { exists } from "../common/helpers/exists"; -const SxNumberFormat = styled(NumericFormat)<{ large?: boolean }>` - outline: 0; - min-width: 170px; - - border: 1px solid ${({ theme }) => theme.col.grayMediumLight}; - font-size: ${({ theme }) => theme.font.md}; - padding: ${({ large }) => - large ? "10px 30px 10px 14px" : "8px 30px 8px 10px"}; - font-size: ${({ theme, large }) => (large ? theme.font.lg : theme.font.sm)}; - border-radius: ${({ theme }) => theme.borderRadius}; -`; +const numberFormat = tv({ + base: [ + "outline-0", + "min-w-[170px]", + "border border-gray-400", + "rounded", + "py-2 pr-[30px] pl-[10px]", + "text-sm", + ], + variants: { + large: { true: "py-[10px] pr-[30px] pl-[14px] text-xl" }, + }, +}); const CurrencyInput = ({ currencyConfig, @@ -46,25 +52,27 @@ const CurrencyInput = ({ setNumberFormatValue(""); } }, [value]); - return ( - { - if (exists(values.floatValue) && !Number.isNaN(values.floatValue)) { - setNumberFormatValue(values.floatValue); - onChange(parseInt((values.floatValue * factor).toFixed(0), 10)); - } else { - setNumberFormatValue(""); - onChange(null); - } - }} - /> - ); + // typed up front with an explicit base type: the polymorphic NumericFormat + // props are otherwise too complex a union for tsc + const props: NumericFormatProps = { + ...currencyConfig, + className: numberFormat({ large }), + suffix: ` ${currencyConfig?.unit}`, + placeholder, + type: "text", + value: numberFormatValue, + onValueChange: (values) => { + if (exists(values.floatValue) && !Number.isNaN(values.floatValue)) { + setNumberFormatValue(values.floatValue); + onChange(parseInt((values.floatValue * factor).toFixed(0), 10)); + } else { + setNumberFormatValue(""); + onChange(null); + } + }, + }; + + return {...props} />; }; export default CurrencyInput; diff --git a/frontend/src/js/ui-components/Dropzone.tsx b/frontend/src/js/ui-components/Dropzone.tsx index 1523f61e1c..0c36a0c853 100644 --- a/frontend/src/js/ui-components/Dropzone.tsx +++ b/frontend/src/js/ui-components/Dropzone.tsx @@ -1,6 +1,6 @@ -import styled from "@emotion/styled"; import type { ReactNode, Ref } from "react"; import { type DropTargetMonitor, useDrop } from "react-dnd"; +import { tv } from "tailwind-variants"; import { DNDType } from "../common/constants/dndTypes"; import { exists } from "../common/helpers/exists"; @@ -12,43 +12,30 @@ import type { import type { DragItemFile } from "./DropzoneWithFileInput"; -const Root = styled("div")<{ - isOver?: boolean; - naked?: boolean; - bare?: boolean; - transparent?: boolean; - canDrop?: boolean; - invisible?: boolean; -}>` - border: ${({ theme, isOver, canDrop, naked }) => - naked - ? "none" - : isOver && !canDrop - ? `3px solid ${theme.col.red}` - : isOver - ? `3px solid ${theme.col.black}` - : `3px dashed ${theme.col.grayMediumLight}`}; - border-radius: ${({ theme }) => theme.borderRadius}; - padding: ${({ bare }) => (bare ? "0" : "10px")}; - display: ${({ invisible }) => (invisible ? "none" : "flex")}; - align-items: center; - justify-content: center; - background-color: ${({ theme, canDrop, naked, isOver, transparent }) => - naked && isOver && canDrop - ? theme.col.grayLight - : canDrop - ? theme.col.grayVeryLight - : transparent - ? "transparent" - : theme.col.bg}; - width: 100%; - color: ${({ theme, isOver, canDrop }) => - isOver && !canDrop - ? theme.col.red - : isOver - ? theme.col.black - : theme.col.gray}; -`; +const root = tv({ + base: [ + "flex items-center justify-center", + "w-full", + "p-[10px]", + "rounded", + "border-[3px] border-dashed border-gray-400", + "bg-bg-50", + "text-gray-500", + ], + variants: { + bare: { true: "p-0" }, + invisible: { true: "hidden" }, + // later wins when several are set + transparent: { true: "bg-transparent" }, + canDrop: { true: "bg-gray-50" }, + isOver: { true: "border-solid border-gray-800 text-gray-800" }, + naked: { true: "border-none" }, + }, + compoundVariants: [ + { isOver: true, canDrop: false, class: "border-red text-red" }, + { naked: true, isOver: true, canDrop: true, class: "bg-gray-100" }, + ], +}); export interface ChildArgs { isOver: boolean; @@ -129,7 +116,9 @@ const Dropzone = ({ }); return ( - { dropRef(instance); @@ -142,21 +131,23 @@ const Dropzone = ({ } } }} - isOver={isOver} - invisible={invisible && !canDropResult} - canDrop={canDropResult} - className={className} + className={root({ + isOver, + invisible: !!invisible && !canDropResult, + canDrop: canDropResult, + naked, + transparent, + bare, + className, + })} onClick={onClick} - naked={naked} - transparent={transparent} - bare={bare} > {children?.({ isOver, canDrop: canDropResult, item: item as DroppableObject, // Casting because see comment above })} - + ); }; diff --git a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx index 46d7622a86..b7785bce97 100644 --- a/frontend/src/js/ui-components/DropzoneWithFileInput.tsx +++ b/frontend/src/js/ui-components/DropzoneWithFileInput.tsx @@ -1,10 +1,9 @@ -import { css } from "@emotion/react"; -import styled from "@emotion/styled"; 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"; import { useTranslation } from "react-i18next"; +import { tv } from "tailwind-variants"; import { SelectFileButton } from "../button/SelectFileButton"; import FaIcon from "../icon/FaIcon"; @@ -20,37 +19,32 @@ export interface DragItemFile { files: File[]; } -const FileInput = styled("input")` - display: none; -`; - -const SxDropzone = styled(Dropzone)<{ isInitial?: boolean; tight?: boolean }>` - cursor: ${({ isInitial }) => (isInitial ? "initial" : "pointer")}; - transition: box-shadow ${({ theme }) => theme.transitionTime}; - position: relative; - ${({ tight }) => - tight && - css` - padding: 5px; - `} - - &:hover { - box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.2); - } -`; - -const SxSelectFileButton = styled(SelectFileButton, { - shouldForwardProp: (prop) => prop !== "outside", -})<{ outside?: boolean }>` - position: absolute; - top: ${({ outside }) => (outside ? "-26px" : "3px")}; - right: ${({ outside }) => (outside ? "-12px" : "0")}; -`; - -const SxFaIcon = styled(FaIcon)` - height: 10px; - padding-right: 3px; -`; +const dropzone = tv({ + base: [ + "relative", + "cursor-pointer", + "transition-shadow duration-100", + "hover:shadow-[0_0_5px_0_rgba(0,0,0,0.2)]", + ], + variants: { + isInitial: { true: "cursor-[initial]" }, + tight: { true: "p-[5px]" }, + }, +}); + +const selectFileButton = tv({ + base: "absolute", + variants: { + outside: { + true: "-top-[26px] -right-[12px]", + false: "top-[3px] right-0", + }, + }, +}); + +const importIcon = tv({ + base: ["h-[10px]", "pr-[3px]"], +}); interface PropsT { children: (args: ChildArgs) => ReactNode; @@ -118,8 +112,7 @@ const DropzoneWithFileInput = < } return ( - { if (disableClick) return; @@ -138,8 +131,7 @@ const DropzoneWithFileInput = < onDrop(item as DroppableObject | DragItemFile, monitor); }} - isInitial={isInitial} - className={className} + className={dropzone({ isInitial, tight, className })} ref={ref} > {(args) => ( @@ -153,16 +145,17 @@ const DropzoneWithFileInput = < /> )} {showImportButton && onImportLines && ( - setImportModalOpen(true)} > - + {t("common.import")} - + )} {onSelectFile && ( - )} )} - + ); }; diff --git a/frontend/src/js/ui-components/EditableTagsForm.tsx b/frontend/src/js/ui-components/EditableTagsForm.tsx index 0b0fa68afe..2fcdb447d7 100644 --- a/frontend/src/js/ui-components/EditableTagsForm.tsx +++ b/frontend/src/js/ui-components/EditableTagsForm.tsx @@ -1,7 +1,7 @@ -import styled from "@emotion/styled"; import { faCheck, faSpinner } from "@fortawesome/free-solid-svg-icons"; import { type FormEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { tv } from "tailwind-variants"; import type { SelectOptionT } from "../api/types"; import IconButton from "../button/IconButton"; @@ -10,20 +10,17 @@ import WithTooltip from "../tooltip/WithTooltip"; import InputMultiSelect from "./InputMultiSelect/InputMultiSelect"; -const Form = styled("form")` - display: flex; - align-items: flex-start; -`; +const form = tv({ + base: "flex items-start", +}); -const SxIconButton = styled(IconButton)` - padding: 7px 10px; - margin-left: 3px; -`; +const saveButton = tv({ + base: ["ml-[3px]", "px-[10px] py-[7px]"], +}); -const SxInputMultiSelect = styled(InputMultiSelect)` - z-index: 2; - flex-grow: 1; -`; +const multiSelect = tv({ + base: ["z-2", "grow"], +}); const EditableTagsForm = ({ className, @@ -60,8 +57,9 @@ const EditableTagsForm = ({ } return ( -
- + - - + ); }; diff --git a/frontend/src/js/ui-components/EditableText.tsx b/frontend/src/js/ui-components/EditableText.tsx index 815dcd647f..88e02c84dc 100644 --- a/frontend/src/js/ui-components/EditableText.tsx +++ b/frontend/src/js/ui-components/EditableText.tsx @@ -1,5 +1,6 @@ -import styled from "@emotion/styled"; import { faPen } from "@fortawesome/free-solid-svg-icons"; +import { tv } from "tailwind-variants"; + import IconButton from "../button/IconButton"; import { Highlighter } from "../common/components/Highlighter"; import HighlightableLabel from "../highlightable-label/HighlightableLabel"; @@ -7,28 +8,29 @@ import WithTooltip from "../tooltip/WithTooltip"; import EditableTextForm from "./EditableTextForm"; -const SxIconButton = styled(IconButton)` - margin-right: ${({ large }) => (large ? "10px" : "8px")}; - padding: 2px 0; -`; +const editButton = tv({ + base: ["px-0 py-[2px]"], + variants: { + large: { + true: "mr-[10px]", + false: "mr-2", + }, + }, +}); -const Text = styled("div")` - display: flex; - flex-direction: row; - align-items: center; -`; +const text = tv({ + base: "flex flex-row items-center", +}); -const SxHighlightableLabel = styled(HighlightableLabel)` - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; -`; +const label = tv({ + base: ["overflow-hidden", "text-ellipsis", "whitespace-nowrap"], +}); const EditableText = ({ className, loading, editing, - text, + text: textValue, tooltip, large, saveOnClickoutside, @@ -55,16 +57,17 @@ const EditableText = ({ ) : ( - +
- - + {highlightedWords && highlightedWords.length > 0 ? ( - + ) : ( - text + textValue )} - - + +
); }; diff --git a/frontend/src/js/ui-components/EditableTextForm.tsx b/frontend/src/js/ui-components/EditableTextForm.tsx index 5967e9a6fd..f03afe76a6 100644 --- a/frontend/src/js/ui-components/EditableTextForm.tsx +++ b/frontend/src/js/ui-components/EditableTextForm.tsx @@ -1,29 +1,23 @@ -import styled from "@emotion/styled"; import { faCheck, faSpinner } from "@fortawesome/free-solid-svg-icons"; import { type FormEvent, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { tv } from "tailwind-variants"; import IconButton from "../button/IconButton"; import { useClickOutside } from "../common/helpers/useClickOutside"; import WithTooltip from "../tooltip/WithTooltip"; -const Input = styled("input")` - font-size: ${({ theme }) => theme.font.sm}; - padding: 0 8px; - height: 28px; - border: 1px solid ${({ theme }) => theme.col.gray}; - border-radius: ${({ theme }) => theme.borderRadius}; -`; +const input = tv({ + base: ["h-[28px]", "px-2", "rounded", "border border-gray-500", "text-sm"], +}); -const Form = styled("form")` - display: flex; - align-items: center; -`; +const form = tv({ + base: "flex items-center", +}); -const SxIconButton = styled(IconButton)` - padding: 6px 10px; - margin-left: 3px; -`; +const saveButton = tv({ + base: ["ml-[3px]", "px-[10px] py-[6px]"], +}); const EditableTextForm = ({ className, @@ -56,8 +50,9 @@ const EditableTextForm = ({ useClickOutside(ref, saveOnClickoutside ? () => onSubmit(value) : onCancel); return ( -
- + setValue(e.target.value)} @@ -73,7 +68,8 @@ const EditableTextForm = ({ /> {!saveOnClickoutside && ( - )} -
+ ); }; diff --git a/frontend/src/js/ui-components/ImportModal.tsx b/frontend/src/js/ui-components/ImportModal.tsx index f53fdccc2d..4a14a578d5 100644 --- a/frontend/src/js/ui-components/ImportModal.tsx +++ b/frontend/src/js/ui-components/ImportModal.tsx @@ -1,4 +1,3 @@ -import styled from "@emotion/styled"; import { faFile, faPaste } from "@fortawesome/free-solid-svg-icons"; import { type ChangeEvent, @@ -10,6 +9,7 @@ import { 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"; @@ -20,32 +20,21 @@ import DropzoneWithFileInput, { type DragItemFile, } from "./DropzoneWithFileInput"; -const Content = styled("div")` - display: flex; - flex-direction: column; - gap: 20px; -`; - -const Row = styled("div")` - display: flex; - justify-content: flex-end; - align-items: center; - gap: 10px; -`; - -const Textarea = styled("textarea")` - font-family: monospace; - width: 100%; -`; - -const HiddenFileInput = styled("input")` - display: none; -`; - -const Subtitle = styled(`p`)` - margin: 0; - max-width: 600px; -`; +const content = tv({ + base: ["flex flex-col", "gap-5"], +}); + +const row = tv({ + base: ["flex items-center justify-end", "gap-[10px]"], +}); + +const textarea = tv({ + base: ["font-mono", "w-full"], +}); + +const subtitle = tv({ + base: ["m-0", "max-w-[600px]"], +}); const acceptedDropTypes = [NativeTypes.FILE]; @@ -161,10 +150,13 @@ export const ImportModal = ({ subtitle={t("importModal.subtitle")} onClose={onClose} > - +
{description && ( - // biome-ignore lint/security/noDangerouslySetInnerHtml: description is our own i18n text - +

)} {() => ( -