feat: Select, Checkbox, Toggle form primitives

This commit is contained in:
hsiegeln
2026-03-18 09:29:00 +01:00
parent c2f61543f1
commit e37a4d323c
6 changed files with 265 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
.wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
}
.box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg-raised);
flex-shrink: 0;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
.box::after {
content: '';
display: none;
width: 4px;
height: 7px;
border: 2px solid white;
border-top: none;
border-left: none;
transform: rotate(45deg) translateY(-1px);
}
.input:checked + .box {
background: var(--amber);
border-color: var(--amber);
}
.input:checked + .box::after {
display: block;
}
.input:focus-visible + .box {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.input:disabled + .box {
opacity: 0.6;
cursor: not-allowed;
}
.wrapper:has(.input:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
.label {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
}

View File

@@ -0,0 +1,28 @@
import styles from './Checkbox.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: ReactNode
id?: string
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ label, id, className, ...rest }, ref) => {
const inputId = id ?? `checkbox-${Math.random().toString(36).slice(2)}`
return (
<label className={`${styles.wrapper} ${className ?? ''}`} htmlFor={inputId}>
<input
ref={ref}
id={inputId}
type="checkbox"
className={styles.input}
{...rest}
/>
<span className={styles.box} aria-hidden="true" />
{label && <span className={styles.label}>{label}</span>}
</label>
)
},
)
Checkbox.displayName = 'Checkbox'

View File

@@ -0,0 +1,40 @@
.wrap {
position: relative;
display: inline-block;
width: 100%;
}
.select {
width: 100%;
padding: 6px 32px 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
appearance: none;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.select:focus {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.chevron {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
font-size: 11px;
pointer-events: none;
}

View File

@@ -0,0 +1,30 @@
import styles from './Select.module.css'
import { forwardRef, type SelectHTMLAttributes } from 'react'
interface SelectOption {
value: string
label: string
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: SelectOption[]
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ options, className, ...rest }, ref) => {
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
<select ref={ref} className={styles.select} {...rest}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className={styles.chevron} aria-hidden="true"></span>
</div>
)
},
)
Select.displayName = 'Select'

View File

@@ -0,0 +1,68 @@
.wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
margin: 0;
}
.track {
position: relative;
display: inline-block;
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--border);
border: 1px solid var(--border);
flex-shrink: 0;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.thumb {
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
box-shadow: var(--shadow-sm);
transition: transform 0.2s;
}
.input:checked + .track {
background: var(--amber);
border-color: var(--amber);
}
.input:checked + .track .thumb {
transform: translateX(14px);
}
.input:focus-visible + .track {
box-shadow: 0 0 0 3px var(--amber-bg);
border-color: var(--amber);
}
.input:disabled + .track {
opacity: 0.6;
}
.wrapper:has(.input:disabled) {
cursor: not-allowed;
opacity: 0.6;
}
.label {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-primary);
}

View File

@@ -0,0 +1,30 @@
import styles from './Toggle.module.css'
import { forwardRef, type InputHTMLAttributes } from 'react'
interface ToggleProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string
id?: string
}
export const Toggle = forwardRef<HTMLInputElement, ToggleProps>(
({ label, id, className, ...rest }, ref) => {
const inputId = id ?? `toggle-${Math.random().toString(36).slice(2)}`
return (
<label className={`${styles.wrapper} ${className ?? ''}`} htmlFor={inputId}>
<input
ref={ref}
id={inputId}
type="checkbox"
className={styles.input}
{...rest}
/>
<span className={styles.track} aria-hidden="true">
<span className={styles.thumb} />
</span>
{label && <span className={styles.label}>{label}</span>}
</label>
)
},
)
Toggle.displayName = 'Toggle'