Add StickyHeader Component and introduce clsx for merging tailwind classNames
This commit is contained in:
parent
7ffda11a34
commit
db23d06fb2
14 changed files with 115 additions and 24 deletions
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
36
frontend/src/components/basics/StickyHeader.tsx
Normal file
36
frontend/src/components/basics/StickyHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue