List components #31

Open
stne3960 wants to merge 57 commits from list_item into main
30 changed files with 1949 additions and 67 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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;
}

View 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 />
</>
);
}

View File

@ -7,6 +7,7 @@ menu.main {
left: 0;
right: 0;
display: flex;
z-index: 40;
li {
list-style: none;
margin: 0;

View File

@ -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()],
});