init project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user