Start time grid #62
@ -17,4 +17,7 @@ spring.security.oauth2.client:
|
||||
|
||||
# Lift the restrictions imposed by __Host- prefix during development
|
||||
# Ideally we keep it on, but it breaks in Chromium on Linux
|
||||
server.servlet.session.cookie.name: studentportalen-bff-session
|
||||
server.servlet.session.cookie:
|
||||
name: studentportalen-bff-session
|
||||
# Disable secure flag for HTTP development - Safari strictly enforces this
|
||||
secure: false
|
||||
|
||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@ -12,7 +12,8 @@
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.4.1"
|
||||
"react-router": "^7.4.1",
|
||||
"temporal-polyfill": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
@ -3726,6 +3727,21 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/temporal-polyfill": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.0.tgz",
|
||||
"integrity": "sha512-qNsTkX9K8hi+FHDfHmf22e/OGuXmfBm9RqNismxBrnSmZVJKegQ+HYYXT+R7Ha8F/YSm2Y34vmzD4cxMu2u95g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"temporal-spec": "0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/temporal-spec": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.0.tgz",
|
||||
"integrity": "sha512-n+noVpIqz4hYgFSMOSiINNOUOMFtV5cZQNCmmszA6GiVFVRt3G7AqVyhXjhCSmowvQn+NsGn+jMDMKJYHd3bSQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.4.1"
|
||||
"react-router": "^7.4.1",
|
||||
"temporal-polyfill": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router";
|
||||
import ComponentLibrary from "./studentportalen/ComponentLibrary.tsx";
|
||||
import ComponentLibrary from "./studentportalen/ComponentLibrary/ComponentLibrary";
|
||||
import Layout from "./studentportalen/Layout.tsx";
|
||||
|
||||
export default function Studentportalen() {
|
||||
|
||||
93
frontend/src/components/Choicebox/Choicebox.tsx
Normal file
93
frontend/src/components/Choicebox/Choicebox.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export interface ChoiceboxProps
|
||||
extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
primaryText: string;
|
||||
secondaryText?: string;
|
||||
unavailable?: boolean;
|
||||
fitContent?: boolean;
|
||||
}
|
||||
|
||||
const baseClasses = clsx(
|
||||
"flex items-center gap-(--spacing-md)",
|
||||
"border-[length:var(--border-width-sm)]",
|
||||
"rounded-(--border-radius-md)",
|
||||
"p-(--padding-md)",
|
||||
);
|
||||
|
||||
const availableClasses = clsx(
|
||||
"bg-sky-20 border-sky-100",
|
||||
"cursor-pointer",
|
||||
// Hover
|
||||
"hover:bg-sky-35",
|
||||
// Focus
|
||||
"focus-within:bg-sky-20 focus-within:border-primary",
|
||||
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||
// Selected
|
||||
"has-[:checked]:bg-sky-70 has-[:checked]:border-secondary",
|
||||
// Selected + Focus
|
||||
"has-[:checked]:focus-within:bg-sky-35",
|
||||
);
|
||||
|
||||
const unavailableClasses = clsx(
|
||||
"bg-base-canvas border-base-ink-soft",
|
||||
"cursor-not-allowed",
|
||||
);
|
||||
|
||||
const radioClasses = clsx(
|
||||
"appearance-none shrink-0",
|
||||
"w-[var(--font-size-body-md)] h-[var(--font-size-body-md)]",
|
||||
"rounded-full",
|
||||
"border-[length:var(--border-width-sm)] border-base-ink-medium",
|
||||
"bg-base-canvas",
|
||||
"outline-none",
|
||||
// Selected
|
||||
"checked:border-primary",
|
||||
"checked:bg-[radial-gradient(circle,var(--color-primary)_50%,var(--color-base-canvas)_50%)]",
|
||||
);
|
||||
|
||||
export default function Choicebox({
|
||||
primaryText,
|
||||
secondaryText,
|
||||
unavailable = false,
|
||||
fitContent = false,
|
||||
className = "",
|
||||
disabled,
|
||||
...props
|
||||
}: ChoiceboxProps) {
|
||||
const isDisabled = unavailable || disabled;
|
||||
const textColorClass = unavailable
|
||||
? "text-base-ink-placeholder"
|
||||
: "text-primary";
|
||||
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
unavailable ? unavailableClasses : availableClasses,
|
||||
fitContent ? "w-fit" : "w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className={clsx("body-bold-lg break-words", textColorClass)}>
|
||||
{primaryText}
|
||||
</span>
|
||||
{secondaryText && (
|
||||
<span className={clsx("body-light-sm break-words", textColorClass)}>
|
||||
{secondaryText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!unavailable && (
|
||||
<input
|
||||
type="radio"
|
||||
className={radioClasses}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@ -25,6 +25,7 @@ export interface ComboboxProps<T> {
|
||||
onChange?: (value: string | string[]) => void;
|
||||
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||
onSearchChange?: (term: string) => void;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const widthClasses: Record<ComboboxSize, string> = {
|
||||
@ -77,6 +78,7 @@ export default function Combobox<T>({
|
||||
value,
|
||||
onChange,
|
||||
onSearchChange,
|
||||
error = false,
|
||||
}: ComboboxProps<T>) {
|
||||
// Convert value (undefined | string | string[]) to always be an array
|
||||
const selectedValues: string[] =
|
||||
@ -226,6 +228,7 @@ export default function Combobox<T>({
|
||||
fullWidth={fullWidth || !!customWidth}
|
||||
customWidth={customWidth}
|
||||
Icon={SearchIcon}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
|
||||
217
frontend/src/components/Dropdown/Dropdown.tsx
Normal file
217
frontend/src/components/Dropdown/Dropdown.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useId, type SelectHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDownIcon } from "../Icon/Icon";
|
||||
|
||||
export type DropdownSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface DropdownProps<T>
|
||||
extends Omit<
|
||||
SelectHTMLAttributes<HTMLSelectElement>,
|
||||
"size" | "onChange" | "value" | "defaultValue"
|
||||
> {
|
||||
options: T[];
|
||||
getOptionValue: (option: T) => string;
|
||||
getOptionLabel: (option: T) => string;
|
||||
size?: DropdownSize;
|
||||
error?: boolean;
|
||||
fullWidth?: boolean;
|
||||
customWidth?: string;
|
||||
label: string;
|
||||
hideLabel?: boolean;
|
||||
message?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string, option: T | undefined) => void;
|
||||
}
|
||||
|
||||
const wrapperSizeClasses: Record<DropdownSize, string> = {
|
||||
sm: clsx("h-(--control-height-sm)", "rounded-(--border-radius-sm)"),
|
||||
md: clsx("h-(--control-height-md)", "rounded-(--border-radius-sm)"),
|
||||
lg: clsx("h-(--control-height-lg)", "rounded-(--border-radius-md)"),
|
||||
};
|
||||
|
||||
const textSizeClasses: Record<DropdownSize, string> = {
|
||||
sm: "body-normal-md",
|
||||
md: "body-normal-md",
|
||||
lg: "body-normal-lg",
|
||||
};
|
||||
|
||||
const iconContainerSizeClasses: Record<DropdownSize, string> = {
|
||||
sm: clsx("w-(--control-height-sm) h-(--control-height-sm)"),
|
||||
md: clsx("w-(--control-height-md) h-(--control-height-md)"),
|
||||
lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"),
|
||||
};
|
||||
|
||||
// Right padding to reserve space for the chevron icon
|
||||
const chevronSpacingClasses: Record<DropdownSize, string> = {
|
||||
sm: "pr-(--control-height-sm)",
|
||||
md: "pr-(--control-height-md)",
|
||||
lg: "pr-(--control-height-lg)",
|
||||
};
|
||||
|
||||
const baseClasses =
|
||||
"relative inline-flex items-center bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium min-w-[110px]";
|
||||
|
||||
const defaultStateClasses = clsx(
|
||||
"hover:border-base-ink-placeholder",
|
||||
"focus-within:border-primary",
|
||||
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||
);
|
||||
|
||||
const errorStateClasses = clsx(
|
||||
"border-fire-100",
|
||||
"outline outline-fire-100 outline-[length:var(--border-width-sm)]",
|
||||
"focus-within:border-primary",
|
||||
"focus-within:outline focus-within:outline-sky-100 focus-within:outline-[length:var(--border-width-lg)]",
|
||||
);
|
||||
|
||||
export default function Dropdown<T>({
|
||||
options,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
size = "md",
|
||||
error = false,
|
||||
fullWidth = false,
|
||||
customWidth,
|
||||
label,
|
||||
hideLabel = false,
|
||||
message,
|
||||
placeholder,
|
||||
className = "",
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: DropdownProps<T>) {
|
||||
const selectId = useId();
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const option = options.find((o) => getOptionValue(o) === newValue);
|
||||
onChange(newValue, option);
|
||||
};
|
||||
|
||||
// Derived values
|
||||
const hasValue = value !== "";
|
||||
const useFixedWidth = fullWidth || customWidth;
|
||||
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||
const showVisibleLabel = !hideLabel;
|
||||
|
||||
// Find longest label for auto-width sizing, otherwise the dropdown will
|
||||
// resize when different options are selected
|
||||
const allLabels = [
|
||||
placeholder,
|
||||
...options.map((o) => getOptionLabel(o)),
|
||||
].filter(Boolean);
|
||||
const longestLabel = allLabels.reduce(
|
||||
(a, b) => (a!.length > b!.length ? a : b),
|
||||
"",
|
||||
);
|
||||
|
||||
// Invisible element that sets minimum width based on longest option.
|
||||
// Includes right padding to account for the absolutely positioned chevron.
|
||||
// A non-breaking space is appended to compensate for native select text
|
||||
// rendering differences.
|
||||
const autoWidthSizer = !useFixedWidth && (
|
||||
<span
|
||||
className={clsx(
|
||||
"invisible pl-(--padding-md)",
|
||||
chevronSpacingClasses[size],
|
||||
textSizeClasses[size],
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{longestLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Dropdown arrow icon - absolutely positioned on the right
|
||||
const chevron = (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute right-0 top-0 flex items-center justify-center text-base-ink-placeholder pointer-events-none",
|
||||
iconContainerSizeClasses[size],
|
||||
)}
|
||||
>
|
||||
<ChevronDownIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Native select with appearance-none for custom styling while retaining
|
||||
// native browser behavior and mobile OS pickers.
|
||||
// Read here for more details:
|
||||
// https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Advanced_form_styling#selects_and_datalists
|
||||
// New styling techniques are not widely supported yet:
|
||||
// https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Customizable_select
|
||||
const selectElement = (
|
||||
<select
|
||||
id={selectId}
|
||||
aria-label={hideLabel ? label : undefined}
|
||||
className={clsx(
|
||||
"appearance-none bg-transparent cursor-pointer outline-none",
|
||||
"absolute inset-0 pl-(--padding-md)",
|
||||
chevronSpacingClasses[size],
|
||||
textSizeClasses[size],
|
||||
hasValue ? "text-base-ink-strong" : "text-base-ink-placeholder",
|
||||
)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={getOptionValue(o)} value={getOptionValue(o)}>
|
||||
{getOptionLabel(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
// The styled dropdown control
|
||||
const dropdownField = (
|
||||
<div
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
wrapperSizeClasses[size],
|
||||
stateClasses,
|
||||
fullWidth && "w-full",
|
||||
!fullWidth && !customWidth && "w-fit",
|
||||
className,
|
||||
)}
|
||||
style={widthStyle}
|
||||
>
|
||||
{autoWidthSizer}
|
||||
{chevron}
|
||||
{selectElement}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showVisibleLabel && !message) {
|
||||
return dropdownField;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-(--spacing-sm)",
|
||||
fullWidth && "w-full",
|
||||
!fullWidth && !customWidth && "w-fit",
|
||||
)}
|
||||
style={widthStyle}
|
||||
>
|
||||
{showVisibleLabel && (
|
||||
<label htmlFor={selectId} className="body-bold-md text-base-ink-strong">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{dropdownField}
|
||||
{message && (
|
||||
<span className="body-light-sm text-base-ink-strong">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -94,3 +94,19 @@ export function CheckmarkIcon({
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownIcon({
|
||||
size = "inherit",
|
||||
className,
|
||||
...props
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(iconSizeClasses[size], className)}
|
||||
{...baseSvgProps}
|
||||
{...props}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
83
frontend/src/components/InlineModal/InlineModal.tsx
Normal file
83
frontend/src/components/InlineModal/InlineModal.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
export type ArrowPosition = "left" | "right";
|
||||
|
||||
export interface InlineModalProps extends HTMLAttributes<HTMLDivElement> {
|
||||
arrowPosition?: ArrowPosition;
|
||||
arrowOffset?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const baseClasses = clsx(
|
||||
"relative",
|
||||
"w-full max-w-[450px] md:max-w-none",
|
||||
"bg-sky-20",
|
||||
"border-[length:var(--border-width-sm)] border-sky-100",
|
||||
"rounded-(--border-radius-lg)",
|
||||
"p-(--padding-lg)",
|
||||
);
|
||||
|
||||
const contentClasses = clsx("flex flex-col", "gap-(--spacing-lg)");
|
||||
|
||||
// Position arrow so its center is 48px from the edge
|
||||
// Arrow is 20px wide, so offset is 48 - 10 = 38px
|
||||
const arrowPositionClasses: Record<ArrowPosition, string> = {
|
||||
left: "left-[38px]",
|
||||
right: "right-[38px]",
|
||||
};
|
||||
|
||||
/**
|
||||
|
stne3960 marked this conversation as resolved
Outdated
|
||||
* A panel with an arrow pointing to a related element above.
|
||||
*
|
||||
* Despite the name, this is not a modal, it doesn't block interaction or overlay
|
||||
* the page. It is used to show contextual content (like a form) tied to a specific
|
||||
* trigger element while keeping surrounding context visible.
|
||||
*
|
||||
* - vs Modal dialog: This keeps surrounding context visible, use a modal when you
|
||||
* want to block interaction.
|
||||
* - vs plain div: This adds the arrow indicator and callout styling.
|
||||
*/
|
||||
export default function InlineModal({
|
||||
|
stne3960 marked this conversation as resolved
ansv7779
commented
Everything else is a SVG icon, why not this too? Using a CSS border trick feels like a relic from the past. Everything else is a SVG icon, why not this too? Using a CSS border trick feels like a relic from the past.
|
||||
arrowPosition = "left",
|
||||
arrowOffset,
|
||||
children,
|
||||
className = "",
|
||||
...props
|
||||
}: InlineModalProps) {
|
||||
const useCustomOffset = arrowOffset !== undefined;
|
||||
|
||||
return (
|
||||
<div className={clsx(baseClasses, className)} {...props}>
|
||||
{/* Arrow pointing up */}
|
||||
<svg
|
||||
className={clsx(
|
||||
"absolute -top-[11px]",
|
||||
!useCustomOffset && arrowPositionClasses[arrowPosition],
|
||||
)}
|
||||
style={useCustomOffset ? { left: arrowOffset } : undefined}
|
||||
width="20"
|
||||
height="11"
|
||||
viewBox="0 0 20 11"
|
||||
fill="none"
|
||||
>
|
||||
{/* Fill triangle - extends below viewBox to cover any artifacts */}
|
||||
<path d="M0 12L10 1L20 12H0Z" fill="var(--color-sky-20)" />
|
||||
{/* Stroke only on diagonal edges - stops before bottom */}
|
||||
<path
|
||||
d="M0.5 10.5L10 1L19.5 10.5"
|
||||
stroke="var(--color-sky-100)"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Content */}
|
||||
<div className={contentClasses}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineModalDivider() {
|
||||
return <hr className="border-t border-sky-100 m-0" />;
|
||||
}
|
||||
@ -20,6 +20,8 @@ export interface ParticipantPickerProps<T>
|
||||
customWidth?: string;
|
||||
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||
onSearchChange?: (term: string) => void;
|
||||
error?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const widthClasses: Record<ComboboxSize, string> = {
|
||||
@ -43,6 +45,8 @@ export default function ParticipantPicker<T>({
|
||||
fullWidth = false,
|
||||
customWidth,
|
||||
onSearchChange,
|
||||
error = false,
|
||||
message,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
@ -80,6 +84,7 @@ export default function ParticipantPicker<T>({
|
||||
fullWidth
|
||||
multiple
|
||||
onSearchChange={onSearchChange}
|
||||
error={error}
|
||||
/>
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="flex flex-col gap-(--spacing-sm)">
|
||||
@ -93,6 +98,9 @@ export default function ParticipantPicker<T>({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<span className="body-light-sm text-base-ink-strong">{message}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
frontend/src/components/StartTimeGrid/BookingForm.tsx
Normal file
176
frontend/src/components/StartTimeGrid/BookingForm.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { InlineModalDivider } from "../InlineModal/InlineModal";
|
||||
import TextInput from "../TextInput/TextInput";
|
||||
import ParticipantPicker from "../ParticipantPicker/ParticipantPicker";
|
||||
import Dropdown from "../Dropdown/Dropdown";
|
||||
import Button from "../Button/Button";
|
||||
import type { EndTimeOption } from "./useGroupRoomBooking";
|
||||
|
||||
export interface BookingFormData {
|
||||
endTime: string;
|
||||
title: string;
|
||||
participants: string[];
|
||||
}
|
||||
|
||||
export interface BookingFormProps<T> {
|
||||
endTimeOptions: EndTimeOption[];
|
||||
participantOptions: T[];
|
||||
getOptionValue: (option: T) => string;
|
||||
getOptionLabel: (option: T) => string;
|
||||
getOptionSubtitle?: (option: T) => string | undefined;
|
||||
roomName?: string;
|
||||
/** Minimum number of participants required for a booking */
|
||||
minimumParticipants: number;
|
||||
onSubmit: (data: BookingFormData) => void;
|
||||
onCancel: () => void;
|
||||
/** Shows loading state and disables form interactions */
|
||||
loading?: boolean;
|
||||
/** General API error message to display */
|
||||
error?: string;
|
||||
/** Field-level API errors from server validation */
|
||||
fieldErrors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function BookingForm<T>({
|
||||
endTimeOptions,
|
||||
participantOptions,
|
||||
getOptionValue,
|
||||
getOptionLabel,
|
||||
getOptionSubtitle,
|
||||
roomName,
|
||||
minimumParticipants,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
error,
|
||||
fieldErrors = {},
|
||||
}: BookingFormProps<T>) {
|
||||
const [endTime, setEndTime] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [participants, setParticipants] = useState<string[]>([]);
|
||||
const [localErrors, setLocalErrors] = useState({
|
||||
endTime: false,
|
||||
title: false,
|
||||
participants: false,
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newErrors = {
|
||||
endTime: !endTime,
|
||||
title: !title.trim(),
|
||||
participants: participants.length < minimumParticipants,
|
||||
};
|
||||
setLocalErrors(newErrors);
|
||||
|
||||
if (!newErrors.endTime && !newErrors.title && !newErrors.participants) {
|
||||
onSubmit({ endTime, title, participants });
|
||||
}
|
||||
};
|
||||
|
||||
// Merge local validation errors with API field errors
|
||||
const hasEndTimeError = localErrors.endTime || !!fieldErrors.endTime;
|
||||
const hasTitleError = localErrors.title || !!fieldErrors.title;
|
||||
const hasParticipantsError =
|
||||
localErrors.participants || !!fieldErrors.participants;
|
||||
|
||||
const getEndTimeMessage = () => {
|
||||
if (fieldErrors.endTime) return fieldErrors.endTime;
|
||||
if (localErrors.endTime) return "Sluttid är obligatoriskt";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getTitleMessage = () => {
|
||||
if (fieldErrors.title) return fieldErrors.title;
|
||||
if (localErrors.title) return "Titel är obligatoriskt";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getParticipantsMessage = () => {
|
||||
if (fieldErrors.participants) return fieldErrors.participants;
|
||||
if (localErrors.participants)
|
||||
return `Minst ${minimumParticipants} deltagare krävs`;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="p-(--spacing-md) bg-critical-background text-critical-ink-strong body-normal-sm rounded-(--border-radius-md) mb-(--spacing-md)">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Dropdown
|
||||
options={endTimeOptions}
|
||||
getOptionValue={(o) => o.value.toString().slice(0, 5)}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={endTime}
|
||||
onChange={(v) => {
|
||||
setEndTime(v);
|
||||
if (v) setLocalErrors((prev) => ({ ...prev, endTime: false }));
|
||||
}}
|
||||
placeholder="Välj sluttid"
|
||||
label="Sluttid"
|
||||
fullWidth
|
||||
error={hasEndTimeError}
|
||||
message={getEndTimeMessage()}
|
||||
disabled={loading}
|
||||
/>
|
||||
<TextInput
|
||||
label="Bokningstitel"
|
||||
placeholder="Bokningstitel"
|
||||
fullWidth
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (e.target.value)
|
||||
setLocalErrors((prev) => ({ ...prev, title: false }));
|
||||
}}
|
||||
error={hasTitleError}
|
||||
message={getTitleMessage()}
|
||||
disabled={loading}
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={participantOptions}
|
||||
getOptionValue={getOptionValue}
|
||||
getOptionLabel={getOptionLabel}
|
||||
getOptionSubtitle={getOptionSubtitle}
|
||||
value={participants}
|
||||
onChange={(value) => {
|
||||
setParticipants(value);
|
||||
if (value.length >= minimumParticipants)
|
||||
setLocalErrors((prev) => ({ ...prev, participants: false }));
|
||||
}}
|
||||
placeholder="Sök deltagare"
|
||||
label="Deltagare"
|
||||
fullWidth
|
||||
error={hasParticipantsError}
|
||||
message={getParticipantsMessage()}
|
||||
/>
|
||||
{roomName && (
|
||||
<div>
|
||||
<span className="body-bold-md text-base-ink-strong">Rum</span>
|
||||
<p className="body-normal-md text-base-ink-strong">{roomName}</p>
|
||||
</div>
|
||||
)}
|
||||
<InlineModalDivider />
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Bokar..." : "Boka"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/StartTimeGrid/StartTimeGrid.tsx
Normal file
88
frontend/src/components/StartTimeGrid/StartTimeGrid.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
import Choicebox from "../Choicebox/Choicebox";
|
||||
import InlineModal from "../InlineModal/InlineModal";
|
||||
|
||||
export interface TimeSlot {
|
||||
time: Temporal.PlainTime;
|
||||
label: string;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface StartTimeGridProps {
|
||||
timeSlots: TimeSlot[];
|
||||
selectedTime: Temporal.PlainTime | null;
|
||||
onChange: (time: Temporal.PlainTime) => void;
|
||||
heading?: string;
|
||||
status?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function StartTimeGrid({
|
||||
timeSlots,
|
||||
selectedTime,
|
||||
onChange,
|
||||
heading = "Välj starttid",
|
||||
status = "Visar alla lediga tider",
|
||||
children,
|
||||
}: StartTimeGridProps) {
|
||||
// Track which column was selected for arrow positioning
|
||||
const selectedIndex = timeSlots.findIndex(
|
||||
(s) => selectedTime && s.time.equals(selectedTime),
|
||||
);
|
||||
const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0;
|
||||
|
||||
// Split into rows of 2
|
||||
const rows: TimeSlot[][] = [];
|
||||
for (let i = 0; i < timeSlots.length; i += 2) {
|
||||
rows.push(timeSlots.slice(i, i + 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-(--spacing-sm)">
|
||||
<span className="body-bold-md text-base-ink-strong">{heading}</span>
|
||||
<span className="body-light-md text-base-ink-placeholder">
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-(--spacing-md) py-(--spacing-lg)">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div key={rowIndex}>
|
||||
<div className="grid grid-cols-2 gap-(--spacing-md)">
|
||||
{row.map((slot) => {
|
||||
const timeString = slot.time.toString().slice(0, 5);
|
||||
return (
|
||||
<Choicebox
|
||||
key={timeString}
|
||||
primaryText={timeString}
|
||||
secondaryText={slot.label}
|
||||
unavailable={slot.unavailable}
|
||||
name="start-time"
|
||||
value={timeString}
|
||||
checked={
|
||||
selectedTime !== null && slot.time.equals(selectedTime)
|
||||
}
|
||||
onChange={() => onChange(slot.time)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedTime &&
|
||||
row.some((slot) => slot.time.equals(selectedTime)) &&
|
||||
children && (
|
||||
<InlineModal
|
||||
arrowPosition={selectedColumn === 0 ? "left" : "right"}
|
||||
className="mt-(--spacing-ml) mb-(--spacing-md) max-w-none"
|
||||
>
|
||||
{children}
|
||||
</InlineModal>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts
Normal file
370
frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts
Normal file
@ -0,0 +1,370 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
|
||||
/** A booking with ISO 8601 datetime strings */
|
||||
export interface Booking {
|
||||
start: string; // e.g. "2025-12-20T10:00:00"
|
||||
end: string; // e.g. "2025-12-20T12:00:00"
|
||||
}
|
||||
|
||||
/** A bookable room with its current bookings */
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
capacity: number;
|
||||
bookings: Booking[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the booking system.
|
||||
*/
|
||||
export interface BookingContext {
|
||||
rooms: Room[];
|
||||
/** ISO 8601 duration, e.g. "PT4H" for 4 hours */
|
||||
maxBookableLength: string;
|
||||
/** Time of day, e.g. "08:00:00" */
|
||||
earliestBookingTime: string;
|
||||
/** Time of day, e.g. "20:00:00" */
|
||||
latestBookingTime: string;
|
||||
minimumParticipants: number;
|
||||
maxDaysInFuture: number;
|
||||
}
|
||||
|
||||
/** Internal booking with Temporal types */
|
||||
interface ParsedBooking {
|
||||
start: Temporal.PlainDateTime;
|
||||
end: Temporal.PlainDateTime;
|
||||
}
|
||||
|
||||
/** Internal room with Temporal types */
|
||||
interface ParsedRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
capacity: number;
|
||||
bookings: ParsedBooking[];
|
||||
}
|
||||
|
||||
/** Internal context with Temporal types */
|
||||
interface ParsedContext {
|
||||
rooms: ParsedRoom[];
|
||||
maxBookableHours: number;
|
||||
earliestBookingTime: Temporal.PlainTime;
|
||||
latestBookingTime: Temporal.PlainTime;
|
||||
minimumParticipants: number;
|
||||
maxDaysInFuture: number;
|
||||
}
|
||||
|
||||
/** Parse API context to Temporal types */
|
||||
function parseContext(context: BookingContext): ParsedContext {
|
||||
return {
|
||||
rooms: context.rooms.map((room) => ({
|
||||
...room,
|
||||
bookings: room.bookings.map((b) => ({
|
||||
start: Temporal.PlainDateTime.from(b.start),
|
||||
end: Temporal.PlainDateTime.from(b.end),
|
||||
})),
|
||||
})),
|
||||
maxBookableHours:
|
||||
Temporal.Duration.from(context.maxBookableLength).hours || 4,
|
||||
earliestBookingTime: Temporal.PlainTime.from(context.earliestBookingTime),
|
||||
latestBookingTime: Temporal.PlainTime.from(context.latestBookingTime),
|
||||
minimumParticipants: context.minimumParticipants,
|
||||
maxDaysInFuture: context.maxDaysInFuture,
|
||||
};
|
||||
}
|
||||
|
||||
/** A selectable time slot in the booking grid */
|
||||
export interface TimeSlot {
|
||||
/** Time of day */
|
||||
time: Temporal.PlainTime;
|
||||
/** Display label, e.g. "Upp till 2 h" */
|
||||
label: string;
|
||||
/** True if no rooms are available at this time */
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
function minutesToTime(minutes: number): Temporal.PlainTime {
|
||||
return Temporal.PlainTime.from({
|
||||
hour: Math.floor(minutes / 60),
|
||||
minute: minutes % 60,
|
||||
});
|
||||
}
|
||||
|
||||
/** Check if a date string is today */
|
||||
function isToday(date: string): boolean {
|
||||
return Temporal.PlainDate.from(date).equals(Temporal.Now.plainDateISO());
|
||||
}
|
||||
|
||||
/** Get current time in minutes since midnight */
|
||||
function getCurrentTimeMinutes(): number {
|
||||
const now = Temporal.Now.plainTimeISO();
|
||||
return now.hour * 60 + now.minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate time slots for the booking grid.
|
||||
* Each slot shows the maximum bookable duration across all (or filtered) rooms.
|
||||
* Past time slots are marked as unavailable.
|
||||
*/
|
||||
function calculateTimeSlots(
|
||||
context: ParsedContext,
|
||||
date: string,
|
||||
roomId?: string,
|
||||
): TimeSlot[] {
|
||||
const maxHours = context.maxBookableHours;
|
||||
const earliestMinutes =
|
||||
context.earliestBookingTime.hour * 60 + context.earliestBookingTime.minute;
|
||||
const latestMinutes =
|
||||
context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
|
||||
const roomsToCheck = roomId
|
||||
? context.rooms.filter((r) => r.id === roomId)
|
||||
: context.rooms;
|
||||
|
||||
const checkingToday = isToday(date);
|
||||
const currentMinutes = checkingToday ? getCurrentTimeMinutes() : 0;
|
||||
|
||||
const slots: TimeSlot[] = [];
|
||||
|
||||
for (let mins = earliestMinutes; mins < latestMinutes; mins += 30) {
|
||||
const slotTime = minutesToTime(mins);
|
||||
|
||||
// Mark past slots as unavailable
|
||||
if (checkingToday && mins <= currentMinutes) {
|
||||
slots.push({ time: slotTime, label: "", unavailable: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
let maxAvailableMinutes = 0;
|
||||
|
||||
for (const room of roomsToCheck) {
|
||||
let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60);
|
||||
|
||||
for (const booking of room.bookings) {
|
||||
if (booking.start.toPlainDate().toString() !== date) continue;
|
||||
const bookingStartTime = booking.start.toPlainTime();
|
||||
const bookingEndTime = booking.end.toPlainTime();
|
||||
const bookingStart =
|
||||
bookingStartTime.hour * 60 + bookingStartTime.minute;
|
||||
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
|
||||
|
||||
if (mins >= bookingStart && mins < bookingEnd) {
|
||||
availableMinutes = 0;
|
||||
break;
|
||||
}
|
||||
if (bookingStart > mins && bookingStart < mins + availableMinutes) {
|
||||
availableMinutes = bookingStart - mins;
|
||||
}
|
||||
}
|
||||
maxAvailableMinutes = Math.max(maxAvailableMinutes, availableMinutes);
|
||||
}
|
||||
|
||||
if (maxAvailableMinutes === 0) {
|
||||
slots.push({ time: slotTime, label: "", unavailable: true });
|
||||
} else {
|
||||
const hours = maxAvailableMinutes / 60;
|
||||
const label =
|
||||
hours >= 1 ? `Upp till ${hours} h` : `${maxAvailableMinutes} minuter`;
|
||||
slots.push({ time: slotTime, label });
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the room with the longest available duration at the given start time.
|
||||
* Used when no specific room is filtered.
|
||||
*/
|
||||
function findBestRoom(
|
||||
context: ParsedContext,
|
||||
date: string,
|
||||
startTime: Temporal.PlainTime,
|
||||
): ParsedRoom | null {
|
||||
const maxHours = context.maxBookableHours;
|
||||
const latestMinutes =
|
||||
context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
|
||||
const startMinutes = startTime.hour * 60 + startTime.minute;
|
||||
|
||||
let bestRoom: ParsedRoom | null = null;
|
||||
let bestDuration = 0;
|
||||
|
||||
for (const room of context.rooms) {
|
||||
let availableMinutes = Math.min(
|
||||
latestMinutes - startMinutes,
|
||||
maxHours * 60,
|
||||
);
|
||||
let roomAvailable = true;
|
||||
|
||||
for (const booking of room.bookings) {
|
||||
if (booking.start.toPlainDate().toString() !== date) continue;
|
||||
const bookingStartTime = booking.start.toPlainTime();
|
||||
const bookingEndTime = booking.end.toPlainTime();
|
||||
const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute;
|
||||
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
|
||||
|
||||
if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
|
||||
roomAvailable = false;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
bookingStart > startMinutes &&
|
||||
bookingStart < startMinutes + availableMinutes
|
||||
) {
|
||||
availableMinutes = bookingStart - startMinutes;
|
||||
}
|
||||
}
|
||||
|
||||
if (roomAvailable && availableMinutes > bestDuration) {
|
||||
bestDuration = availableMinutes;
|
||||
bestRoom = room;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRoom;
|
||||
}
|
||||
|
||||
/** An end time option with value and display label */
|
||||
export interface EndTimeOption {
|
||||
/** Time value */
|
||||
value: Temporal.PlainTime;
|
||||
/** Display label, e.g. "12:30 · 1,5 h" */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Format duration in minutes to Swedish display format */
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
}
|
||||
const hours = minutes / 60;
|
||||
if (Number.isInteger(hours)) {
|
||||
return `${hours} h`;
|
||||
}
|
||||
// Use comma as decimal separator for Swedish
|
||||
return `${hours.toString().replace(".", ",")} h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate valid end time options for the booking form.
|
||||
* Returns 30-minute increments up to the next booking or max duration.
|
||||
*/
|
||||
function calculateEndTimeOptions(
|
||||
context: ParsedContext,
|
||||
date: string,
|
||||
startTime: Temporal.PlainTime,
|
||||
roomId: string,
|
||||
): EndTimeOption[] {
|
||||
const maxHours = context.maxBookableHours;
|
||||
const latestMinutes =
|
||||
context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
|
||||
const startMinutes = startTime.hour * 60 + startTime.minute;
|
||||
const room = context.rooms.find((r) => r.id === roomId);
|
||||
if (!room) return [];
|
||||
|
||||
let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes);
|
||||
|
||||
for (const booking of room.bookings) {
|
||||
if (booking.start.toPlainDate().toString() !== date) continue;
|
||||
const bookingStartTime = booking.start.toPlainTime();
|
||||
const bookingEndTime = booking.end.toPlainTime();
|
||||
const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute;
|
||||
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
|
||||
|
||||
// If start time is during a booking, no options available
|
||||
if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (bookingStart > startMinutes && bookingStart < maxEndMinutes) {
|
||||
maxEndMinutes = bookingStart;
|
||||
}
|
||||
}
|
||||
|
||||
const options: EndTimeOption[] = [];
|
||||
for (let mins = startMinutes + 30; mins <= maxEndMinutes; mins += 30) {
|
||||
const time = minutesToTime(mins);
|
||||
const duration = formatDuration(mins - startMinutes);
|
||||
options.push({
|
||||
value: time,
|
||||
label: `${time.toString().slice(0, 5)} · ${duration}`,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing group room booking state and derived data.
|
||||
*
|
||||
* Handles:
|
||||
* - Room filtering
|
||||
* - Time slot availability calculation
|
||||
* - Automatic room selection (picks room with longest available duration)
|
||||
* - End time options based on selected start time and room
|
||||
*
|
||||
* @param context - Booking configuration from backend
|
||||
* @param date - The date to show bookings for (ISO format, e.g. "2025-12-20")
|
||||
*
|
||||
* @returns
|
||||
* - `roomFilter` / `setRoomFilter` - Filter to show only specific room's availability
|
||||
* - `roomOptions` - Options for the room filter dropdown
|
||||
* - `timeSlots` - Available time slots for the StartTimeGrid
|
||||
* - `selectedTime` / `setSelectedTime` - Currently selected start time
|
||||
* - `bookedRoom` - The room that will be booked (filtered room or best available)
|
||||
* - `endTimeOptions` - Valid end times for the booking form dropdown
|
||||
* - `clearSelection` - Reset the selected time
|
||||
*/
|
||||
export function useGroupRoomBooking(context: BookingContext, date: string) {
|
||||
const [roomFilter, setRoomFilter] = useState<string>("");
|
||||
const [selectedTime, setSelectedTime] = useState<Temporal.PlainTime | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const parsedContext = useMemo(() => parseContext(context), [context]);
|
||||
|
||||
const roomOptions = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "Alla rum" },
|
||||
...context.rooms.map((room) => ({ value: room.id, label: room.name })),
|
||||
],
|
||||
[context.rooms],
|
||||
);
|
||||
|
||||
const timeSlots = useMemo(
|
||||
() => calculateTimeSlots(parsedContext, date, roomFilter || undefined),
|
||||
[parsedContext, date, roomFilter],
|
||||
);
|
||||
|
||||
const bookedRoom = useMemo(() => {
|
||||
if (roomFilter) {
|
||||
return parsedContext.rooms.find((r) => r.id === roomFilter) || null;
|
||||
}
|
||||
if (selectedTime) {
|
||||
return findBestRoom(parsedContext, date, selectedTime);
|
||||
}
|
||||
return null;
|
||||
}, [parsedContext, date, roomFilter, selectedTime]);
|
||||
|
||||
const endTimeOptions = useMemo(() => {
|
||||
if (!selectedTime || !bookedRoom) return [];
|
||||
return calculateEndTimeOptions(
|
||||
parsedContext,
|
||||
date,
|
||||
selectedTime,
|
||||
bookedRoom.id,
|
||||
);
|
||||
}, [parsedContext, date, selectedTime, bookedRoom]);
|
||||
|
||||
const clearSelection = () => setSelectedTime(null);
|
||||
|
||||
return {
|
||||
roomFilter,
|
||||
setRoomFilter,
|
||||
roomOptions,
|
||||
timeSlots,
|
||||
selectedTime,
|
||||
setSelectedTime,
|
||||
bookedRoom,
|
||||
endTimeOptions,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
@ -142,8 +142,10 @@
|
||||
/* Spacing */
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-ml: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-xxl: 48px;
|
||||
|
||||
/* Control heights */
|
||||
--control-height-sm: 32px;
|
||||
@ -168,6 +170,17 @@
|
||||
/* Text input default width */
|
||||
--text-input-default-width-md: 194px;
|
||||
--text-input-default-width-lg: 218px;
|
||||
|
||||
/* Layout */
|
||||
--max-page-width: 900px;
|
||||
|
||||
/* Arrow */
|
||||
--arrow-width: 20px;
|
||||
|
||||
/* Breakpoints */
|
||||
--breakpoint-sm: 500px;
|
||||
--breakpoint-md: 800px;
|
||||
--breakpoint-lg: 1179px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
||||
23
frontend/src/lib/errors.ts
Normal file
23
frontend/src/lib/errors.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* API error response following RFC 7807 Problem Details format.
|
||||
*/
|
||||
export interface ApiError {
|
||||
type: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
status: number;
|
||||
violations?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error object is an ApiError.
|
||||
*/
|
||||
export function isApiError(error: unknown): error is ApiError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"type" in error &&
|
||||
"title" in error &&
|
||||
typeof (error as ApiError).type === "string"
|
||||
);
|
||||
}
|
||||
@ -1,460 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Button from "../components/Button/Button";
|
||||
import TextInput from "../components/TextInput/TextInput";
|
||||
import { SearchIcon } from "../components/Icon/Icon";
|
||||
import ListItem from "../components/ListItem/ListItem";
|
||||
import SearchResultList from "../components/SearchResultList/SearchResultList";
|
||||
import Combobox from "../components/Combobox/Combobox";
|
||||
import ListCard from "../components/ListCard/ListCard";
|
||||
import ParticipantPicker from "../components/ParticipantPicker/ParticipantPicker";
|
||||
|
||||
interface Person {
|
||||
value: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
const peopleOptions: Person[] = [
|
||||
{ value: "1", label: "Lennart Johansson", subtitle: "lejo1891" },
|
||||
{ value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" },
|
||||
{ value: "3", label: "Daniel Tjernström", subtitle: "datj1891" },
|
||||
{ value: "4", label: "Johan Mjällby", subtitle: "jomj1891" },
|
||||
{ value: "5", label: "Krister Nordin", subtitle: "krno1891" },
|
||||
{ value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" },
|
||||
];
|
||||
|
||||
const getPersonValue = (person: Person) => person.value;
|
||||
const getPersonLabel = (person: Person) => person.label;
|
||||
const getPersonSubtitle = (person: Person) => person.subtitle;
|
||||
|
||||
export default function ComponentLibrary() {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
});
|
||||
const [selectedPerson, setSelectedPerson] = useState<string>("");
|
||||
const [selectedPeople, setSelectedPeople] = useState<string[]>([]);
|
||||
const [participants, setParticipants] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Component Library</h1>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Dark Mode</h2>
|
||||
<Button variant="primary" onClick={() => setDarkMode(!darkMode)}>
|
||||
{darkMode ? "Light Mode" : "Dark Mode"}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Button Variants</h2>
|
||||
<div className="flex flex-wrap gap-md">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="red">Red</Button>
|
||||
<Button variant="green">Green</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Button Sizes</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md">Medium</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input Sizes</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Small"
|
||||
label="Small input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="md"
|
||||
placeholder="Medium"
|
||||
label="Medium input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="lg"
|
||||
placeholder="Large"
|
||||
label="Large input"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Icon</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Small with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Small search"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="md"
|
||||
placeholder="Medium with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Medium search"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="lg"
|
||||
placeholder="Large with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Large search"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input States</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput placeholder="Default" label="Default state" hideLabel />
|
||||
<TextInput
|
||||
placeholder="Error state"
|
||||
error
|
||||
label="Error state"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input With/Without Placeholder</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
placeholder="Placeholder"
|
||||
label="With placeholder"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput label="Without placeholder" hideLabel />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input Width Options</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<TextInput
|
||||
placeholder="Full width"
|
||||
fullWidth
|
||||
label="Full width input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Custom width"
|
||||
customWidth="300px"
|
||||
label="Custom width input"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Label</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<TextInput label="Email" placeholder="Enter your email" />
|
||||
<TextInput label="Password" placeholder="Enter password" error />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Label and Message</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
error
|
||||
message="This field is required"
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Choose a username"
|
||||
error
|
||||
message="Must be at least 3 characters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">List Item</h2>
|
||||
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||
<ListItem title="Lennart Johansson" subtitle="lejo1891" />
|
||||
<ListItem title="Mats Rubarth" subtitle="matsrub1891" />
|
||||
<ListItem title="Daniel Tjernström" subtitle="datj1891" selected />
|
||||
<ListItem title="Johan Mjällby" subtitle="jomj1891" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">List Item - Title Only</h2>
|
||||
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||
<ListItem title="Krister Nordin" />
|
||||
<ListItem title="Kurre Hamrin" selected />
|
||||
<ListItem title="Per Karlsson" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">SearchResultList</h2>
|
||||
<div className="max-w-96">
|
||||
<SearchResultList
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
selectedValues={["3"]}
|
||||
focusedIndex={1}
|
||||
noResultsText="Inga resultat"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">SearchResultList - Empty</h2>
|
||||
<div className="max-w-96">
|
||||
<SearchResultList
|
||||
options={[]}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
noResultsText="Inga resultat"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Single Select</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPerson}
|
||||
onChange={(v) => setSelectedPerson(v as string)}
|
||||
placeholder="Sök..."
|
||||
label="Välj person"
|
||||
/>
|
||||
<p className="body-light-sm text-base-ink-placeholder">
|
||||
Selected:{" "}
|
||||
{selectedPerson
|
||||
? peopleOptions.find((p) => p.value === selectedPerson)?.label
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Multi Select</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPeople}
|
||||
onChange={(v) => setSelectedPeople(v as string[])}
|
||||
placeholder="Sök..."
|
||||
label="Välj personer"
|
||||
multiple
|
||||
/>
|
||||
<p className="body-light-sm text-base-ink-placeholder">
|
||||
Selected:{" "}
|
||||
{selectedPeople.length > 0
|
||||
? selectedPeople
|
||||
.map((v) => peopleOptions.find((p) => p.value === v)?.label)
|
||||
.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Sizes</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Custom Width</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
customWidth="350px"
|
||||
label="Custom width"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
label="Full width"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ListCard</h2>
|
||||
<div className="flex flex-col gap-md max-w-96">
|
||||
<ListCard title="Lennart Johansson" onRemove={() => {}} />
|
||||
<ListCard title="Mats Rubarth" onRemove={() => {}} />
|
||||
<ListCard title="Daniel Tjernström" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ParticipantPicker</h2>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök deltagare..."
|
||||
label="Välj deltagare"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ParticipantPicker - Sizes</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ParticipantPicker - Custom Width</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
customWidth="350px"
|
||||
label="Custom width"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
label="Full width"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import Button from "../../components/Button/Button";
|
||||
|
||||
export default function ButtonSection() {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">Button Variants</h2>
|
||||
<div className="flex flex-wrap gap-md">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="red">Red</Button>
|
||||
<Button variant="green">Green</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Button Sizes</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md">Medium</Button>
|
||||
<Button size="lg">Large</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import Choicebox from "../../components/Choicebox/Choicebox";
|
||||
|
||||
export default function ChoiceboxSection() {
|
||||
const [selectedTime, setSelectedTime] = useState<string>("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">States</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Choicebox
|
||||
primaryText="09:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-states"
|
||||
value="09:00"
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="10:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-states"
|
||||
value="10:00"
|
||||
defaultChecked
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="11:00"
|
||||
secondaryText="Upptaget"
|
||||
name="time-states"
|
||||
value="11:00"
|
||||
unavailable
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Interactive</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Choicebox
|
||||
primaryText="09:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-interactive"
|
||||
value="09:00"
|
||||
checked={selectedTime === "09:00"}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="10:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-interactive"
|
||||
value="10:00"
|
||||
checked={selectedTime === "10:00"}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="11:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-interactive"
|
||||
value="11:00"
|
||||
checked={selectedTime === "11:00"}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="12:00"
|
||||
secondaryText="Upptaget"
|
||||
name="time-interactive"
|
||||
value="12:00"
|
||||
unavailable
|
||||
/>
|
||||
</div>
|
||||
<p className="body-light-sm text-base-ink-placeholder mt-sm">
|
||||
Vald tid: {selectedTime || "Ingen"}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Fit Content</h2>
|
||||
<div className="flex flex-wrap gap-md">
|
||||
<Choicebox
|
||||
primaryText="09:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-fit"
|
||||
value="09:00"
|
||||
fitContent
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="10:00"
|
||||
secondaryText="Ledigt"
|
||||
name="time-fit"
|
||||
value="10:00"
|
||||
fitContent
|
||||
/>
|
||||
<Choicebox
|
||||
primaryText="11:00"
|
||||
secondaryText="Upptaget"
|
||||
name="time-fit"
|
||||
value="11:00"
|
||||
fitContent
|
||||
unavailable
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import Combobox from "../../components/Combobox/Combobox";
|
||||
import {
|
||||
peopleOptions,
|
||||
getPersonValue,
|
||||
getPersonLabel,
|
||||
getPersonSubtitle,
|
||||
} from "./data";
|
||||
|
||||
export default function ComboboxSection() {
|
||||
const [selectedPerson, setSelectedPerson] = useState<string>("");
|
||||
const [selectedPeople, setSelectedPeople] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">Combobox - Single Select</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPerson}
|
||||
onChange={(v) => setSelectedPerson(v as string)}
|
||||
placeholder="Sök..."
|
||||
label="Välj person"
|
||||
/>
|
||||
<p className="body-light-sm text-base-ink-placeholder">
|
||||
Selected:{" "}
|
||||
{selectedPerson
|
||||
? peopleOptions.find((p) => p.value === selectedPerson)?.label
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Multi Select</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={selectedPeople}
|
||||
onChange={(v) => setSelectedPeople(v as string[])}
|
||||
placeholder="Sök..."
|
||||
label="Välj personer"
|
||||
multiple
|
||||
/>
|
||||
<p className="body-light-sm text-base-ink-placeholder">
|
||||
Selected:{" "}
|
||||
{selectedPeople.length > 0
|
||||
? selectedPeople
|
||||
.map((v) => peopleOptions.find((p) => p.value === v)?.label)
|
||||
.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Sizes</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Combobox - Custom Width</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
customWidth="350px"
|
||||
label="Custom width"
|
||||
hideLabel
|
||||
/>
|
||||
<Combobox
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
placeholder="Sök..."
|
||||
label="Full width"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { type ComponentCategory } from "./componentCategories";
|
||||
import ButtonSection from "./ButtonSection";
|
||||
import TextInputSection from "./TextInputSection";
|
||||
import DropdownSection from "./DropdownSection";
|
||||
import ListItemSection from "./ListItemSection";
|
||||
import SearchResultListSection from "./SearchResultListSection";
|
||||
import ComboboxSection from "./ComboboxSection";
|
||||
import ListCardSection from "./ListCardSection";
|
||||
import ParticipantPickerSection from "./ParticipantPickerSection";
|
||||
import InlineModalSection from "./InlineModalSection";
|
||||
import ChoiceboxSection from "./ChoiceboxSection";
|
||||
import StartTimeGridSection from "./StartTimeGridSection";
|
||||
|
||||
export default function ComponentLibrary() {
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
return document.documentElement.classList.contains("dark");
|
||||
});
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
useState<ComponentCategory>("Button");
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (selectedCategory) {
|
||||
case "Button":
|
||||
return <ButtonSection />;
|
||||
case "TextInput":
|
||||
return <TextInputSection />;
|
||||
case "Dropdown":
|
||||
return <DropdownSection />;
|
||||
case "ListItem":
|
||||
return <ListItemSection />;
|
||||
case "SearchResultList":
|
||||
return <SearchResultListSection />;
|
||||
case "Combobox":
|
||||
return <ComboboxSection />;
|
||||
case "ListCard":
|
||||
return <ListCardSection />;
|
||||
case "ParticipantPicker":
|
||||
return <ParticipantPickerSection />;
|
||||
case "InlineModal":
|
||||
return <InlineModalSection />;
|
||||
case "Choicebox":
|
||||
return <ChoiceboxSection />;
|
||||
case "StartTimeGrid":
|
||||
return <StartTimeGridSection />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-lg">
|
||||
<h1 className="mb-lg">{selectedCategory}</h1>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
import { useState } from "react";
|
||||
import Dropdown from "../../components/Dropdown/Dropdown";
|
||||
import { peopleOptions, getPersonValue, getPersonLabel } from "./data";
|
||||
|
||||
export default function DropdownSection() {
|
||||
const [sizeSmall, setSizeSmall] = useState("");
|
||||
const [sizeMedium, setSizeMedium] = useState("");
|
||||
const [sizeLarge, setSizeLarge] = useState("");
|
||||
const [stateDefault, setStateDefault] = useState("");
|
||||
const [stateError, setStateError] = useState("");
|
||||
const [withLabel, setWithLabel] = useState("");
|
||||
const [withLabelError, setWithLabelError] = useState("");
|
||||
const [withMessage, setWithMessage] = useState("");
|
||||
const [withMessageError, setWithMessageError] = useState("");
|
||||
const [fullWidth, setFullWidth] = useState("");
|
||||
const [customWidth, setCustomWidth] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">Dropdown Sizes</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small dropdown"
|
||||
hideLabel
|
||||
value={sizeSmall}
|
||||
onChange={(v) => setSizeSmall(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium dropdown"
|
||||
hideLabel
|
||||
value={sizeMedium}
|
||||
onChange={(v) => setSizeMedium(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large dropdown"
|
||||
hideLabel
|
||||
value={sizeLarge}
|
||||
onChange={(v) => setSizeLarge(v)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Dropdown States</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Default"
|
||||
label="Default state"
|
||||
hideLabel
|
||||
value={stateDefault}
|
||||
onChange={(v) => setStateDefault(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Error state"
|
||||
error
|
||||
label="Error state"
|
||||
hideLabel
|
||||
value={stateError}
|
||||
onChange={(v) => setStateError(v)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Dropdown with Label</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Select person"
|
||||
label="Person"
|
||||
value={withLabel}
|
||||
onChange={(v) => setWithLabel(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Select person"
|
||||
label="Person (error)"
|
||||
error
|
||||
value={withLabelError}
|
||||
onChange={(v) => setWithLabelError(v)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Dropdown with Label and Message</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Select person"
|
||||
label="Person"
|
||||
message="Please select a person"
|
||||
value={withMessage}
|
||||
onChange={(v) => setWithMessage(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Select person"
|
||||
label="Person"
|
||||
error
|
||||
message="This field is required"
|
||||
value={withMessageError}
|
||||
onChange={(v) => setWithMessageError(v)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Dropdown Width Options</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Full width"
|
||||
fullWidth
|
||||
label="Full width dropdown"
|
||||
hideLabel
|
||||
value={fullWidth}
|
||||
onChange={(v) => setFullWidth(v)}
|
||||
/>
|
||||
<Dropdown
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
placeholder="Custom width"
|
||||
customWidth="300px"
|
||||
label="Custom width dropdown"
|
||||
hideLabel
|
||||
value={customWidth}
|
||||
onChange={(v) => setCustomWidth(v)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import InlineModal, {
|
||||
InlineModalDivider,
|
||||
} from "../../components/InlineModal/InlineModal";
|
||||
import TextInput from "../../components/TextInput/TextInput";
|
||||
import Button from "../../components/Button/Button";
|
||||
|
||||
export default function InlineModalSection() {
|
||||
return (
|
||||
<>
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Arrow Left</h2>
|
||||
<InlineModal arrowPosition="left">
|
||||
<TextInput
|
||||
label="Beskrivning"
|
||||
placeholder="Ange beskrivning..."
|
||||
fullWidth
|
||||
/>
|
||||
<InlineModalDivider />
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="md">
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="primary" size="md">
|
||||
Boka
|
||||
</Button>
|
||||
</div>
|
||||
</InlineModal>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Arrow Right</h2>
|
||||
<InlineModal arrowPosition="right">
|
||||
<TextInput
|
||||
label="Beskrivning"
|
||||
placeholder="Ange beskrivning..."
|
||||
fullWidth
|
||||
/>
|
||||
<InlineModalDivider />
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="md">
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="primary" size="md">
|
||||
Boka
|
||||
</Button>
|
||||
</div>
|
||||
</InlineModal>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import ListCard from "../../components/ListCard/ListCard";
|
||||
|
||||
export default function ListCardSection() {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-md">ListCard</h2>
|
||||
<div className="flex flex-col gap-md max-w-96">
|
||||
<ListCard title="Lennart Johansson" onRemove={() => {}} />
|
||||
<ListCard title="Mats Rubarth" onRemove={() => {}} />
|
||||
<ListCard title="Daniel Tjernström" />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import ListItem from "../../components/ListItem/ListItem";
|
||||
|
||||
export default function ListItemSection() {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">List Item</h2>
|
||||
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||
<ListItem title="Lennart Johansson" subtitle="lejo1891" />
|
||||
<ListItem title="Mats Rubarth" subtitle="matsrub1891" />
|
||||
<ListItem title="Daniel Tjernström" subtitle="datj1891" selected />
|
||||
<ListItem title="Johan Mjällby" subtitle="jomj1891" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">List Item - Title Only</h2>
|
||||
<div className="max-w-96 border border-base-ink-soft rounded-(--border-radius-md) overflow-hidden">
|
||||
<ListItem title="Krister Nordin" />
|
||||
<ListItem title="Kurre Hamrin" selected />
|
||||
<ListItem title="Per Karlsson" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import ParticipantPicker from "../../components/ParticipantPicker/ParticipantPicker";
|
||||
import {
|
||||
peopleOptions,
|
||||
getPersonValue,
|
||||
getPersonLabel,
|
||||
getPersonSubtitle,
|
||||
} from "./data";
|
||||
|
||||
export default function ParticipantPickerSection() {
|
||||
const [participants, setParticipants] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">ParticipantPicker</h2>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök deltagare..."
|
||||
label="Välj deltagare"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ParticipantPicker - Sizes</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Small"
|
||||
size="sm"
|
||||
label="Small"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Medium"
|
||||
size="md"
|
||||
label="Medium"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Large"
|
||||
size="lg"
|
||||
label="Large"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">ParticipantPicker - Custom Width</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
customWidth="350px"
|
||||
label="Custom width"
|
||||
hideLabel
|
||||
/>
|
||||
<ParticipantPicker
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
value={participants}
|
||||
onChange={setParticipants}
|
||||
placeholder="Sök..."
|
||||
label="Full width"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import SearchResultList from "../../components/SearchResultList/SearchResultList";
|
||||
import {
|
||||
peopleOptions,
|
||||
getPersonValue,
|
||||
getPersonLabel,
|
||||
getPersonSubtitle,
|
||||
} from "./data";
|
||||
|
||||
export default function SearchResultListSection() {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">SearchResultList</h2>
|
||||
<div className="max-w-96">
|
||||
<SearchResultList
|
||||
options={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
selectedValues={["3"]}
|
||||
focusedIndex={1}
|
||||
noResultsText="Inga resultat"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">SearchResultList - Empty</h2>
|
||||
<div className="max-w-96">
|
||||
<SearchResultList
|
||||
options={[]}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
noResultsText="Inga resultat"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx
Normal file
48
frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import clsx from "clsx";
|
||||
import Button from "../../components/Button/Button";
|
||||
import {
|
||||
componentCategories,
|
||||
type ComponentCategory,
|
||||
} from "./componentCategories";
|
||||
|
||||
interface SidebarProps {
|
||||
selectedCategory: ComponentCategory;
|
||||
onSelectCategory: (category: ComponentCategory) => void;
|
||||
darkMode: boolean;
|
||||
onToggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
selectedCategory,
|
||||
onSelectCategory,
|
||||
darkMode,
|
||||
onToggleDarkMode,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<nav className="w-64 shrink-0 border-r border-base-ink-soft bg-base-canvas p-md overflow-y-auto">
|
||||
<h1 className="mb-md">Components</h1>
|
||||
<div className="mb-lg">
|
||||
<Button variant="secondary" size="sm" onClick={onToggleDarkMode}>
|
||||
{darkMode ? "Light Mode" : "Dark Mode"}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-xs">
|
||||
{componentCategories.map((category) => (
|
||||
<li key={category}>
|
||||
<button
|
||||
onClick={() => onSelectCategory(category)}
|
||||
className={clsx(
|
||||
"w-full text-left px-md py-sm rounded-(--border-radius-sm) body-normal-md cursor-pointer",
|
||||
selectedCategory === category
|
||||
? "bg-primary text-base-canvas"
|
||||
: "text-base-ink-strong hover:bg-base-ink-soft",
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import Dropdown from "../../components/Dropdown/Dropdown";
|
||||
import StartTimeGrid from "../../components/StartTimeGrid/StartTimeGrid";
|
||||
import BookingForm from "../../components/StartTimeGrid/BookingForm";
|
||||
import { useGroupRoomBooking } from "../../components/StartTimeGrid/useGroupRoomBooking";
|
||||
import {
|
||||
peopleOptions,
|
||||
getPersonValue,
|
||||
getPersonLabel,
|
||||
getPersonSubtitle,
|
||||
} from "./data";
|
||||
|
||||
const sampleContext = {
|
||||
rooms: [
|
||||
{
|
||||
id: "1",
|
||||
name: "G5:1",
|
||||
capacity: 5,
|
||||
bookings: [
|
||||
{ start: "2025-12-20T10:00:00", end: "2025-12-20T12:00:00" },
|
||||
{ start: "2025-12-20T14:00:00", end: "2025-12-20T16:00:00" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "G5:2",
|
||||
capacity: 5,
|
||||
bookings: [{ start: "2025-12-20T09:00:00", end: "2025-12-20T11:00:00" }],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "G5:3",
|
||||
capacity: 5,
|
||||
bookings: [
|
||||
{ start: "2025-12-20T08:00:00", end: "2025-12-20T10:00:00" },
|
||||
{ start: "2025-12-20T13:00:00", end: "2025-12-20T15:00:00" },
|
||||
],
|
||||
},
|
||||
],
|
||||
maxBookableLength: "PT4H",
|
||||
earliestBookingTime: "08:00:00",
|
||||
latestBookingTime: "20:00:00",
|
||||
minimumParticipants: 2,
|
||||
maxDaysInFuture: 14,
|
||||
};
|
||||
|
||||
export default function StartTimeGridSection() {
|
||||
const booking = useGroupRoomBooking(sampleContext, "2025-12-20");
|
||||
|
||||
return (
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Default</h2>
|
||||
|
||||
<div className="mb-md">
|
||||
<Dropdown
|
||||
options={booking.roomOptions}
|
||||
getOptionValue={(o) => o.value}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={booking.roomFilter}
|
||||
onChange={booking.setRoomFilter}
|
||||
label="Filtrera på rum"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StartTimeGrid
|
||||
timeSlots={booking.timeSlots}
|
||||
selectedTime={booking.selectedTime}
|
||||
onChange={booking.setSelectedTime}
|
||||
>
|
||||
<BookingForm
|
||||
key={booking.selectedTime?.toString()}
|
||||
endTimeOptions={booking.endTimeOptions}
|
||||
participantOptions={peopleOptions}
|
||||
getOptionValue={getPersonValue}
|
||||
getOptionLabel={getPersonLabel}
|
||||
getOptionSubtitle={getPersonSubtitle}
|
||||
roomName={booking.bookedRoom?.name}
|
||||
minimumParticipants={sampleContext.minimumParticipants}
|
||||
onSubmit={(data) => {
|
||||
console.log("Booked:", {
|
||||
roomId: booking.bookedRoom?.id,
|
||||
startTime: booking.selectedTime,
|
||||
...data,
|
||||
});
|
||||
booking.clearSelection();
|
||||
}}
|
||||
onCancel={booking.clearSelection}
|
||||
/>
|
||||
</StartTimeGrid>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,128 @@
|
||||
import TextInput from "../../components/TextInput/TextInput";
|
||||
import { SearchIcon } from "../../components/Icon/Icon";
|
||||
|
||||
export default function TextInputSection() {
|
||||
return (
|
||||
<>
|
||||
<section>
|
||||
<h2 className="mb-md">Text Input Sizes</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Small"
|
||||
label="Small input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="md"
|
||||
placeholder="Medium"
|
||||
label="Medium input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="lg"
|
||||
placeholder="Large"
|
||||
label="Large input"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Icon</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
size="sm"
|
||||
placeholder="Small with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Small search"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="md"
|
||||
placeholder="Medium with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Medium search"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
size="lg"
|
||||
placeholder="Large with icon"
|
||||
Icon={SearchIcon}
|
||||
label="Large search"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input States</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput placeholder="Default" label="Default state" hideLabel />
|
||||
<TextInput
|
||||
placeholder="Error state"
|
||||
error
|
||||
label="Error state"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input With/Without Placeholder</h2>
|
||||
<div className="flex flex-wrap items-center gap-md">
|
||||
<TextInput
|
||||
placeholder="Placeholder"
|
||||
label="With placeholder"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput label="Without placeholder" hideLabel />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input Width Options</h2>
|
||||
<div className="flex flex-col gap-md">
|
||||
<TextInput
|
||||
placeholder="Full width"
|
||||
fullWidth
|
||||
label="Full width input"
|
||||
hideLabel
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Custom width"
|
||||
customWidth="300px"
|
||||
label="Custom width input"
|
||||
hideLabel
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Label</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<TextInput label="Email" placeholder="Enter your email" />
|
||||
<TextInput label="Password" placeholder="Enter password" error />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-lg">
|
||||
<h2 className="mb-md">Text Input with Label and Message</h2>
|
||||
<div className="flex flex-wrap items-start gap-md">
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
error
|
||||
message="This field is required"
|
||||
/>
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Choose a username"
|
||||
error
|
||||
message="Must be at least 3 characters"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
export const componentCategories = [
|
||||
"Button",
|
||||
"TextInput",
|
||||
"Dropdown",
|
||||
"ListItem",
|
||||
"SearchResultList",
|
||||
"Combobox",
|
||||
"ListCard",
|
||||
"ParticipantPicker",
|
||||
"InlineModal",
|
||||
"Choicebox",
|
||||
"StartTimeGrid",
|
||||
] as const;
|
||||
|
||||
export type ComponentCategory = (typeof componentCategories)[number];
|
||||
18
frontend/src/studentportalen/ComponentLibrary/data.ts
Normal file
18
frontend/src/studentportalen/ComponentLibrary/data.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface Person {
|
||||
value: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export const peopleOptions: Person[] = [
|
||||
{ value: "1", label: "Lennart Johansson", subtitle: "lejo1891" },
|
||||
{ value: "2", label: "Mats Rubarth", subtitle: "matsrub1891" },
|
||||
{ value: "3", label: "Daniel Tjernström", subtitle: "datj1891" },
|
||||
{ value: "4", label: "Johan Mjällby", subtitle: "jomj1891" },
|
||||
{ value: "5", label: "Krister Nordin", subtitle: "krno1891" },
|
||||
{ value: "6", label: "Kurre Hamrin", subtitle: "kuha1891" },
|
||||
];
|
||||
|
||||
export const getPersonValue = (person: Person) => person.value;
|
||||
export const getPersonLabel = (person: Person) => person.label;
|
||||
export const getPersonSubtitle = (person: Person) => person.subtitle;
|
||||
@ -1,6 +1,10 @@
|
||||
#layout {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 5em;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: var(--max-page-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user
This needs some documentation what it is. Inline and modal are to me opposing concepts. What actually is it? When would I use it compared to a modal dialog and how is it different from just a
<div>?