diff --git a/.cursor/rules/form-spacing.mdc b/.cursor/rules/form-spacing.mdc new file mode 100644 index 0000000..5270095 --- /dev/null +++ b/.cursor/rules/form-spacing.mdc @@ -0,0 +1,47 @@ +--- +description: Form spacing hierarchy — small/medium/large rhythm for settings, modals, and auth forms +globs: src/app/**/*.{ts,tsx},src/app/_components/**/*.{ts,tsx} +alwaysApply: false +--- + +# Form spacing + +Import tokens from `~/lib/form-spacing` (`formSpacing.tight` / `.stack` / `.section`). Do not invent one-off `space-y-*` values for form layout. + +## Scale + +| Level | Token | Use for | +|---|---|---| +| Small | `formSpacing.tight` (`space-y-1.5`) | Title→description, label→input, input→helper/error, avatar→upload copy | +| Medium | `formSpacing.stack` (`space-y-5`) | Related controls in the same section (name→username, password fields) | +| Large | `formSpacing.section` (`space-y-8`) | Distinct sections (photo vs profile info) and content→actions | + +Horizontal siblings in a row use the matching `*Gap` token (`tightGap` / `stackGap` / `sectionGap`). + +## Grouping + +Think in logical groups, not identical gaps between every control: + +```tsx +
{error}
+ ) : status ? ( + status + ) : description ? ( +{description}
+ ) : null} +{rootError}
+ ) : successMessage ? ( ++ {successMessage} +
+ ) : null} ++ We couldn't load your account details. Try again in a moment. +
+ ); +} + +function DialogSection({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +{description}
++ {draft.error} +
+ ) : ( ++ PNG, JPEG, WebP, or GIF up to {AVATAR_MAX_SIZE_LABEL}. +
+ )} +{schemaError}
+ ) : showAvailability ? ( +- Add a username in Account to use your real URL path. -
- ) : null} -+ Add a username in Account to use your real URL path. +
+ ) : null} +{helperText}
++ {helperText} +
) : null} ); diff --git a/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx new file mode 100644 index 0000000..3e7146e --- /dev/null +++ b/src/app/_components/mobile-drawer/mobile-drawer-field-view.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { + useEffect, + useRef, + type FormEvent, + type ReactNode, +} from "react"; + +import { cn } from "~/lib/utils"; + +import { MobileDrawerEditBody } from "./mobile-drawer-edit-body"; +import { MobileDrawerNavHeader } from "./mobile-drawer-nav-header"; +import { useMobileDrawerLeave } from "./use-mobile-drawer-leave"; +import { focusMobileDrawerInput } from "./utils"; + +/** Size/touch overrides only — surface styles come from `Input` / `formControlClassName`. */ +export const MOBILE_DRAWER_FIELD_INPUT_CLASS = cn( + "h-10 w-full rounded-lg px-3 text-base", +); + +export type MobileDrawerFieldViewProps = { + title: string; + children: ReactNode; + onBack: () => void; + onDone: () => void; + helperText?: ReactNode; + helperTextId?: string; + /** Field status/error associated via `aria-describedby` (see EditBody). */ + description?: ReactNode; + descriptionId?: string; + backLabel?: string; + doneLabel?: ReactNode; + disabled?: boolean; + doneDisabled?: boolean; + doneClassName?: string; + /** Autofocus the first text input/textarea on mount. Default true. */ + autoFocus?: boolean; + /** + * When true (default), Done also waits for keyboard dismiss before `onDone`. + * Set false for async save flows that call `useMobileDrawerLeave()` after + * a successful save. + */ + dismissKeyboardOnDone?: boolean; + /** Wrap content in a `