From 22d592b61e3dda7afd138c1643ee21bf9f8cb244 Mon Sep 17 00:00:00 2001 From: Croco Dendy Date: Tue, 4 Aug 2026 17:01:27 +0000 Subject: [PATCH 01/10] feat(input): add Input and Textarea form controls Input and Textarea ship the label/helperText/error/size (sm/md) API with native DOM onChange, matching the paper-ui call sites. The control id is auto-generated with useId unless the caller passes one, and the label wires to it via htmlFor. helperText and error share one line: error replaces helperText and flips aria-invalid plus aria-describedby, and the inset border re-tints to --fui-status-error-text. size intentionally shadows the native input size attribute (numeric px), hence InputProps Omit<>s it from InputHTMLAttributes. The frosted inset-bordered look mirrors Card and secondary Button: --fui-surface fill with backdrop blur that resolves to none inside Glass, and a 2px inset in --fui-shadow-color that goes accent on focus. Visual check of sm/md proportions is left to a human (headless run); verification was check-types + lint + build only. --- papercamp/ideas/IDEA-2.md | 2 +- src/components/input/index.ts | 2 + src/components/input/input.module.scss | 74 +++++++++++++++++++ src/components/input/input.tsx | 44 ++++++++++++ src/components/textarea/index.ts | 2 + src/components/textarea/textarea.module.scss | 76 ++++++++++++++++++++ src/components/textarea/textarea.tsx | 44 ++++++++++++ src/index.ts | 6 ++ 8 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/components/input/index.ts create mode 100644 src/components/input/input.module.scss create mode 100644 src/components/input/input.tsx create mode 100644 src/components/textarea/index.ts create mode 100644 src/components/textarea/textarea.module.scss create mode 100644 src/components/textarea/textarea.tsx diff --git a/papercamp/ideas/IDEA-2.md b/papercamp/ideas/IDEA-2.md index db73e1c..133ad3a 100644 --- a/papercamp/ideas/IDEA-2.md +++ b/papercamp/ideas/IDEA-2.md @@ -14,7 +14,7 @@ The medium-usage band from the paper-camp inventory: Input (8 files), Modal (6), API conventions from observed usage: Input/Textarea keep DOM-event `onChange` (native-like); Select takes an `options` array and value-based `onChange` (matching how all 7 call sites use it). Modal ships compound parts — `Modal.Body`, `Modal.Footer`, `Modal.Error` — because the identical body/footer/error-line scaffolding is copy-pasted across all six paper-camp modals today. ### Phases -- [ ] Input + Textarea (label, helperText, error, sm/md) +- [x] Input + Textarea (label, helperText, error, sm/md) - [ ] Select (options array, controlled value, keyboard nav, hidden native select for forms) - [ ] Checkbox + Switch - [ ] Modal (open/onClose/title/size, focus trap, scroll lock, Escape) with Body/Footer/Error compound parts diff --git a/src/components/input/index.ts b/src/components/input/index.ts new file mode 100644 index 0000000..188cbaa --- /dev/null +++ b/src/components/input/index.ts @@ -0,0 +1,2 @@ +export { Input } from './input'; +export type { InputProps } from './input'; diff --git a/src/components/input/input.module.scss b/src/components/input/input.module.scss new file mode 100644 index 0000000..2ccec9f --- /dev/null +++ b/src/components/input/input.module.scss @@ -0,0 +1,74 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.label { + font-family: $font-family-mono; + font-size: $font-size-sm; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.input { + width: 100%; + border: none; + background-color: var(--fui-surface); + color: var(--fui-text); + font-family: $font-family-sans; + line-height: 1.4; + outline: none; + box-shadow: inset 0 0 0 2px var(--fui-shadow-color); + backdrop-filter: var(--fui-nested-backdrop, blur(12px)); + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &::placeholder { + opacity: 0.5; + } + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-accent); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.invalid { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + } +} + +// ─── Sizes ───────────────────────────────────────────────────── +.md { + padding: $space-2 $space-3; + font-size: $font-size-md; +} + +.sm { + padding: $space-1 $space-3; + font-size: $font-size-sm; +} + +.message { + margin: 0; + font-size: $font-size-sm; + opacity: 0.7; +} + +.error { + opacity: 1; + color: var(--fui-status-error-text); +} diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx new file mode 100644 index 0000000..cd736ef --- /dev/null +++ b/src/components/input/input.tsx @@ -0,0 +1,44 @@ +import { type InputHTMLAttributes, forwardRef, useId } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './input.module.scss'; + +export interface InputProps extends Omit, 'size'> { + label?: string; + helperText?: string; + error?: string; + size?: 'sm' | 'md'; +} + +export const Input = forwardRef(function Input( + { label, helperText, error, size = 'md', id, className, ...props }, + ref, +) { + const uid = useId(); + const inputId = id ?? `fui-input-${uid}`; + const messageId = `fui-input-message-${uid}`; + const invalid = error != null; + const message = invalid ? error : helperText; + + return ( +
+ {label && ( + + )} + + {message != null && ( +

+ {message} +

+ )} +
+ ); +}); diff --git a/src/components/textarea/index.ts b/src/components/textarea/index.ts new file mode 100644 index 0000000..9b0ac27 --- /dev/null +++ b/src/components/textarea/index.ts @@ -0,0 +1,2 @@ +export { Textarea } from './textarea'; +export type { TextareaProps } from './textarea'; diff --git a/src/components/textarea/textarea.module.scss b/src/components/textarea/textarea.module.scss new file mode 100644 index 0000000..81af0ee --- /dev/null +++ b/src/components/textarea/textarea.module.scss @@ -0,0 +1,76 @@ +@use '../../styles/tokens' as *; +@use '../../styles/mixins' as *; + +.field { + display: flex; + flex-direction: column; + gap: $space-2; +} + +.label { + font-family: $font-family-mono; + font-size: $font-size-sm; + font-weight: 700; + text-transform: lowercase; + letter-spacing: 0.02em; +} + +.textarea { + width: 100%; + min-height: 6rem; + resize: vertical; + border: none; + background-color: var(--fui-surface); + color: var(--fui-text); + font-family: $font-family-sans; + line-height: 1.6; + outline: none; + box-shadow: inset 0 0 0 2px var(--fui-shadow-color); + backdrop-filter: var(--fui-nested-backdrop, blur(12px)); + transition: + box-shadow 120ms ease, + background-color 200ms ease; + + &::placeholder { + opacity: 0.5; + } + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-accent); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.invalid { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + + &:focus { + box-shadow: inset 0 0 0 2px var(--fui-status-error-text); + } +} + +// ─── Sizes ───────────────────────────────────────────────────── +.md { + padding: $space-2 $space-3; + font-size: $font-size-md; +} + +.sm { + padding: $space-1 $space-3; + font-size: $font-size-sm; +} + +.message { + margin: 0; + font-size: $font-size-sm; + opacity: 0.7; +} + +.error { + opacity: 1; + color: var(--fui-status-error-text); +} diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx new file mode 100644 index 0000000..a679daf --- /dev/null +++ b/src/components/textarea/textarea.tsx @@ -0,0 +1,44 @@ +import { type TextareaHTMLAttributes, forwardRef, useId } from 'react'; +import { cn } from '../../utils/style-helpers'; +import styles from './textarea.module.scss'; + +export interface TextareaProps extends TextareaHTMLAttributes { + label?: string; + helperText?: string; + error?: string; + size?: 'sm' | 'md'; +} + +export const Textarea = forwardRef(function Textarea( + { label, helperText, error, size = 'md', id, className, ...props }, + ref, +) { + const uid = useId(); + const inputId = id ?? `fui-textarea-${uid}`; + const messageId = `fui-textarea-message-${uid}`; + const invalid = error != null; + const message = invalid ? error : helperText; + + return ( +
+ {label && ( + + )} +