Add StickyHeader Component and introduce clsx for merging tailwind classNames

This commit is contained in:
araemer 2025-10-25 18:10:56 +02:00
parent 7ffda11a34
commit db23d06fb2
14 changed files with 115 additions and 24 deletions

View file

@ -11,6 +11,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide": "^0.545.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
@ -2345,6 +2346,15 @@
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View file

@ -13,6 +13,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide": "^0.545.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",

View file

@ -48,11 +48,6 @@
}
/* @todo replace by components!
/* headings */
.sticky-header {
@apply sticky bg-gray-100 top-0 left-0 right-0 pb-6 border-b-2 border-gray-300;
}
/* groups */
.button-group {
@apply flex gap-4 mt-8;

View file

@ -1,5 +1,6 @@
import {defaultIconSize} from "./SvgIcon";
import {type BasicButtonProps, basicButtonStyle, ButtonType} from "./BasicButtonDefinitions";
import clsx from "clsx";
type ButtonProps = BasicButtonProps & {
onClick: () => void;
@ -20,7 +21,12 @@ export default function Button({
}: ButtonProps) {
return (
<button
className={`${basicButtonStyle} ${buttonType.backgroundColor} ${buttonType.textColor} ${className}`}
className={clsx(
basicButtonStyle,
buttonType.backgroundColor,
buttonType.textColor,
className
)}
onClick={onClick}
disabled={disabled}
{...props}

View file

@ -1,6 +1,7 @@
import {Link, type LinkProps} from "react-router-dom"
import {defaultIconSize} from "./SvgIcon"
import {type BasicButtonProps, basicButtonStyle, ButtonType} from "./BasicButtonDefinitions"
import clsx from "clsx";
type ButtonLinkProps = LinkProps & BasicButtonProps
& {
@ -21,7 +22,12 @@ export default function ButtonLink({
return (
<Link
to={to}
className={`${basicButtonStyle} ${buttonType.backgroundColor} ${buttonType.textColor} ${className}`}
className={clsx(
basicButtonStyle,
buttonType.backgroundColor,
buttonType.textColor,
className
)}
{...props}
>
<div className="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
import {defaultIconSize} from "./SvgIcon";
import type {LucideIcon} from "lucide-react";
import {ButtonType} from "./BasicButtonDefinitions.ts";
import clsx from "clsx";
type CircularIconButtonProps = {
icon: LucideIcon;
@ -21,8 +22,10 @@ export default function CircularIconButton({
}: CircularIconButtonProps) {
return (
<button
className={`flex-shrink-0 w-7 h-7 rounded-full text-white flex items-center justify-center shadow-sm
${ButtonType.PrimaryButton.backgroundColor} ${className}`}
className={clsx(
"flex-shrink-0 w-7 h-7 rounded-full text-white flex items-center justify-center shadow-sm",
ButtonType.PrimaryButton.backgroundColor,
className)}
onClick={onClick}
disabled={disabled}
{...props}

View file

@ -1,12 +1,14 @@
import Button from "./Button.tsx";
import {ArrowDown, ArrowUp} from "lucide-react";
import {ButtonType} from "./BasicButtonDefinitions.ts";
import clsx from "clsx";
type MoveButtonControlProps = {
isUpDisabled: boolean;
isDownDisabled: boolean;
onMoveUp(): void;
onMoveDown(): void;
className?: string;
}
/**
@ -15,10 +17,20 @@ type MoveButtonControlProps = {
* @param isDownDisabled Indicates whether the down button is enabled
* @param onMoveUp Method to call when move up is clicked
* @param onMoveDown Method to call when move down is clicked
* @param className Custom Style information
*/
export function MoveButtonControl({isUpDisabled, isDownDisabled, onMoveUp, onMoveDown}: MoveButtonControlProps) {
export function MoveButtonControl({
isUpDisabled,
isDownDisabled,
onMoveUp,
onMoveDown,
className = ""
}: MoveButtonControlProps) {
return (
<div className="flex flex-col mt-2">
<div className={clsx(
"flex flex-col mt-2",
className
)}>
<Button
icon={ArrowUp}
onClick={() => onMoveUp()}

View file

@ -1,5 +1,6 @@
import {Minus, Plus} from "lucide-react";
import CircularIconButton from "./CircularIconButton.tsx";
import clsx from "clsx";
type NumberStepControlProps = {
/** Current numeric value */
@ -44,7 +45,9 @@ export function NumberStepControl({
};
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={clsx(
"flex items-center gap-2",
className)}>
<CircularIconButton
onClick={handleDecrease}
icon={Minus}

View file

@ -1,8 +1,11 @@
import clsx from "clsx";
type NumberedListItemProps = {
/** Index of the element. Index + 1 is displayed in circle */
index: number;
/** List item text */
text: string;
className?: string;
}
/**
@ -10,8 +13,10 @@ type NumberedListItemProps = {
* @param elementNumber Number to be displayed
* @param text Text to be displayed
*/
export function NumberedListItem({index, text}: NumberedListItemProps) {
return <li className="flex items-start gap-4">
export function NumberedListItem({index, text, className = ""}: NumberedListItemProps) {
return <li className={clsx(
"flex items-start gap-4",
className)}>
{/* Step number circle */}
<div className="circular-container">
{index + 1}

View file

@ -1,16 +1,18 @@
import {Eye, EyeOff} from "lucide-react";
import {useState} from "react";
import {defaultIconSize} from "./SvgIcon";
import clsx from "clsx";
type PasswordFieldProps = {
onPasswordChanged: (password: string) => void
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
className?: string
}
/**
* Password field component
*/
export default function PasswordField({onPasswordChanged, onKeyDown}: PasswordFieldProps) {
export default function PasswordField({onPasswordChanged, onKeyDown, className = ""}: PasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false);
const [password, setPassword] = useState<string>("");
@ -19,7 +21,9 @@ export default function PasswordField({onPasswordChanged, onKeyDown}: PasswordFi
onPasswordChanged(password)
}
return (
<div className="relative">
<div className={clsx(
"relative",
className)}>
<input
className="pr-10"
type={showPassword ? "text" : "password"}

View file

@ -1,19 +1,21 @@
import {useState} from "react";
import {Search, X} from "lucide-react";
import {defaultIconSize} from "./SvgIcon";
import clsx from "clsx";
/**
* Custom search field component including a clear search functionality
*/
type SearchFieldProps = {
onSearchStringChanged: (searchString: string) => void
className?: string
}
/**
* @param SearchFieldProps consisting of an initial searchString and an onSearchStringChanged handler
* @returns Custom search field component
*/
export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
export default function SearchField({onSearchStringChanged, className = ""}: SearchFieldProps) {
const [currentSearchString, setCurrentSearchString] = useState<string>("")
const changeSearchString = (newSearchString: string) => {
@ -25,7 +27,9 @@ export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
const iconStyle: string = "text-gray-400 hover:text-gray-500";
return (
<div className="relative">
<div className={clsx(
"relative",
className)}>
{/* Input of searchfield
Defines border and behavior. Requires extra padding at both sides to
accommodate the icons
@ -41,7 +45,9 @@ export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
Clears search string on click
*/}
<button
className={`absolute right-0 inset-y-0 flex items-center -ml-1 mr-3 ${iconStyle}`}
className={clsx(
"absolute right-0 inset-y-0 flex items-center -ml-1 mr-3",
iconStyle)}
onClick={() => changeSearchString("")}
>
<X
@ -49,7 +55,9 @@ export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
/>
</button>
{/* Left icon: Looking glass */}
<div className={`absolute left-0 inset-y-0 flex items-center ml-3 ${iconStyle}`}>
<div className={clsx(
"absolute left-0 inset-y-0 flex items-center ml-3",
iconStyle)}>
<Search
size={defaultIconSize}
/>

View file

@ -0,0 +1,36 @@
import type {ReactNode} from "react";
import clsx from "clsx";
/**
* A reusable sticky header component that stays fixed at the top
* of its parent container. You can pass arbitrary children such as
* headings, toolbars, or filters.
*
* Example:
* ```tsx
* <StickyHeader className="mt-4">
* <h1>Recipes</h1>
* <RecipeListToolbar ... />
* </StickyHeader>
* ```
*/
type StickyHeaderProps = {
/** Content to render inside the header */
children: ReactNode;
/** Optional additional Tailwind classes */
className?: string;
};
export default function StickyHeader({children, className = ""}: StickyHeaderProps) {
return (
<div
className={clsx(
"sticky top-0 left-0 right-0 bg-gray-100 pb-6 border-b-2 border-gray-300 z-10",
className
)}
>
{children}
</div>
);
}

View file

@ -8,6 +8,7 @@ import {mapRecipeDtoToModel} from "../../mappers/RecipeMapper"
import {NumberStepControl} from "../basics/NumberStepControl.tsx";
import {NumberedListItem} from "../basics/NumberedListItem.tsx";
import {ButtonType} from "../basics/BasicButtonDefinitions.ts";
import StickyHeader from "../basics/StickyHeader.tsx";
/**
@ -81,9 +82,9 @@ export default function RecipeDetailPage() {
{/* Container defining the maximum width of the content */}
<div className="content-bg">
{/* Header - remains in position when scrolling */}
<div className="sticky-header">
<StickyHeader>
<h1>{recipeWorkingCopy.title}</h1>
</div>
</StickyHeader>
{/* Content */}
<div className="content-container">

View file

@ -5,6 +5,7 @@ import {fetchRecipeList} from "../../api/points/CompactRecipePoint"
import {useNavigate} from "react-router-dom"
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
import RecipeListToolbar from "./RecipeListToolbar"
import StickyHeader from "../basics/StickyHeader.tsx";
/**
* Displays a list of recipes in a sidebar layout.
@ -45,14 +46,14 @@ export default function RecipeListPage() {
{/* Container defining the maximum width of the content */}
<div className="content-bg">
{/* Header - remains in position when scrolling */}
<div className="sticky-header">
<StickyHeader>
<h1>Recipes</h1>
<RecipeListToolbar
onAddClicked={handleAdd}
onSearchStringChanged={setSearchString}
numberOfRecipes={recipeList.length}
/>
</div>
</StickyHeader>
{/*Content - List of recipe cards */}
<div className="w-full pt-4">
<div