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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^7.4.1"
|
||||
"react-router": "^7.4.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
export default function Studentportalen() {
|
||||
@ -8,7 +8,8 @@ export default function Studentportalen() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route index element={<ComponentLibrary />} />
|
||||
<Route path="components" element={<ComponentLibrary />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</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 {
|
||||
--color-su-primary: #002f5f;
|
||||
--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;
|
||||
}
|
||||
body {
|
||||
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;
|
||||
right: 0;
|
||||
display: flex;
|
||||
z-index: 40;
|
||||
li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user