Text input component #30

Open
stne3960 wants to merge 26 commits from text_input into main
24 changed files with 1215 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,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,150 @@
import { useState, useEffect } from 'react';
import Button from '../components/Button/Button';
import TextInput from '../components/TextInput/TextInput';
export default function ComponentLibrary() {
const [darkMode, setDarkMode] = useState(() => {
return document.documentElement.classList.contains('dark');
});
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>
<br />
<br />
<br />
<br />
</>
);
}

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