init project

This commit is contained in:
root
2026-06-25 19:06:59 -04:00
parent c5dfee6efb
commit 5d017f533a
349 changed files with 31330 additions and 0 deletions
@@ -0,0 +1,77 @@
.choice {
display: flex;
min-block-size: 2.75rem;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
}
.choice > input:not(.switchInput) {
inline-size: 1.25rem;
block-size: 1.25rem;
margin-block-start: 0.2rem;
accent-color: var(--interactive-primary);
}
.content {
display: grid;
gap: var(--space-1);
}
.label {
font-weight: 700;
}
.description {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.invalid .label {
color: var(--status-error-text);
}
.switchInput {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
}
.switchTrack {
position: relative;
flex: none;
inline-size: 2.75rem;
block-size: 1.5rem;
margin-block-start: 0.1rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
background: var(--surface-strong);
transition: background var(--duration-fast) var(--easing-productive);
}
.switchTrack span {
position: absolute;
inset-block-start: 0.18rem;
inset-inline-start: 0.2rem;
inline-size: 1rem;
block-size: 1rem;
border-radius: 50%;
background: var(--surface-elevated);
box-shadow: var(--shadow-subtle);
transition: transform var(--duration-fast) var(--easing-productive);
}
.switchInput:checked + .switchTrack {
background: var(--interactive-primary);
}
.switchInput:checked + .switchTrack span {
transform: translateX(1.2rem);
}
html[dir='rtl'] .switchInput:checked + .switchTrack span {
transform: translateX(-1.2rem);
}
.switchInput:focus-visible + .switchTrack {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.switchInput:disabled + .switchTrack {
opacity: var(--opacity-disabled);
}
@media (prefers-reduced-motion: reduce) {
.switchTrack,
.switchTrack span {
transition: none;
}
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import type { InputHTMLAttributes, ReactNode } from 'react';
import styles from './ChoiceControls.module.css';
interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label: ReactNode;
description?: ReactNode;
invalid?: boolean;
}
function Choice({
label,
description,
invalid = false,
className,
type,
...props
}: ChoiceProps & { type: 'checkbox' | 'radio' }) {
return (
<label className={classNames(styles.choice, invalid && styles.invalid, className)}>
<input type={type} aria-invalid={invalid || undefined} {...props} />
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
export function Checkbox(props: ChoiceProps) {
return <Choice type="checkbox" {...props} />;
}
export function Radio(props: ChoiceProps) {
return <Choice type="radio" {...props} />;
}
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'role'> {
label: ReactNode;
description?: ReactNode;
}
export function Switch({ label, description, className, ...props }: SwitchProps) {
return (
<label className={classNames(styles.choice, className)}>
<input type="checkbox" role="switch" className={styles.switchInput} {...props} />
<span aria-hidden="true" className={styles.switchTrack}>
<span />
</span>
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
@@ -0,0 +1,24 @@
.summary {
padding: var(--space-5);
border: 2px solid var(--status-error-text);
border-radius: var(--radius-sm);
background: var(--status-error-surface);
color: var(--status-error-text);
}
.heading {
display: flex;
align-items: center;
gap: var(--space-2);
}
.heading h2 {
margin: 0;
font-size: var(--type-heading-3-size);
}
.summary ul {
margin-block-end: 0;
padding-inline-start: var(--space-6);
}
.summary a {
color: currentColor;
font-weight: 700;
}
+31
View File
@@ -0,0 +1,31 @@
import { Icon } from '@/components/foundation/Icon';
import styles from './ErrorSummary.module.css';
export interface FormErrorItem {
fieldId: string;
message: string;
}
export function ErrorSummary({ title, errors }: { title: string; errors: FormErrorItem[] }) {
if (errors.length === 0) return null;
return (
<section
className={styles.summary}
role="alert"
aria-labelledby="form-error-summary-title"
tabIndex={-1}
>
<div className={styles.heading}>
<Icon name="error" />
<h2 id="form-error-summary-title">{title}</h2>
</div>
<ul>
{errors.map((error) => (
<li key={error.fieldId}>
<a href={`#${error.fieldId}`}>{error.message}</a>
</li>
))}
</ul>
</section>
);
}
+47
View File
@@ -0,0 +1,47 @@
.field {
display: grid;
gap: var(--space-2);
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
font-weight: 700;
}
.required {
color: var(--status-error-text);
}
.optional {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 500;
}
.help,
.error {
margin: 0;
font-size: var(--type-label-size);
}
.help {
color: var(--text-secondary);
}
.error {
color: var(--status-error-text);
font-weight: 650;
}
.invalid .label {
color: var(--status-error-text);
}
.fieldset {
display: grid;
gap: var(--space-4);
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-block-end: var(--space-3);
padding: 0;
font-weight: 750;
}
+69
View File
@@ -0,0 +1,69 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './FormField.module.css';
interface FormFieldProps {
id: string;
label: string;
required?: boolean;
optionalLabel?: string;
supportingText?: string;
error?: string;
children: ReactNode;
className?: string;
}
export function FormField({
id,
label,
required = false,
optionalLabel,
supportingText,
error,
children,
className,
}: FormFieldProps) {
return (
<div className={classNames(styles.field, error && styles.invalid, className)}>
<label className={styles.label} htmlFor={id}>
<span>{label}</span>
{required ? (
<span aria-hidden="true" className={styles.required}>
*
</span>
) : null}
{!required && optionalLabel ? (
<span className={styles.optional}>{optionalLabel}</span>
) : null}
</label>
{children}
{supportingText ? (
<p id={`${id}-help`} className={styles.help}>
{supportingText}
</p>
) : null}
{error ? (
<p id={`${id}-error`} className={styles.error}>
<span aria-hidden="true">!</span> {error}
</p>
) : null}
</div>
);
}
export function Fieldset({
legend,
children,
className,
}: {
legend: string;
children: ReactNode;
className?: string;
}) {
return (
<fieldset className={classNames(styles.fieldset, className)}>
<legend className={styles.legend}>{legend}</legend>
{children}
</fieldset>
);
}
+43
View File
@@ -0,0 +1,43 @@
.control {
inline-size: 100%;
min-block-size: 2.875rem;
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-primary);
color: var(--text-primary);
}
.control::placeholder {
color: var(--text-subdued);
opacity: 1;
}
.control:hover:not(:disabled) {
border-color: var(--interactive-primary);
}
.control[aria-invalid='true'] {
border-color: var(--status-error-text);
box-shadow: 0 0 0 1px var(--status-error-text);
}
.control:disabled {
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.textarea {
min-block-size: 8rem;
resize: vertical;
}
.textareaGroup {
display: grid;
gap: var(--space-1);
}
.count {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-caption-size);
text-align: end;
}
.select {
appearance: auto;
}
+91
View File
@@ -0,0 +1,91 @@
import { BidiText } from '@/components/foundation/BidiText';
import { classNames } from '@/lib/components/classNames';
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
import styles from './TextInput.module.css';
export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
type?: TextInputType;
invalid?: boolean;
describedBy?: string;
bidiValue?: boolean;
}
export function TextInput({
type = 'text',
invalid = false,
describedBy,
bidiValue = false,
className,
...props
}: TextInputProps) {
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
return (
<input
type={type}
dir={dir}
className={classNames(styles.control, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
/>
);
}
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
invalid?: boolean;
describedBy?: string;
characterCount?: { current: number; max: number; label: string };
}
export function TextArea({
invalid = false,
describedBy,
characterCount,
className,
...props
}: TextAreaProps) {
const countId = characterCount && props.id ? `${props.id}-count` : undefined;
const ids = [describedBy, countId].filter(Boolean).join(' ') || undefined;
return (
<div className={styles.textareaGroup}>
<textarea
className={classNames(styles.control, styles.textarea, className)}
aria-invalid={invalid || undefined}
aria-describedby={ids}
{...props}
/>
{characterCount ? (
<p id={countId} className={styles.count} aria-live="polite">
<BidiText>{`${characterCount.current}/${characterCount.max}`}</BidiText>{' '}
{characterCount.label}
</p>
) : null}
</div>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
invalid?: boolean;
describedBy?: string;
}
export function Select({
invalid = false,
describedBy,
className,
children,
...props
}: SelectProps) {
return (
<select
className={classNames(styles.control, styles.select, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
>
{children}
</select>
);
}