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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide": "^0.545.0", "lucide": "^0.545.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",
@ -2345,6 +2346,15 @@
"node": ">=18" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"clsx": "^2.1.1",
"lucide": "^0.545.0", "lucide": "^0.545.0",
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"react": "^19.1.1", "react": "^19.1.1",

View file

@ -48,11 +48,6 @@
} }
/* @todo replace by components! /* @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 */ /* groups */
.button-group { .button-group {
@apply flex gap-4 mt-8; @apply flex gap-4 mt-8;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,21 @@
import {useState} from "react"; import {useState} from "react";
import {Search, X} from "lucide-react"; import {Search, X} from "lucide-react";
import {defaultIconSize} from "./SvgIcon"; import {defaultIconSize} from "./SvgIcon";
import clsx from "clsx";
/** /**
* Custom search field component including a clear search functionality * Custom search field component including a clear search functionality
*/ */
type SearchFieldProps = { type SearchFieldProps = {
onSearchStringChanged: (searchString: string) => void onSearchStringChanged: (searchString: string) => void
className?: string
} }
/** /**
* @param SearchFieldProps consisting of an initial searchString and an onSearchStringChanged handler * @param SearchFieldProps consisting of an initial searchString and an onSearchStringChanged handler
* @returns Custom search field component * @returns Custom search field component
*/ */
export default function SearchField({onSearchStringChanged}: SearchFieldProps) { export default function SearchField({onSearchStringChanged, className = ""}: SearchFieldProps) {
const [currentSearchString, setCurrentSearchString] = useState<string>("") const [currentSearchString, setCurrentSearchString] = useState<string>("")
const changeSearchString = (newSearchString: 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"; const iconStyle: string = "text-gray-400 hover:text-gray-500";
return ( return (
<div className="relative"> <div className={clsx(
"relative",
className)}>
{/* Input of searchfield {/* Input of searchfield
Defines border and behavior. Requires extra padding at both sides to Defines border and behavior. Requires extra padding at both sides to
accommodate the icons accommodate the icons
@ -41,7 +45,9 @@ export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
Clears search string on click Clears search string on click
*/} */}
<button <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("")} onClick={() => changeSearchString("")}
> >
<X <X
@ -49,7 +55,9 @@ export default function SearchField({onSearchStringChanged}: SearchFieldProps) {
/> />
</button> </button>
{/* Left icon: Looking glass */} {/* 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 <Search
size={defaultIconSize} 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 {NumberStepControl} from "../basics/NumberStepControl.tsx";
import {NumberedListItem} from "../basics/NumberedListItem.tsx"; import {NumberedListItem} from "../basics/NumberedListItem.tsx";
import {ButtonType} from "../basics/BasicButtonDefinitions.ts"; 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 */} {/* Container defining the maximum width of the content */}
<div className="content-bg"> <div className="content-bg">
{/* Header - remains in position when scrolling */} {/* Header - remains in position when scrolling */}
<div className="sticky-header"> <StickyHeader>
<h1>{recipeWorkingCopy.title}</h1> <h1>{recipeWorkingCopy.title}</h1>
</div> </StickyHeader>
{/* Content */} {/* Content */}
<div className="content-container"> <div className="content-container">

View file

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