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/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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
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 {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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue