From 1be7ba69524101d890b0beb0e70e4f43f16b1d62 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 19 Dec 2025 08:52:33 +0100 Subject: [PATCH 01/29] Create dropdown component --- frontend/src/components/Dropdown/Dropdown.tsx | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 frontend/src/components/Dropdown/Dropdown.tsx diff --git a/frontend/src/components/Dropdown/Dropdown.tsx b/frontend/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..c82cda2 --- /dev/null +++ b/frontend/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,225 @@ +import { useState, useId, type SelectHTMLAttributes } from "react"; +import clsx from "clsx"; +import { ChevronDownIcon } from "../Icon/Icon"; + +export type DropdownSize = "sm" | "md" | "lg"; + +export interface DropdownProps + extends Omit< + SelectHTMLAttributes, + "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; + /** Controlled mode - parent manages state */ + value?: string; + /** Uncontrolled mode - component manages state with initial value */ + defaultValue?: string; + onChange?: (value: string, option: T | undefined) => void; +} + +const wrapperSizeClasses: Record = { + 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 = { + sm: "body-normal-md", + md: "body-normal-md", + lg: "body-normal-lg", +}; + +const iconContainerSizeClasses: Record = { + 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)"), +}; + +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({ + options, + getOptionValue, + getOptionLabel, + size = "md", + error = false, + fullWidth = false, + customWidth, + label, + hideLabel = false, + message, + placeholder, + className = "", + value, + defaultValue, + onChange, + ...props +}: DropdownProps) { + const selectId = useId(); + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(defaultValue ?? ""); + const currentValue = isControlled ? value : internalValue; + + const selectedOption = options.find( + (o) => getOptionValue(o) === currentValue, + ); + const selectedLabel = selectedOption + ? getOptionLabel(selectedOption) + : placeholder || ""; + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (!isControlled) { + setInternalValue(newValue); + } + const option = options.find((o) => getOptionValue(o) === newValue); + onChange?.(newValue, option); + }; + + // Derived values + const hasValue = currentValue !== ""; + 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 + const autoWidthSizer = !useFixedWidth && ( + + ); + + // Shows current selection or placeholder + const displayLabel = ( + + {selectedLabel} + + ); + + // Dropdown arrow icon + const chevron = ( +
+ +
+ ); + + // Native select (invisible, handles interaction) + const selectElement = ( + + ); + + // The styled dropdown control + const dropdownField = ( +
+ {autoWidthSizer} + {displayLabel} + {chevron} + {selectElement} +
+ ); + + if (!showVisibleLabel && !message) { + return dropdownField; + } + + return ( +
+ {showVisibleLabel && ( + + )} + {dropdownField} + {message && ( + {message} + )} +
+ ); +} -- 2.39.5 From c2c5c44fe393d4fd07b24ab2cd50adb0bcc24735 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 19 Dec 2025 08:52:59 +0100 Subject: [PATCH 02/29] Add chevron --- frontend/src/components/Icon/Icon.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/components/Icon/Icon.tsx b/frontend/src/components/Icon/Icon.tsx index 1c0589b..16eb8bd 100644 --- a/frontend/src/components/Icon/Icon.tsx +++ b/frontend/src/components/Icon/Icon.tsx @@ -94,3 +94,19 @@ export function CheckmarkIcon({ ); } + +export function ChevronDownIcon({ + size = "inherit", + className, + ...props +}: IconProps) { + return ( + + + + ); +} -- 2.39.5 From 28f0d2636cb7312e1e7d43cd61b6c3ecba433ee0 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 19 Dec 2025 08:54:41 +0100 Subject: [PATCH 03/29] Add padding --- frontend/src/studentportalen/layout.css | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/studentportalen/layout.css b/frontend/src/studentportalen/layout.css index af17ddb..308621c 100644 --- a/frontend/src/studentportalen/layout.css +++ b/frontend/src/studentportalen/layout.css @@ -1,5 +1,6 @@ #layout { min-height: 100vh; + padding-bottom: 4em; } main { padding: 0 1em; -- 2.39.5 From e966572c2b228c0b7f7087669b00aac3c1ba39d9 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 19 Dec 2025 08:55:23 +0100 Subject: [PATCH 04/29] Refactor ComponentLibrary --- frontend/src/Studentportalen.tsx | 2 +- .../src/studentportalen/ComponentLibrary.tsx | 460 ------------------ .../ComponentLibrary/ButtonSection.tsx | 26 + .../ComponentLibrary/ComboboxSection.tsx | 125 +++++ .../ComponentLibrary/ComponentLibrary.tsx | 64 +++ .../ComponentLibrary/DropdownSection.tsx | 137 ++++++ .../ComponentLibrary/ListCardSection.tsx | 14 + .../ComponentLibrary/ListItemSection.tsx | 26 + .../ParticipantPickerSection.tsx | 101 ++++ .../SearchResultListSection.tsx | 40 ++ .../ComponentLibrary/Sidebar.tsx | 57 +++ .../ComponentLibrary/TextInputSection.tsx | 128 +++++ .../studentportalen/ComponentLibrary/data.ts | 18 + 13 files changed, 737 insertions(+), 461 deletions(-) delete mode 100644 frontend/src/studentportalen/ComponentLibrary.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/data.ts diff --git a/frontend/src/Studentportalen.tsx b/frontend/src/Studentportalen.tsx index 11ad6ed..a076c96 100644 --- a/frontend/src/Studentportalen.tsx +++ b/frontend/src/Studentportalen.tsx @@ -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() { diff --git a/frontend/src/studentportalen/ComponentLibrary.tsx b/frontend/src/studentportalen/ComponentLibrary.tsx deleted file mode 100644 index 79a9db2..0000000 --- a/frontend/src/studentportalen/ComponentLibrary.tsx +++ /dev/null @@ -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(""); - const [selectedPeople, setSelectedPeople] = useState([]); - const [participants, setParticipants] = useState([]); - - useEffect(() => { - if (darkMode) { - document.documentElement.classList.add("dark"); - } else { - document.documentElement.classList.remove("dark"); - } - }, [darkMode]); - - return ( - <> -

