List components #31
733
frontend/package-lock.json
generated
733
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,10 +12,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router": "^7.4.1"
|
"react-router": "^7.4.1",
|
||||||
|
"tailwindcss": "^4.1.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.21.0",
|
"@eslint/js": "^9.21.0",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter, Route, Routes } from "react-router";
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
import Home from "./studentportalen/Home.tsx";
|
import ComponentLibrary from "./studentportalen/ComponentLibrary.tsx";
|
||||||
import Layout from "./studentportalen/Layout.tsx";
|
import Layout from "./studentportalen/Layout.tsx";
|
||||||
|
|
||||||
export default function Studentportalen() {
|
export default function Studentportalen() {
|
||||||
@ -8,7 +8,8 @@ export default function Studentportalen() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<ComponentLibrary />} />
|
||||||
|
<Route path="components" element={<ComponentLibrary />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
BIN
frontend/src/assets/TheSansB-W2ExtraLight.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W2ExtraLight.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W2ExtraLightItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W2ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W3Light.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W3Light.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W3LightItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W3LightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W4SemiLight.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W4SemiLight.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W4SemiLightItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W4SemiLightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W5Plain.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W5Plain.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W5PlainItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W5PlainItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W6SemiBold.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W6SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W6SemiBoldItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W6SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W7Bold.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W7Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W7BoldItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W7BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W8ExtraBold.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W8ExtraBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W8ExtraBoldItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W8ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W9Black.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W9Black.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/TheSansB-W9BlackItalic.woff2
Normal file
BIN
frontend/src/assets/TheSansB-W9BlackItalic.woff2
Normal file
Binary file not shown.
57
frontend/src/components/Button/Button.tsx
Normal file
57
frontend/src/components/Button/Button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "red" | "green";
|
||||||
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-primary text-base-canvas border border-primary hover:bg-secondary hover:text-base-canvas hover:border hover:border-primary focus-visible:bg-base-canvas focus-visible:text-base-ink-strong focus-visible:border focus-visible:border-primary focus-visible:outline focus-visible:outline-[length:var(--border-width-lg)] focus-visible:outline-sky-100",
|
||||||
|
secondary:
|
||||||
|
"bg-base-canvas text-base-ink-strong border-solid [border-width:var(--border-width-sm)] border-base-ink-soft hover:bg-base-canvas hover:text-base-ink-strong hover:border-base-ink-medium focus-visible:bg-base-canvas focus-visible:text-base-ink-strong focus-visible:border-primary focus-visible:outline focus-visible:outline-[length:var(--border-width-lg)] focus-visible:outline-sky-100",
|
||||||
|
red: "bg-other-red-100 text-su-white focus-visible:bg-base-canvas focus-visible:text-base-ink-strong focus-visible:border-primary focus-visible:border focus-visible:outline focus-visible:outline-[length:var(--border-width-lg)] focus-visible:outline-sky-100",
|
||||||
|
green:
|
||||||
|
"bg-other-green text-su-white focus-visible:bg-base-canvas focus-visible:text-base-ink-strong focus-visible:border-primary focus-visible:border focus-visible:outline focus-visible:outline-[length:var(--border-width-lg)] focus-visible:outline-sky-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: "h-(--control-height-sm) min-w-(--button-min-width-sm) px-(--button-padding-x-sm) body-bold-md rounded-(--border-radius-sm)",
|
||||||
|
md: "h-(--control-height-md) min-w-(--button-min-width-md) px-(--button-padding-x-md) body-bold-md rounded-(--border-radius-sm)",
|
||||||
|
lg: "h-(--control-height-lg) min-w-(--button-min-width-lg) px-(--button-padding-x-lg) body-bold-lg rounded-(--border-radius-md)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const textPaddingClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: "px-(--button-text-padding-x-sm)",
|
||||||
|
md: "px-(--button-text-padding-x-md)",
|
||||||
|
lg: "px-(--button-text-padding-x-lg)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const baseClasses = "inline-flex items-center justify-center cursor-pointer";
|
||||||
|
|
||||||
|
const classes = [
|
||||||
|
baseClasses,
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes} {...props}>
|
||||||
|
<span className={textPaddingClasses[size]}>{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
frontend/src/components/Combobox/Combobox.tsx
Normal file
243
frontend/src/components/Combobox/Combobox.tsx
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import TextInput from '../TextInput/TextInput';
|
||||||
|
import SearchResultList, { type SearchResultOption } from '../SearchResultList/SearchResultList';
|
||||||
|
|
||||||
|
export type ComboboxOption = SearchResultOption;
|
||||||
|
|
||||||
|
export type ComboboxSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
export interface ComboboxProps {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
size?: ComboboxSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
dropdownHeight?: number;
|
||||||
|
noResultsText?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (value: string | string[]) => void;
|
||||||
|
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||||
|
onSearchChange?: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthClasses: Record<ComboboxSize, string> = {
|
||||||
|
sm: 'w-(--text-input-default-width-md)',
|
||||||
|
md: 'w-(--text-input-default-width-md)',
|
||||||
|
lg: 'w-(--text-input-default-width-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownWrapperClasses = 'absolute top-full left-0 z-50 w-full mt-(--spacing-sm)';
|
||||||
|
|
||||||
|
function SearchIcon({ style }: { style?: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={style}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="M21 21l-4.35-4.35" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClickOutside: () => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
onClickOutside();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [ref, onClickOutside]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useScrollIntoView(index: number, refs: React.RefObject<(HTMLDivElement | null)[]>) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (index >= 0 && refs.current[index]) {
|
||||||
|
refs.current[index]?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [index, refs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterOptions(options: ComboboxOption[], searchTerm: string): ComboboxOption[] {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return options.filter(
|
||||||
|
(opt) => opt.label.toLowerCase().includes(term) || opt.subtitle?.toLowerCase().includes(term),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextIndex(currentIndex: number, direction: 'up' | 'down', maxIndex: number): number {
|
||||||
|
const next = currentIndex + (direction === 'down' ? 1 : -1);
|
||||||
|
return Math.max(0, Math.min(next, maxIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayValue(
|
||||||
|
options: ComboboxOption[],
|
||||||
|
selectedValues: string[],
|
||||||
|
multiple: boolean,
|
||||||
|
searchTerm: string,
|
||||||
|
): string {
|
||||||
|
if (searchTerm) return searchTerm;
|
||||||
|
|
||||||
|
if (multiple) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.length === 0) return '';
|
||||||
|
|
||||||
|
return options.find((opt) => opt.value === selectedValues[0])?.label || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Combobox({
|
||||||
|
options,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
label,
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
dropdownHeight = 300,
|
||||||
|
noResultsText = 'No results found',
|
||||||
|
multiple = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSearchChange,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
// Normalize value to array for internal use
|
||||||
|
const selectedValues: string[] = value === undefined ? [] : Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Derived state - skip local filtering when onSearchChange is provided (API handles filtering)
|
||||||
|
const filteredOptions = onSearchChange ? options : filterOptions(options, searchTerm);
|
||||||
|
const displayValue = getDisplayValue(options, selectedValues, multiple, searchTerm);
|
||||||
|
|
||||||
|
const closeDropdown = () => setIsOpen(false);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
useClickOutside(containerRef, closeDropdown);
|
||||||
|
useScrollIntoView(focusedIndex, itemRefs);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSearchTerm(value);
|
||||||
|
onSearchChange?.(value);
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
// Clear selection when user empties the field in single-select mode
|
||||||
|
if (!multiple && selectedValues.length > 0) {
|
||||||
|
onChange?.('');
|
||||||
|
}
|
||||||
|
closeDropdown();
|
||||||
|
} else {
|
||||||
|
setIsOpen(true);
|
||||||
|
setFocusedIndex(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (option: ComboboxOption) => {
|
||||||
|
if (multiple) {
|
||||||
|
// Toggle selection in multi-select mode
|
||||||
|
const newValues = selectedValues.includes(option.value)
|
||||||
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
|
: [...selectedValues, option.value];
|
||||||
|
onChange?.(newValues);
|
||||||
|
} else {
|
||||||
|
// Replace selection in single-select mode
|
||||||
|
onChange?.(option.value);
|
||||||
|
}
|
||||||
|
setSearchTerm(''); // Clear search so selected label shows
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Open dropdown on arrow keys when closed (only if we have options to show)
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
if (filteredOptions.length > 0) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle navigation when open
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => getNextIndex(prev, 'down', filteredOptions.length - 1));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusedIndex((prev) => getNextIndex(prev, 'up', filteredOptions.length - 1));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
|
||||||
|
handleSelect(filteredOptions[focusedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
case 'Tab':
|
||||||
|
closeDropdown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerClasses = fullWidth
|
||||||
|
? 'relative w-full'
|
||||||
|
: customWidth
|
||||||
|
? 'relative'
|
||||||
|
: `relative ${widthClasses[size]}`;
|
||||||
|
const containerStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={containerClasses} style={containerStyle}>
|
||||||
|
<TextInput
|
||||||
|
value={displayValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={closeDropdown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
size={size}
|
||||||
|
fullWidth={fullWidth || !!customWidth}
|
||||||
|
customWidth={customWidth}
|
||||||
|
icon={<SearchIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={dropdownWrapperClasses}>
|
||||||
|
<SearchResultList
|
||||||
|
options={filteredOptions}
|
||||||
|
selectedValues={selectedValues}
|
||||||
|
focusedIndex={focusedIndex}
|
||||||
|
maxHeight={dropdownHeight}
|
||||||
|
noResultsText={noResultsText}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
itemRefs={itemRefs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/ListCard/ListCard.tsx
Normal file
66
frontend/src/components/ListCard/ListCard.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { HTMLAttributes, CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export interface ListCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
onRemove?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = [
|
||||||
|
'px-(--padding-md) py-(--padding-md)',
|
||||||
|
'bg-sky-35 border border-sky-100 rounded-(--border-radius-md)',
|
||||||
|
'flex items-center justify-between',
|
||||||
|
'group text-base-ink-strong hover:bg-sky-70 hover:text-base-ink-max focus-visible:text-base-ink-max',
|
||||||
|
'focus-visible:border-primary focus-visible:border-[length:var(--border-width-sm)] focus-visible:outline focus-visible:outline-sky-100 focus-visible:outline-[length:var(--border-width-lg)]',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const iconStyles: CSSProperties = {
|
||||||
|
width: 'var(--font-size-body-md)',
|
||||||
|
height: 'var(--font-size-body-md)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RemoveIcon({ style }: { style?: CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={style}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListCard({ title, subtitle = '', onRemove, className = '', ...props }: ListCardProps) {
|
||||||
|
const classes = [baseClasses, className].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && onRemove) {
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes} {...props} tabIndex={0} onKeyDown={handleKeyDown}>
|
||||||
|
<div>
|
||||||
|
<div className="body-light-sm">{title}</div>
|
||||||
|
{subtitle && <div className="body-light-sm">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onRemove}
|
||||||
|
className="shrink-0 ml-(--spacing-sm) text-base-ink-placeholder group-hover:text-base-ink-max group-focus-visible:text-base-ink-max focus-visible:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<RemoveIcon style={iconStyles} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/components/ListItem/ListItem.tsx
Normal file
72
frontend/src/components/ListItem/ListItem.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import type { HTMLAttributes, CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export interface ListItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
focused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyles: CSSProperties = {
|
||||||
|
width: 'var(--font-size-body-md)',
|
||||||
|
height: 'var(--font-size-body-md)',
|
||||||
|
marginLeft: 'var(--spacing-sm)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CheckmarkIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={iconStyles}
|
||||||
|
>
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = 'w-full px-(--padding-md) py-(--padding-md) cursor-pointer flex items-center justify-between';
|
||||||
|
|
||||||
|
const defaultStateClasses =
|
||||||
|
'bg-base-canvas text-base-ink-strong hover:bg-sky-100 hover:text-base-ink-max focus-visible:bg-sky-100 focus-visible:text-primary focus-visible:outline-none';
|
||||||
|
|
||||||
|
const selectedStateClasses =
|
||||||
|
'bg-base-canvas text-base-ink-placeholder hover:bg-sky-100 hover:text-base-ink-max focus-visible:bg-sky-100 focus-visible:text-primary focus-visible:outline-none';
|
||||||
|
|
||||||
|
const focusedStateClasses = 'bg-sky-100 text-base-ink-max';
|
||||||
|
|
||||||
|
export default function ListItem({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
selected = false,
|
||||||
|
focused = false,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: ListItemProps) {
|
||||||
|
const getStateClasses = () => {
|
||||||
|
if (selected && focused) return focusedStateClasses;
|
||||||
|
if (selected) return selectedStateClasses;
|
||||||
|
if (focused) return focusedStateClasses;
|
||||||
|
return defaultStateClasses;
|
||||||
|
};
|
||||||
|
|
||||||
|
const classes = [baseClasses, getStateClasses(), className].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes} tabIndex={-1} role="option" aria-selected={selected} {...props}>
|
||||||
|
<div>
|
||||||
|
<div className="body-normal-md">{title}</div>
|
||||||
|
{subtitle && <div className="body-light-sm">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<CheckmarkIcon />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
import Combobox, { type ComboboxOption, type ComboboxSize } from '../Combobox/Combobox';
|
||||||
|
import ListCard from '../ListCard/ListCard';
|
||||||
|
|
||||||
|
export interface ParticipantPickerProps {
|
||||||
|
options: ComboboxOption[];
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
noResultsText?: string;
|
||||||
|
size?: ComboboxSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
/** Called when search term changes. When provided, local filtering is disabled (assumes API filtering). */
|
||||||
|
onSearchChange?: (term: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthClasses: Record<ComboboxSize, string> = {
|
||||||
|
sm: 'w-(--text-input-default-width-md)',
|
||||||
|
md: 'w-(--text-input-default-width-md)',
|
||||||
|
lg: 'w-(--text-input-default-width-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ParticipantPicker({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Sök...',
|
||||||
|
label,
|
||||||
|
noResultsText = 'Inga resultat',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
onSearchChange,
|
||||||
|
}: ParticipantPickerProps) {
|
||||||
|
const handleRemove = (valueToRemove: string) => {
|
||||||
|
onChange(value.filter((v) => v !== valueToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||||
|
|
||||||
|
const containerClasses = fullWidth
|
||||||
|
? 'flex flex-col gap-(--spacing-sm) w-full'
|
||||||
|
: customWidth
|
||||||
|
? 'flex flex-col gap-(--spacing-sm)'
|
||||||
|
: `flex flex-col gap-(--spacing-sm) ${widthClasses[size]}`;
|
||||||
|
const containerStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses} style={containerStyle}>
|
||||||
|
<Combobox
|
||||||
|
options={options}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v as string[])}
|
||||||
|
placeholder={placeholder}
|
||||||
|
label={label}
|
||||||
|
noResultsText={noResultsText}
|
||||||
|
size={size}
|
||||||
|
fullWidth
|
||||||
|
multiple
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
/>
|
||||||
|
{selectedOptions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
|
{selectedOptions.map((option) => (
|
||||||
|
<ListCard
|
||||||
|
key={option.value}
|
||||||
|
title={option.label}
|
||||||
|
subtitle={option.subtitle}
|
||||||
|
onRemove={() => handleRemove(option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
import ListItem from '../ListItem/ListItem';
|
||||||
|
|
||||||
|
export interface SearchResultOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResultListProps {
|
||||||
|
options: SearchResultOption[];
|
||||||
|
selectedValues?: string[];
|
||||||
|
focusedIndex?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
noResultsText?: string;
|
||||||
|
onSelect?: (option: SearchResultOption) => void;
|
||||||
|
itemRefs?: React.RefObject<(HTMLDivElement | null)[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClasses = [
|
||||||
|
'w-full bg-base-canvas border border-base-ink-medium rounded-(--border-radius-md)',
|
||||||
|
'overflow-y-auto',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const noResultsClasses = 'px-(--padding-md) py-(--padding-md) body-normal-md text-base-ink-placeholder text-center';
|
||||||
|
|
||||||
|
export default function SearchResultList({
|
||||||
|
options,
|
||||||
|
selectedValues = [],
|
||||||
|
focusedIndex = -1,
|
||||||
|
maxHeight = 300,
|
||||||
|
noResultsText = 'No results found',
|
||||||
|
onSelect,
|
||||||
|
itemRefs,
|
||||||
|
}: SearchResultListProps) {
|
||||||
|
const isSelected = (value: string) => selectedValues.includes(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses} style={{ maxHeight }} onMouseDown={(e) => e.preventDefault()}>
|
||||||
|
{options.length > 0 ? (
|
||||||
|
options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
ref={(el) => {
|
||||||
|
if (itemRefs?.current) {
|
||||||
|
itemRefs.current[index] = el;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
index > 0 ? 'border-t border-base-ink-soft [border-top-width:var(--border-width-sm)]' : ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={option.label}
|
||||||
|
subtitle={option.subtitle}
|
||||||
|
selected={isSelected(option.value)}
|
||||||
|
focused={index === focusedIndex}
|
||||||
|
onClick={() => onSelect?.(option)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={noResultsClasses}>{noResultsText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/TextInput/TextInput.tsx
Normal file
113
frontend/src/components/TextInput/TextInput.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import type { InputHTMLAttributes, ReactNode, CSSProperties } from 'react';
|
||||||
|
// isValidElement: checks if something is a React element (e.g., <svg>, <MyComponent />)
|
||||||
|
// cloneElement: creates a copy of a React element with modified/additional props
|
||||||
|
import { cloneElement, isValidElement } from 'react';
|
||||||
|
|
||||||
|
export type TextInputSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
// Omit<... 'size'> removes the native 'size' attribute from input elements so we can use our own
|
||||||
|
export interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
|
||||||
|
size?: TextInputSize;
|
||||||
|
icon?: ReactNode;
|
||||||
|
error?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
customWidth?: string;
|
||||||
|
label?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'h-(--control-height-sm)',
|
||||||
|
md: 'h-(--control-height-md)',
|
||||||
|
lg: 'h-(--control-height-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const widthClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'w-(--text-input-default-width-md)',
|
||||||
|
md: 'w-(--text-input-default-width-md)',
|
||||||
|
lg: 'w-(--text-input-default-width-lg)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const radiusClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'rounded-(--border-radius-sm)',
|
||||||
|
md: 'rounded-(--border-radius-sm)',
|
||||||
|
lg: 'rounded-(--border-radius-md)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textClasses: Record<TextInputSize, string> = {
|
||||||
|
sm: 'body-normal-md',
|
||||||
|
md: 'body-normal-md',
|
||||||
|
lg: 'body-normal-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconContainerStyles: Record<TextInputSize, CSSProperties> = {
|
||||||
|
sm: { width: 'var(--control-height-sm)', height: 'var(--control-height-sm)' },
|
||||||
|
md: { width: 'var(--control-height-md)', height: 'var(--control-height-md)' },
|
||||||
|
lg: { width: 'var(--control-height-lg)', height: 'var(--control-height-lg)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyles: Record<TextInputSize, CSSProperties> = {
|
||||||
|
sm: { width: 'var(--font-size-body-md)', height: 'var(--font-size-body-md)' },
|
||||||
|
md: { width: 'var(--font-size-body-md)', height: 'var(--font-size-body-md)' },
|
||||||
|
lg: { width: 'var(--font-size-body-lg)', height: 'var(--font-size-body-lg)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = 'bg-base-canvas border-[length:var(--border-width-sm)] border-base-ink-medium';
|
||||||
|
|
||||||
|
// focus-within: applies styles when any child element (the input) has focus
|
||||||
|
const defaultStateClasses =
|
||||||
|
'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 =
|
||||||
|
'border-fire-100 outline outline-fire-100 outline-[length:var(--border-width-sm)] focus-within:border-primary focus-within:outline focus-within:outline-[length:var(--border-width-lg)] focus-within:outline-sky-100';
|
||||||
|
|
||||||
|
export default function TextInput({
|
||||||
|
size = 'md',
|
||||||
|
icon,
|
||||||
|
error = false,
|
||||||
|
fullWidth = false,
|
||||||
|
customWidth,
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
className = '',
|
||||||
|
...props
|
||||||
|
}: TextInputProps) {
|
||||||
|
const widthClass = fullWidth ? 'w-full' : widthClasses[size];
|
||||||
|
const widthStyle = customWidth ? { width: customWidth } : undefined;
|
||||||
|
const stateClasses = error ? errorStateClasses : defaultStateClasses;
|
||||||
|
const inputPadding = icon ? 'pr-(--padding-md)' : 'px-(--padding-md)';
|
||||||
|
|
||||||
|
const inputField = (
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${baseClasses} ${heightClasses[size]} ${widthClass} ${radiusClasses[size]} ${stateClasses} ${className}`}
|
||||||
|
style={widthStyle}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center shrink-0 text-base-ink-placeholder"
|
||||||
|
style={iconContainerStyles[size]}
|
||||||
|
>
|
||||||
|
{isValidElement<{ style?: CSSProperties }>(icon)
|
||||||
|
? cloneElement(icon, { style: iconStyles[size] })
|
||||||
|
: icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
className={`flex-1 min-w-0 h-full bg-transparent border-none outline-none text-base-ink-max placeholder:text-base-ink-placeholder ${inputPadding} ${textClasses[size]}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!label && !message) {
|
||||||
|
return inputField;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-(--spacing-sm)">
|
||||||
|
{label && <label className="body-bold-md text-base-ink-strong">{label}</label>}
|
||||||
|
{inputField}
|
||||||
|
{message && <span className="body-light-sm text-base-ink-strong">{message}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,222 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* TheSans Font Family */
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans";
|
||||||
|
src: url("./assets/TheSansB-W5Plain.woff2") format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans";
|
||||||
|
src: url("./assets/TheSansB-W5PlainItalic.woff2") format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans Light";
|
||||||
|
src: url("./assets/TheSansB-W3Light.woff2") format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans Light";
|
||||||
|
src: url("./assets/TheSansB-W3LightItalic.woff2") format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans SemiLight";
|
||||||
|
src: url("./assets/TheSansB-W4SemiLight.woff2") format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans SemiLight";
|
||||||
|
src: url("./assets/TheSansB-W4SemiLightItalic.woff2") format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans Plain";
|
||||||
|
src: url("./assets/TheSansB-W5Plain.woff2") format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans Plain";
|
||||||
|
src: url("./assets/TheSansB-W5PlainItalic.woff2") format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans SemiBold";
|
||||||
|
src: url("./assets/TheSansB-W6SemiBold.woff2") format("woff2");
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "TheSans SemiBold";
|
||||||
|
src: url("./assets/TheSansB-W6SemiBoldItalic.woff2") format("woff2");
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Colors */
|
||||||
|
--color-primary: #05305d;
|
||||||
|
--color-base-canvas: #ffffff;
|
||||||
|
--color-secondary: #34587f;
|
||||||
|
--color-sky-100: #b0dee4;
|
||||||
|
--color-sky-70: #c7e8ed;
|
||||||
|
--color-sky-35: #e4f4f7;
|
||||||
|
--color-sky-20: #eff9fa;
|
||||||
|
--color-base-ink-max: #000000;
|
||||||
|
--color-base-ink-strong: #4b4b4b;
|
||||||
|
--color-base-ink-medium: #bababa;
|
||||||
|
--color-base-ink-soft: #dadada;
|
||||||
|
--color-base-ink-placeholder: #757575;
|
||||||
|
--color-other-red-100: #aa1227;
|
||||||
|
--color-other-red-10: #f6e6e8;
|
||||||
|
--color-other-green: #539848;
|
||||||
|
--color-su-white: #ffffff;
|
||||||
|
--color-fire-100: #eb7124;
|
||||||
|
--color-fire-70: #f19b66;
|
||||||
|
--color-fire-35: #f8cdb4;
|
||||||
|
--color-fire-20: #fbe2d3;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--font-size-body-md: 16px;
|
||||||
|
--font-size-body-lg: 18px;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--border-radius-sm: 3px;
|
||||||
|
--border-radius-md: 4px;
|
||||||
|
--border-radius-lg: 6px;
|
||||||
|
--border-radius-xl: 8px;
|
||||||
|
|
||||||
|
/* Border width */
|
||||||
|
--border-width-sm: 1px;
|
||||||
|
--border-width-lg: 3px;
|
||||||
|
|
||||||
|
/* Padding */
|
||||||
|
--padding-md: 12px;
|
||||||
|
--padding-lg: 24px;
|
||||||
|
--padding-xl: 48px;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 12px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
|
||||||
|
/* Control heights */
|
||||||
|
--control-height-sm: 32px;
|
||||||
|
--control-height-md: 40px;
|
||||||
|
--control-height-lg: 48px;
|
||||||
|
|
||||||
|
/* Button padding x */
|
||||||
|
--button-padding-x-sm: 6px;
|
||||||
|
--button-padding-x-md: 10px;
|
||||||
|
--button-padding-x-lg: 14px;
|
||||||
|
|
||||||
|
/* Button min width */
|
||||||
|
--button-min-width-sm: 72px;
|
||||||
|
--button-min-width-md: 72px;
|
||||||
|
--button-min-width-lg: 84px;
|
||||||
|
|
||||||
|
/* Button text padding x */
|
||||||
|
--button-text-padding-x-sm: 6px;
|
||||||
|
--button-text-padding-x-md: 6px;
|
||||||
|
--button-text-padding-x-lg: 6px;
|
||||||
|
|
||||||
|
/* Text input default width */
|
||||||
|
--text-input-default-width-md: 194px;
|
||||||
|
--text-input-default-width-lg: 218px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--color-primary: #ffffff;
|
||||||
|
--color-base-canvas: #000000;
|
||||||
|
--color-secondary: #d9d6d6;
|
||||||
|
--color-sky-100: #403d3d;
|
||||||
|
--color-sky-70: #2d2b2b;
|
||||||
|
--color-sky-35: #1f1e1e;
|
||||||
|
--color-sky-20: #141414;
|
||||||
|
--color-base-ink-max: #ffffff;
|
||||||
|
--color-base-ink-strong: #ffffff;
|
||||||
|
--color-base-ink-medium: #636363;
|
||||||
|
--color-base-ink-soft: #555555;
|
||||||
|
--color-base-ink-placeholder: #959595;
|
||||||
|
--color-other-red-100: #aa1227;
|
||||||
|
--color-other-red-10: #f6e6e8;
|
||||||
|
--color-other-green: #539848;
|
||||||
|
--color-su-white: #ffffff;
|
||||||
|
--color-fire-100: #eb7124;
|
||||||
|
--color-fire-70: #f19b66;
|
||||||
|
--color-fire-35: #f8cdb4;
|
||||||
|
--color-fire-20: #fbe2d3;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--color-su-primary: #002f5f;
|
--color-su-primary: #002f5f;
|
||||||
--color-su-primary-80: #33587f;
|
--color-su-primary-80: #33587f;
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
--bottom-nav-height: 4.5rem;
|
||||||
|
font-family: 'TheSans', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text styles - Body */
|
||||||
|
.body-light-sm {
|
||||||
|
font-family: "TheSans Light", "TheSans", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-normal-md {
|
||||||
|
font-family: "TheSans SemiLight", "TheSans", system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-normal-lg {
|
||||||
|
font-family: 'TheSans SemiLight', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-semibold-md {
|
||||||
|
font-family: 'TheSans Plain', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-semibold-lg {
|
||||||
|
font-family: 'TheSans Plain', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-bold-md {
|
||||||
|
font-family: 'TheSans SemiBold', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-bold-lg {
|
||||||
|
font-family: 'TheSans SemiBold', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text styles - Heading */
|
||||||
|
.heading-semibold-lg {
|
||||||
|
font-family: 'TheSans SemiBold', 'TheSans', system-ui, sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
background-color: #141414;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
359
frontend/src/studentportalen/ComponentLibrary.tsx
Normal file
359
frontend/src/studentportalen/ComponentLibrary.tsx
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Button from '../components/Button/Button';
|
||||||
|
import TextInput from '../components/TextInput/TextInput';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const peopleOptions = [
|
||||||
|
{ 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 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" />
|
||||||
|
<TextInput size="md" placeholder="Medium" />
|
||||||
|
<TextInput size="lg" placeholder="Large" />
|
||||||
|
</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={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="md"
|
||||||
|
placeholder="Medium with icon"
|
||||||
|
icon={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
size="lg"
|
||||||
|
placeholder="Large with icon"
|
||||||
|
icon={
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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" />
|
||||||
|
<TextInput placeholder="Error state" error />
|
||||||
|
</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" />
|
||||||
|
<TextInput />
|
||||||
|
</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 />
|
||||||
|
<TextInput placeholder="Custom width" customWidth="300px" />
|
||||||
|
</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}
|
||||||
|
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={[]}
|
||||||
|
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}
|
||||||
|
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}
|
||||||
|
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}
|
||||||
|
placeholder="Small"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
placeholder="Medium"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
placeholder="Large"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">Combobox - Custom Width</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
placeholder="Sök..."
|
||||||
|
customWidth="350px"
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={peopleOptions}
|
||||||
|
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}
|
||||||
|
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}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Small"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Medium"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Large"
|
||||||
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-lg">
|
||||||
|
<h2 className="mb-md">ParticipantPicker - Custom Width</h2>
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
value={participants}
|
||||||
|
onChange={setParticipants}
|
||||||
|
placeholder="Sök..."
|
||||||
|
customWidth="350px"
|
||||||
|
/>
|
||||||
|
<ParticipantPicker
|
||||||
|
options={peopleOptions}
|
||||||
|
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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ menu.main {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
z-index: 40;
|
||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from 'vite';
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user