Component Library

- -
-

Dark Mode

- -
- -
-

Button Variants

-
- - - - -
-
- -
-

Button Sizes

-
- - - -
-
-
-

Text Input Sizes

-
- - - -
-
- -
-

Text Input with Icon

-
- - - -
-
- -
-

Text Input States

-
- - -
-
- -
-

Text Input With/Without Placeholder

-
- - -
-
- -
-

Text Input Width Options

-
- - -
-
- -
-

Text Input with Label

-
- - -
-
- -
-

Text Input with Label and Message

-
- - -
-
- -
-

List Item

-
- - - - -
-
- -
-

List Item - Title Only

-
- - - -
-
- -
-

SearchResultList

-
- -
-
- -
-

SearchResultList - Empty

-
- -
-
- -
-

Combobox - Single Select

-
- setSelectedPerson(v as string)} - placeholder="Sök..." - label="Välj person" - /> -

- Selected:{" "} - {selectedPerson - ? peopleOptions.find((p) => p.value === selectedPerson)?.label - : "None"} -

-
-
- -
-

Combobox - Multi Select

-
- setSelectedPeople(v as string[])} - placeholder="Sök..." - label="Välj personer" - multiple - /> -

- Selected:{" "} - {selectedPeople.length > 0 - ? selectedPeople - .map((v) => peopleOptions.find((p) => p.value === v)?.label) - .join(", ") - : "None"} -

-
-
- -
-

Combobox - Sizes

-
- - - -
-
- -
-

Combobox - Custom Width

-
- - -
-
- -
-

ListCard

-
- {}} /> - {}} /> - -
-
- -
-

ParticipantPicker

- -
- -
-

ParticipantPicker - Sizes

-
- - - -
-
- -
-

ParticipantPicker - Custom Width

-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - ); -} diff --git a/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx new file mode 100644 index 0000000..25d473c --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ButtonSection.tsx @@ -0,0 +1,26 @@ +import Button from "../../components/Button/Button"; + +export default function ButtonSection() { + return ( + <> +
+

Button Variants

+
+ + + + +
+
+ +
+

Button Sizes

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx new file mode 100644 index 0000000..b311e5c --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ComboboxSection.tsx @@ -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(""); + const [selectedPeople, setSelectedPeople] = useState([]); + + return ( + <> +
+

Combobox - Single Select

+
+ setSelectedPerson(v as string)} + placeholder="Sök..." + label="Välj person" + /> +

+ Selected:{" "} + {selectedPerson + ? peopleOptions.find((p) => p.value === selectedPerson)?.label + : "None"} +

+
+
+ +
+

Combobox - Multi Select

+
+ setSelectedPeople(v as string[])} + placeholder="Sök..." + label="Välj personer" + multiple + /> +

+ Selected:{" "} + {selectedPeople.length > 0 + ? selectedPeople + .map((v) => peopleOptions.find((p) => p.value === v)?.label) + .join(", ") + : "None"} +

+
+
+ +
+

Combobox - Sizes

+
+ + + +
+
+ +
+

Combobox - Custom Width

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx new file mode 100644 index 0000000..e8e8850 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from "react"; +import Sidebar, { type ComponentCategory } from "./Sidebar"; +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"; + +export default function ComponentLibrary() { + const [darkMode, setDarkMode] = useState(() => { + return document.documentElement.classList.contains("dark"); + }); + const [selectedCategory, setSelectedCategory] = + useState("Button"); + + useEffect(() => { + if (darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + }, [darkMode]); + + const renderContent = () => { + switch (selectedCategory) { + case "Button": + return ; + case "TextInput": + return ; + case "Dropdown": + return ; + case "ListItem": + return ; + case "SearchResultList": + return ; + case "Combobox": + return ; + case "ListCard": + return ; + case "ParticipantPicker": + return ; + default: + return null; + } + }; + + return ( +
+ setDarkMode(!darkMode)} + /> +
+

{selectedCategory}

+ {renderContent()} +
+
+ ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx new file mode 100644 index 0000000..7618e30 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import Dropdown from "../../components/Dropdown/Dropdown"; +import { peopleOptions, getPersonValue, getPersonLabel } from "./data"; + +export default function DropdownSection() { + const [selectedPerson, setSelectedPerson] = useState(""); + + return ( + <> +
+

Dropdown Sizes

+
+ + + +
+
+ +
+

Dropdown States

+
+ + +
+
+ +
+

Dropdown with Label

+
+ setSelectedPerson(value)} + /> + +
+
+ +
+

Dropdown with Label and Message

+
+ + +
+
+ +
+

Dropdown Width Options

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx new file mode 100644 index 0000000..ce12717 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ListCardSection.tsx @@ -0,0 +1,14 @@ +import ListCard from "../../components/ListCard/ListCard"; + +export default function ListCardSection() { + return ( +
+

ListCard

+
+ {}} /> + {}} /> + +
+
+ ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx new file mode 100644 index 0000000..eca3995 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ListItemSection.tsx @@ -0,0 +1,26 @@ +import ListItem from "../../components/ListItem/ListItem"; + +export default function ListItemSection() { + return ( + <> +
+

List Item

+
+ + + + +
+
+ +
+

List Item - Title Only

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx new file mode 100644 index 0000000..111fabf --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ParticipantPickerSection.tsx @@ -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([]); + + return ( + <> +
+

ParticipantPicker

+ +
+ +
+

ParticipantPicker - Sizes

+
+ + + +
+
+ +
+

ParticipantPicker - Custom Width

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx b/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx new file mode 100644 index 0000000..d6237c9 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/SearchResultListSection.tsx @@ -0,0 +1,40 @@ +import SearchResultList from "../../components/SearchResultList/SearchResultList"; +import { + peopleOptions, + getPersonValue, + getPersonLabel, + getPersonSubtitle, +} from "./data"; + +export default function SearchResultListSection() { + return ( + <> +
+

SearchResultList

+
+ +
+
+ +
+

SearchResultList - Empty

+
+ +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx new file mode 100644 index 0000000..6328a21 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx @@ -0,0 +1,57 @@ +import clsx from "clsx"; +import Button from "../../components/Button/Button"; + +export const componentCategories = [ + "Button", + "TextInput", + "Dropdown", + "ListItem", + "SearchResultList", + "Combobox", + "ListCard", + "ParticipantPicker", +] as const; + +export type ComponentCategory = (typeof componentCategories)[number]; + +interface SidebarProps { + selectedCategory: ComponentCategory; + onSelectCategory: (category: ComponentCategory) => void; + darkMode: boolean; + onToggleDarkMode: () => void; +} + +export default function Sidebar({ + selectedCategory, + onSelectCategory, + darkMode, + onToggleDarkMode, +}: SidebarProps) { + return ( + + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx b/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx new file mode 100644 index 0000000..bbc200b --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/TextInputSection.tsx @@ -0,0 +1,128 @@ +import TextInput from "../../components/TextInput/TextInput"; +import { SearchIcon } from "../../components/Icon/Icon"; + +export default function TextInputSection() { + return ( + <> +
+

Text Input Sizes

+
+ + + +
+
+ +
+

Text Input with Icon

+
+ + + +
+
+ +
+

Text Input States

+
+ + +
+
+ +
+

Text Input With/Without Placeholder

+
+ + +
+
+ +
+

Text Input Width Options

+
+ + +
+
+ +
+

Text Input with Label

+
+ + +
+
+ +
+

Text Input with Label and Message

+
+ + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/data.ts b/frontend/src/studentportalen/ComponentLibrary/data.ts new file mode 100644 index 0000000..0883c85 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/data.ts @@ -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; -- 2.39.5 From eacaf56cac699751645c1bc80689737e63e0e085 Mon Sep 17 00:00:00 2001 From: nenzen Date: Sun, 21 Dec 2025 15:22:41 +0100 Subject: [PATCH 05/29] Include errors --- frontend/src/components/Combobox/Combobox.tsx | 3 +++ .../components/ParticipantPicker/ParticipantPicker.tsx | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/frontend/src/components/Combobox/Combobox.tsx b/frontend/src/components/Combobox/Combobox.tsx index 7a980cb..d6ca63b 100644 --- a/frontend/src/components/Combobox/Combobox.tsx +++ b/frontend/src/components/Combobox/Combobox.tsx @@ -25,6 +25,7 @@ export interface ComboboxProps { 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 = { @@ -77,6 +78,7 @@ export default function Combobox({ value, onChange, onSearchChange, + error = false, }: ComboboxProps) { // Convert value (undefined | string | string[]) to always be an array const selectedValues: string[] = @@ -226,6 +228,7 @@ export default function Combobox({ fullWidth={fullWidth || !!customWidth} customWidth={customWidth} Icon={SearchIcon} + error={error} /> {isOpen && ( diff --git a/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx b/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx index 8497b7c..99a0dfb 100644 --- a/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx +++ b/frontend/src/components/ParticipantPicker/ParticipantPicker.tsx @@ -20,6 +20,8 @@ export interface ParticipantPickerProps 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 = { @@ -43,6 +45,8 @@ export default function ParticipantPicker({ fullWidth = false, customWidth, onSearchChange, + error = false, + message, className, style, ...props @@ -80,6 +84,7 @@ export default function ParticipantPicker({ fullWidth multiple onSearchChange={onSearchChange} + error={error} /> {selectedOptions.length > 0 && (
@@ -93,6 +98,9 @@ export default function ParticipantPicker({ ))}
)} + {message && ( + {message} + )} ); } -- 2.39.5 From 234a7adfb0a0f33cf35c88fc2b75bda8c96cf485 Mon Sep 17 00:00:00 2001 From: nenzen Date: Sun, 21 Dec 2025 15:23:02 +0100 Subject: [PATCH 06/29] Add breakpoints --- frontend/src/index.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index a1412fe..ccbf349 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -168,6 +168,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 { -- 2.39.5 From e31b5abdab3c1621abec85ad68ef7ed45b0a3680 Mon Sep 17 00:00:00 2001 From: nenzen Date: Sun, 21 Dec 2025 15:23:44 +0100 Subject: [PATCH 07/29] Page sizing --- frontend/src/studentportalen/layout.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/studentportalen/layout.css b/frontend/src/studentportalen/layout.css index af17ddb..bd5803b 100644 --- a/frontend/src/studentportalen/layout.css +++ b/frontend/src/studentportalen/layout.css @@ -2,5 +2,7 @@ min-height: 100vh; } main { + max-width: var(--max-page-width); + margin: 0 auto; padding: 0 1em; } -- 2.39.5 From 08b63dfb5095a3479a2c6a17886240188caef3b7 Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 03:22:00 +0100 Subject: [PATCH 08/29] Disable secure flag in local development --- bff/src/main/resources/application-development.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bff/src/main/resources/application-development.yaml b/bff/src/main/resources/application-development.yaml index ead2087..0f55830 100644 --- a/bff/src/main/resources/application-development.yaml +++ b/bff/src/main/resources/application-development.yaml @@ -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 -- 2.39.5 From fd389dae010978505d52164b8d415f1f5b07c6c8 Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 03:23:45 +0100 Subject: [PATCH 09/29] Use preferred workaround for select styling --- frontend/src/components/Dropdown/Dropdown.tsx | 84 +++++++++---------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/Dropdown/Dropdown.tsx b/frontend/src/components/Dropdown/Dropdown.tsx index c82cda2..14130e8 100644 --- a/frontend/src/components/Dropdown/Dropdown.tsx +++ b/frontend/src/components/Dropdown/Dropdown.tsx @@ -1,4 +1,4 @@ -import { useState, useId, type SelectHTMLAttributes } from "react"; +import { useId, type SelectHTMLAttributes } from "react"; import clsx from "clsx"; import { ChevronDownIcon } from "../Icon/Icon"; @@ -20,11 +20,8 @@ export interface DropdownProps hideLabel?: boolean; message?: string; placeholder?: string; - /** Controlled mode - parent manages state */ - value?: string; - /** Uncontrolled mode - component manages state with initial value */ - defaultValue?: string; - onChange?: (value: string, option: T | undefined) => void; + value: string; + onChange: (value: string, option: T | undefined) => void; } const wrapperSizeClasses: Record = { @@ -45,6 +42,13 @@ const iconContainerSizeClasses: Record = { lg: clsx("w-(--control-height-lg) h-(--control-height-lg)"), }; +// Right padding to reserve space for the chevron icon +const chevronSpacingClasses: Record = { + 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]"; @@ -75,33 +79,19 @@ export default function Dropdown({ placeholder, className = "", value, - defaultValue, onChange, ...props }: DropdownProps) { const selectId = useId(); - const isControlled = value !== undefined; - const [internalValue, setInternalValue] = useState(defaultValue ?? ""); - const currentValue = isControlled ? value : internalValue; - - const selectedOption = options.find( - (o) => getOptionValue(o) === currentValue, - ); - const selectedLabel = selectedOption - ? getOptionLabel(selectedOption) - : placeholder || ""; const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - if (!isControlled) { - setInternalValue(newValue); - } const option = options.find((o) => getOptionValue(o) === newValue); - onChange?.(newValue, option); + onChange(newValue, option); }; // Derived values - const hasValue = currentValue !== ""; + const hasValue = value !== ""; const useFixedWidth = fullWidth || customWidth; const widthStyle = customWidth ? { width: customWidth } : undefined; const stateClasses = error ? errorStateClasses : defaultStateClasses; @@ -118,36 +108,28 @@ export default function Dropdown({ "", ); - // Invisible element that sets minimum width based on longest option + // 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 && ( ); - // Shows current selection or placeholder - const displayLabel = ( - - {selectedLabel} - - ); - - // Dropdown arrow icon + // Dropdown arrow icon - absolutely positioned on the right const chevron = (
@@ -155,13 +137,24 @@ export default function Dropdown({
); - // Native select (invisible, handles interaction) + // 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 = ( + + ); +} -- 2.39.5 From ad10fe5fd3411d385a47387141b3c40aede8925e Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 08:14:02 +0100 Subject: [PATCH 11/29] Add inline modal --- .../components/InlineModal/InlineModal.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 frontend/src/components/InlineModal/InlineModal.tsx diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx new file mode 100644 index 0000000..9f8a0e8 --- /dev/null +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -0,0 +1,82 @@ +import type { HTMLAttributes, ReactNode } from "react"; +import clsx from "clsx"; + +export type ArrowPosition = "left" | "right"; + +export interface InlineModalProps extends HTMLAttributes { + arrowPosition?: ArrowPosition; + arrowOffset?: number; + children: ReactNode; +} + +const baseClasses = clsx( + "relative", + "w-full max-w-[450px] md:max-w-none", + "bg-sky-35", + "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 +// Values are offset by -10px to account for arrow border rendering +const arrowPositionClasses: Record = { + left: "left-[38px]", + right: "right-[58px]", +}; + +export default function InlineModal({ + arrowPosition = "left", + arrowOffset, + children, + className = "", + ...props +}: InlineModalProps) { + const useCustomOffset = arrowOffset !== undefined; + + return ( +
+ {/* Arrow pointing up - uses two layered CSS triangles to create a bordered arrow effect. + CSS triangles are made with borders on a zero-size element, but can't have their own border/stroke. + Solution: layer two triangles - a larger one in border color behind, a smaller one in background color on top. */} +
+ {/* Border arrow: triangle in border color (sky-100), sits behind */} +
+ {/* Fill arrow: triangle in background color (sky-35), offset 1px down to cover inner part, + leaving only the border visible around the edge */} +
+
+ + {/* Content */} +
{children}
+
+ ); +} + +export function InlineModalDivider() { + return
; +} -- 2.39.5 From ee2ff6ccca7cb467fce2d163b0933bcc6cf192ec Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 08:14:26 +0100 Subject: [PATCH 12/29] Increase distance --- frontend/src/studentportalen/layout.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/studentportalen/layout.css b/frontend/src/studentportalen/layout.css index 2daafae..4f9bfba 100644 --- a/frontend/src/studentportalen/layout.css +++ b/frontend/src/studentportalen/layout.css @@ -1,9 +1,10 @@ #layout { min-height: 100vh; - padding-bottom: 4em; + padding-bottom: 5em; } + main { max-width: var(--max-page-width); margin: 0 auto; padding: 0 1em; -} +} \ No newline at end of file -- 2.39.5 From 70ff2136dc7fbe10ce88919c3b18b37bf9adbe07 Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 08:15:31 +0100 Subject: [PATCH 13/29] Update Component library --- .../ComponentLibrary/ChoiceboxSection.tsx | 104 ++++++++++++++++++ .../ComponentLibrary/ComponentLibrary.tsx | 13 ++- .../ComponentLibrary/DropdownSection.tsx | 36 +++++- .../ComponentLibrary/InlineModalSection.tsx | 51 +++++++++ .../ComponentLibrary/Sidebar.tsx | 3 + 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx create mode 100644 frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx diff --git a/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx b/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx new file mode 100644 index 0000000..0057978 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/ChoiceboxSection.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import Choicebox from "../../components/Choicebox/Choicebox"; + +export default function ChoiceboxSection() { + const [selectedTime, setSelectedTime] = useState(""); + + return ( + <> +
+

States

+
+ + + +
+
+ +
+

Interactive

+
+ setSelectedTime(e.target.value)} + /> + setSelectedTime(e.target.value)} + /> + setSelectedTime(e.target.value)} + /> + +
+

+ Vald tid: {selectedTime || "Ingen"} +

+
+ +
+

Fit Content

+
+ + + +
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx index e8e8850..cf58c1d 100644 --- a/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx +++ b/frontend/src/studentportalen/ComponentLibrary/ComponentLibrary.tsx @@ -8,6 +8,9 @@ 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(() => { @@ -42,6 +45,12 @@ export default function ComponentLibrary() { return ; case "ParticipantPicker": return ; + case "InlineModal": + return ; + case "Choicebox": + return ; + case "StartTimeGrid": + return ; default: return null; } @@ -55,10 +64,10 @@ export default function ComponentLibrary() { darkMode={darkMode} onToggleDarkMode={() => setDarkMode(!darkMode)} /> -
+

{selectedCategory}

{renderContent()} -
+
); } diff --git a/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx index 7618e30..75fbdeb 100644 --- a/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx +++ b/frontend/src/studentportalen/ComponentLibrary/DropdownSection.tsx @@ -3,7 +3,17 @@ import Dropdown from "../../components/Dropdown/Dropdown"; import { peopleOptions, getPersonValue, getPersonLabel } from "./data"; export default function DropdownSection() { - const [selectedPerson, setSelectedPerson] = useState(""); + 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 ( <> @@ -18,6 +28,8 @@ export default function DropdownSection() { size="sm" label="Small dropdown" hideLabel + value={sizeSmall} + onChange={(v) => setSizeSmall(v)} /> setSizeMedium(v)} /> setSizeLarge(v)} /> @@ -50,6 +66,8 @@ export default function DropdownSection() { placeholder="Default" label="Default state" hideLabel + value={stateDefault} + onChange={(v) => setStateDefault(v)} /> setStateError(v)} /> @@ -72,8 +92,8 @@ export default function DropdownSection() { getOptionLabel={getPersonLabel} placeholder="Select person" label="Person" - value={selectedPerson} - onChange={(value) => setSelectedPerson(value)} + value={withLabel} + onChange={(v) => setWithLabel(v)} /> setWithLabelError(v)} /> @@ -96,6 +118,8 @@ export default function DropdownSection() { placeholder="Select person" label="Person" message="Please select a person" + value={withMessage} + onChange={(v) => setWithMessage(v)} /> setWithMessageError(v)} /> @@ -120,6 +146,8 @@ export default function DropdownSection() { fullWidth label="Full width dropdown" hideLabel + value={fullWidth} + onChange={(v) => setFullWidth(v)} /> setCustomWidth(v)} /> diff --git a/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx b/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx new file mode 100644 index 0000000..1d14b49 --- /dev/null +++ b/frontend/src/studentportalen/ComponentLibrary/InlineModalSection.tsx @@ -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 ( + <> +
+

Arrow Left

+ + + +
+ + +
+
+
+ +
+

Arrow Right

+ + + +
+ + +
+
+
+ + ); +} diff --git a/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx index 6328a21..e23dcec 100644 --- a/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx +++ b/frontend/src/studentportalen/ComponentLibrary/Sidebar.tsx @@ -10,6 +10,9 @@ export const componentCategories = [ "Combobox", "ListCard", "ParticipantPicker", + "InlineModal", + "Choicebox", + "StartTimeGrid", ] as const; export type ComponentCategory = (typeof componentCategories)[number]; -- 2.39.5 From 1403010345c05edf5d6242002dafa27310b7bc9b Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 6 Jan 2026 08:30:15 +0100 Subject: [PATCH 14/29] Adjust arrow --- frontend/src/components/InlineModal/InlineModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx index 9f8a0e8..da9aa3f 100644 --- a/frontend/src/components/InlineModal/InlineModal.tsx +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -43,7 +43,7 @@ export default function InlineModal({ Solution: layer two triangles - a larger one in border color behind, a smaller one in background color on top. */}
Date: Tue, 6 Jan 2026 09:19:19 +0100 Subject: [PATCH 15/29] linting --- .../src/components/Choicebox/Choicebox.tsx | 4 +++- .../ComponentLibrary/Sidebar.tsx | 20 ++++--------------- .../ComponentLibrary/componentCategories.ts | 15 ++++++++++++++ frontend/src/studentportalen/layout.css | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) create mode 100644 frontend/src/studentportalen/ComponentLibrary/componentCategories.ts diff --git a/frontend/src/components/Choicebox/Choicebox.tsx b/frontend/src/components/Choicebox/Choicebox.tsx index 0e2e378..b0cc065 100644 --- a/frontend/src/components/Choicebox/Choicebox.tsx +++ b/frontend/src/components/Choicebox/Choicebox.tsx @@ -57,7 +57,9 @@ export default function Choicebox({ ...props }: ChoiceboxProps) { const isDisabled = unavailable || disabled; - const textColorClass = unavailable ? "text-base-ink-placeholder" : "text-primary"; + const textColorClass = unavailable + ? "text-base-ink-placeholder" + : "text-primary"; return (
- + {!unavailable && ( + + )} ); } -- 2.39.5 From 294467882699433c91c889ad89022d09c4637de7 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 9 Jan 2026 14:58:10 +0100 Subject: [PATCH 21/29] Change margin top and add new spacings --- frontend/src/components/StartTimeGrid/StartTimeGrid.tsx | 2 +- frontend/src/index.css | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx index bfc7863..8956fe5 100644 --- a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx +++ b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx @@ -67,7 +67,7 @@ export default function StartTimeGrid({ children && ( {children} diff --git a/frontend/src/index.css b/frontend/src/index.css index ccbf349..4c759f5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; -- 2.39.5 From f039a7696a504751f00f437bd8700d35ff65e0d6 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 9 Jan 2026 15:03:14 +0100 Subject: [PATCH 22/29] Don't show Upp till for 30 minute slots --- frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts index 4fa0efb..ade1a53 100644 --- a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts +++ b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts @@ -126,7 +126,7 @@ function calculateTimeSlots( const label = hours >= 1 ? `Upp till ${hours} h` - : `Upp till ${maxAvailableMinutes} min`; + : `${maxAvailableMinutes} minuter`; slots.push({ time: slotTime, label }); } } -- 2.39.5 From 78b035ccfac6ee54deac9aa680294fd463c32eef Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 9 Jan 2026 15:05:47 +0100 Subject: [PATCH 23/29] Update status text --- frontend/src/components/StartTimeGrid/StartTimeGrid.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx index 8956fe5..0fdaa39 100644 --- a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx +++ b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx @@ -22,7 +22,7 @@ export default function StartTimeGrid({ selectedTime, onChange, heading = "Välj starttid", - status = "Visar lediga tider", + status = "Visar alla lediga tider", children, }: StartTimeGridProps) { // Track which column was selected for arrow positioning @@ -39,7 +39,7 @@ export default function StartTimeGrid({
{heading} - + {status}
-- 2.39.5 From 79778e02feff12a46cf18566d5d00f93d87b2393 Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 9 Jan 2026 15:19:20 +0100 Subject: [PATCH 24/29] npm format --- frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts index ade1a53..cb64d7e 100644 --- a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts +++ b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts @@ -124,9 +124,7 @@ function calculateTimeSlots( } else { const hours = maxAvailableMinutes / 60; const label = - hours >= 1 - ? `Upp till ${hours} h` - : `${maxAvailableMinutes} minuter`; + hours >= 1 ? `Upp till ${hours} h` : `${maxAvailableMinutes} minuter`; slots.push({ time: slotTime, label }); } } -- 2.39.5 From 29fdbd5146afc86092e24ba3215ba48f5bc7037c Mon Sep 17 00:00:00 2001 From: nenzen Date: Fri, 9 Jan 2026 16:13:18 +0100 Subject: [PATCH 25/29] Change background on arrow --- frontend/src/components/InlineModal/InlineModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx index 084cdb2..dfd6fab 100644 --- a/frontend/src/components/InlineModal/InlineModal.tsx +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -58,7 +58,7 @@ export default function InlineModal({ "border-b-[10px] border-b-sky-100", )} /> - {/* Fill arrow: triangle in background color (sky-35), offset 1px down to cover inner part, + {/* Fill arrow: triangle in background color (sky-20), offset 1px down to cover inner part, leaving only the border visible around the edge */}
-- 2.39.5 From b57227a448653db2fc5e26f7d0db172208b3b9c5 Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 13 Jan 2026 06:31:50 +0100 Subject: [PATCH 26/29] Use svg instead of css trick to create arrow --- .../components/InlineModal/InlineModal.tsx | 46 ++++++++----------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx index dfd6fab..ad5b656 100644 --- a/frontend/src/components/InlineModal/InlineModal.tsx +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -21,10 +21,10 @@ const baseClasses = clsx( const contentClasses = clsx("flex flex-col", "gap-(--spacing-lg)"); // Position arrow so its center is 48px from the edge -// Values are offset by -10px to account for arrow border rendering +// Arrow is 20px wide, so offset is 48 - 10 = 38px const arrowPositionClasses: Record = { left: "left-[38px]", - right: "right-[58px]", + right: "right-[38px]", }; export default function InlineModal({ @@ -38,38 +38,28 @@ export default function InlineModal({ return (
- {/* Arrow pointing up - uses two layered CSS triangles to create a bordered arrow effect. - CSS triangles are made with borders on a zero-size element, but can't have their own border/stroke. - Solution: layer two triangles - a larger one in border color behind, a smaller one in background color on top. */} -
- {/* Border arrow: triangle in border color (sky-100), sits behind */} -
+ {/* Stroke only on diagonal edges - stops before bottom */} + - {/* Fill arrow: triangle in background color (sky-20), offset 1px down to cover inner part, - leaving only the border visible around the edge */} -
-
+ {/* Content */}
{children}
-- 2.39.5 From 8bff166cfb493e35d9c9950e002e3941bcc81056 Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 13 Jan 2026 07:06:30 +0100 Subject: [PATCH 27/29] Add comment to InlineModal --- frontend/src/components/InlineModal/InlineModal.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/components/InlineModal/InlineModal.tsx b/frontend/src/components/InlineModal/InlineModal.tsx index ad5b656..0a303c1 100644 --- a/frontend/src/components/InlineModal/InlineModal.tsx +++ b/frontend/src/components/InlineModal/InlineModal.tsx @@ -27,6 +27,17 @@ const arrowPositionClasses: Record = { right: "right-[38px]", }; +/** + * 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({ arrowPosition = "left", arrowOffset, -- 2.39.5 From 0fc2f7c0d85e775891f214f1fd448bdafc412a3d Mon Sep 17 00:00:00 2001 From: nenzen Date: Tue, 13 Jan 2026 07:13:57 +0100 Subject: [PATCH 28/29] Use temporal-polyfill instead of string manipulation --- frontend/package-lock.json | 18 +++++++- frontend/package.json | 3 +- .../StartTimeGrid/useGroupRoomBooking.ts | 42 ++++++++++--------- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8684d09..e747cdd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 154b092..d4b65bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts index cb64d7e..3067902 100644 --- a/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts +++ b/frontend/src/components/StartTimeGrid/useGroupRoomBooking.ts @@ -1,4 +1,5 @@ import { useState, useMemo } from "react"; +import { Temporal } from "temporal-polyfill"; /** A booking with ISO 8601 datetime strings */ export interface Booking { @@ -41,30 +42,30 @@ export interface TimeSlot { /** Parse ISO 8601 duration (e.g. "PT4H") to hours */ function parseDurationToHours(duration: string): number { - const match = duration.match(/PT(\d+)H/); - return match ? parseInt(match[1], 10) : 4; + return Temporal.Duration.from(duration).hours || 4; } function timeToMinutes(time: string): number { - const [hours, minutes] = time.split(":").map(Number); - return hours * 60 + minutes; + const t = Temporal.PlainTime.from(time); + return t.hour * 60 + t.minute; } function minutesToTime(minutes: number): string { - const h = Math.floor(minutes / 60); - const m = minutes % 60; - return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; + return Temporal.PlainTime.from({ + hour: Math.floor(minutes / 60), + minute: minutes % 60, + }).toString().slice(0, 5); } /** Check if a date string is today */ function isToday(date: string): boolean { - return date === new Date().toISOString().split("T")[0]; + return Temporal.PlainDate.from(date).equals(Temporal.Now.plainDateISO()); } /** Get current time in minutes since midnight */ function getCurrentTimeMinutes(): number { - const now = new Date(); - return now.getHours() * 60 + now.getMinutes(); + const now = Temporal.Now.plainTimeISO(); + return now.hour * 60 + now.minute; } /** @@ -104,9 +105,10 @@ function calculateTimeSlots( let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60); for (const booking of room.bookings) { - if (!booking.start.startsWith(date)) continue; - const bookingStart = timeToMinutes(booking.start.split("T")[1]); - const bookingEnd = timeToMinutes(booking.end.split("T")[1]); + const bookingDateTime = Temporal.PlainDateTime.from(booking.start); + if (bookingDateTime.toPlainDate().toString() !== date) continue; + const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); + const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); if (mins >= bookingStart && mins < bookingEnd) { availableMinutes = 0; @@ -156,9 +158,10 @@ function findBestRoom( let roomAvailable = true; for (const booking of room.bookings) { - if (!booking.start.startsWith(date)) continue; - const bookingStart = timeToMinutes(booking.start.split("T")[1]); - const bookingEnd = timeToMinutes(booking.end.split("T")[1]); + const bookingDateTime = Temporal.PlainDateTime.from(booking.start); + if (bookingDateTime.toPlainDate().toString() !== date) continue; + const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); + const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); if (startMinutes >= bookingStart && startMinutes < bookingEnd) { roomAvailable = false; @@ -221,9 +224,10 @@ function calculateEndTimeOptions( let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes); for (const booking of room.bookings) { - if (!booking.start.startsWith(date)) continue; - const bookingStart = timeToMinutes(booking.start.split("T")[1]); - const bookingEnd = timeToMinutes(booking.end.split("T")[1]); + const bookingDateTime = Temporal.PlainDateTime.from(booking.start); + if (bookingDateTime.toPlainDate().toString() !== date) continue; + const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); + const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); // If start time is during a booking, no options available if (startMinutes >= bookingStart && startMinutes < bookingEnd) { -- 2.39.5 From 89f3ba2dec20fd0e5bfb09e6bbdf9beebf4b7c55 Mon Sep 17 00:00:00 2001 From: nenzen Date: Wed, 14 Jan 2026 23:39:08 +0100 Subject: [PATCH 29/29] Refactor booking components to use Temporal types internally with string conversion at system boundaries --- .../components/StartTimeGrid/BookingForm.tsx | 2 +- .../StartTimeGrid/StartTimeGrid.tsx | 42 +++-- .../StartTimeGrid/useGroupRoomBooking.ts | 156 ++++++++++++------ .../ComponentLibrary/StartTimeGridSection.tsx | 2 +- 4 files changed, 130 insertions(+), 72 deletions(-) diff --git a/frontend/src/components/StartTimeGrid/BookingForm.tsx b/frontend/src/components/StartTimeGrid/BookingForm.tsx index 3b633c6..4e56ecf 100644 --- a/frontend/src/components/StartTimeGrid/BookingForm.tsx +++ b/frontend/src/components/StartTimeGrid/BookingForm.tsx @@ -101,7 +101,7 @@ export default function BookingForm({ )} o.value} + getOptionValue={(o) => o.value.toString().slice(0, 5)} getOptionLabel={(o) => o.label} value={endTime} onChange={(v) => { diff --git a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx index 0fdaa39..38c7103 100644 --- a/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx +++ b/frontend/src/components/StartTimeGrid/StartTimeGrid.tsx @@ -1,17 +1,18 @@ import { ReactNode } from "react"; +import { Temporal } from "temporal-polyfill"; import Choicebox from "../Choicebox/Choicebox"; import InlineModal from "../InlineModal/InlineModal"; export interface TimeSlot { - time: string; + time: Temporal.PlainTime; label: string; unavailable?: boolean; } export interface StartTimeGridProps { timeSlots: TimeSlot[]; - selectedTime: string | null; - onChange: (time: string) => void; + selectedTime: Temporal.PlainTime | null; + onChange: (time: Temporal.PlainTime) => void; heading?: string; status?: string; children?: ReactNode; @@ -26,7 +27,9 @@ export default function StartTimeGrid({ children, }: StartTimeGridProps) { // Track which column was selected for arrow positioning - const selectedIndex = timeSlots.findIndex((s) => s.time === selectedTime); + const selectedIndex = timeSlots.findIndex( + (s) => selectedTime && s.time.equals(selectedTime), + ); const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0; // Split into rows of 2 @@ -48,22 +51,27 @@ export default function StartTimeGrid({ {rows.map((row, rowIndex) => (
- {row.map((slot) => ( - onChange(slot.time)} - /> - ))} + {row.map((slot) => { + const timeString = slot.time.toString().slice(0, 5); + return ( + onChange(slot.time)} + /> + ); + })}
{selectedTime && - row.some((slot) => slot.time === selectedTime) && + row.some((slot) => slot.time.equals(selectedTime)) && children && ( ({ + ...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, e.g. "10:00" */ - time: string; + /** 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; } -/** Parse ISO 8601 duration (e.g. "PT4H") to hours */ -function parseDurationToHours(duration: string): number { - return Temporal.Duration.from(duration).hours || 4; -} - -function timeToMinutes(time: string): number { - const t = Temporal.PlainTime.from(time); - return t.hour * 60 + t.minute; -} - -function minutesToTime(minutes: number): string { +function minutesToTime(minutes: number): Temporal.PlainTime { return Temporal.PlainTime.from({ hour: Math.floor(minutes / 60), minute: minutes % 60, - }).toString().slice(0, 5); + }); } /** Check if a date string is today */ @@ -74,13 +107,15 @@ function getCurrentTimeMinutes(): number { * Past time slots are marked as unavailable. */ function calculateTimeSlots( - context: BookingContext, + context: ParsedContext, date: string, roomId?: string, ): TimeSlot[] { - const maxHours = parseDurationToHours(context.maxBookableLength); - const earliestMinutes = timeToMinutes(context.earliestBookingTime); - const latestMinutes = timeToMinutes(context.latestBookingTime); + 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; @@ -105,10 +140,12 @@ function calculateTimeSlots( let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60); for (const booking of room.bookings) { - const bookingDateTime = Temporal.PlainDateTime.from(booking.start); - if (bookingDateTime.toPlainDate().toString() !== date) continue; - const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); - const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); + 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; @@ -139,15 +176,16 @@ function calculateTimeSlots( * Used when no specific room is filtered. */ function findBestRoom( - context: BookingContext, + context: ParsedContext, date: string, - startTime: string, -): Room | null { - const maxHours = parseDurationToHours(context.maxBookableLength); - const latestMinutes = timeToMinutes(context.latestBookingTime); - const startMinutes = timeToMinutes(startTime); + 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: Room | null = null; + let bestRoom: ParsedRoom | null = null; let bestDuration = 0; for (const room of context.rooms) { @@ -158,10 +196,11 @@ function findBestRoom( let roomAvailable = true; for (const booking of room.bookings) { - const bookingDateTime = Temporal.PlainDateTime.from(booking.start); - if (bookingDateTime.toPlainDate().toString() !== date) continue; - const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); - const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); + 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; @@ -186,8 +225,8 @@ function findBestRoom( /** An end time option with value and display label */ export interface EndTimeOption { - /** Time value, e.g. "12:30" */ - value: string; + /** Time value */ + value: Temporal.PlainTime; /** Display label, e.g. "12:30 · 1,5 h" */ label: string; } @@ -210,24 +249,26 @@ function formatDuration(minutes: number): string { * Returns 30-minute increments up to the next booking or max duration. */ function calculateEndTimeOptions( - context: BookingContext, + context: ParsedContext, date: string, - startTime: string, + startTime: Temporal.PlainTime, roomId: string, ): EndTimeOption[] { - const maxHours = parseDurationToHours(context.maxBookableLength); - const latestMinutes = timeToMinutes(context.latestBookingTime); - const startMinutes = timeToMinutes(startTime); + 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) { - const bookingDateTime = Temporal.PlainDateTime.from(booking.start); - if (bookingDateTime.toPlainDate().toString() !== date) continue; - const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); - const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); + 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) { @@ -245,7 +286,7 @@ function calculateEndTimeOptions( const duration = formatDuration(mins - startMinutes); options.push({ value: time, - label: `${time} · ${duration}`, + label: `${time.toString().slice(0, 5)} · ${duration}`, }); } return options; @@ -274,7 +315,11 @@ function calculateEndTimeOptions( */ export function useGroupRoomBooking(context: BookingContext, date: string) { const [roomFilter, setRoomFilter] = useState(""); - const [selectedTime, setSelectedTime] = useState(null); + const [selectedTime, setSelectedTime] = useState( + null, + ); + + const parsedContext = useMemo(() => parseContext(context), [context]); const roomOptions = useMemo( () => [ @@ -285,24 +330,29 @@ export function useGroupRoomBooking(context: BookingContext, date: string) { ); const timeSlots = useMemo( - () => calculateTimeSlots(context, date, roomFilter || undefined), - [context, date, roomFilter], + () => calculateTimeSlots(parsedContext, date, roomFilter || undefined), + [parsedContext, date, roomFilter], ); const bookedRoom = useMemo(() => { if (roomFilter) { - return context.rooms.find((r) => r.id === roomFilter) || null; + return parsedContext.rooms.find((r) => r.id === roomFilter) || null; } if (selectedTime) { - return findBestRoom(context, date, selectedTime); + return findBestRoom(parsedContext, date, selectedTime); } return null; - }, [context, date, roomFilter, selectedTime]); + }, [parsedContext, date, roomFilter, selectedTime]); const endTimeOptions = useMemo(() => { if (!selectedTime || !bookedRoom) return []; - return calculateEndTimeOptions(context, date, selectedTime, bookedRoom.id); - }, [context, date, selectedTime, bookedRoom]); + return calculateEndTimeOptions( + parsedContext, + date, + selectedTime, + bookedRoom.id, + ); + }, [parsedContext, date, selectedTime, bookedRoom]); const clearSelection = () => setSelectedTime(null); diff --git a/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx b/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx index 9d51542..a514bee 100644 --- a/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx +++ b/frontend/src/studentportalen/ComponentLibrary/StartTimeGridSection.tsx @@ -68,7 +68,7 @@ export default function StartTimeGridSection() { onChange={booking.setSelectedTime} >