Skip to content

fix(forms): stop typing at each field's length limit instead of refusing it on submit - #1888

Merged
dawsontoth merged 4 commits into
claude/1294-placeholder-usabilityfrom
claude/1290-maxlength-attributes
Oct 12, 2026
Merged

dawsontoth merged 4 commits into
claude/1294-placeholder-usabilityfrom
claude/1290-maxlength-attributes

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

A beaver carving a name into a plank with a stop-block at the end

⊙ Problem

Several text fields cap their length in validation but let you type past the cap, and only refuse it once you submit or leave the field. The Role Name field in #1290 stops at 30 characters this way, and the same pattern shows up on emails, names, file names and table names. One form was also checking the wrong limits: Profile allowed 50/50 characters for first/last name, while central manager's validateUpdateUser allows 40/80. So a 45-character first name passed the form and the server then refused it, and a 60-character last name that sign-up accepted could never be saved from Profile again.

A hard maxLength has a problem of its own: the browser cuts a longer paste to fit and says nothing. A value pasted from a password manager or the clipboard can land cut short with nothing on screen to show it.

❓ Your call: is a maxLength per field the right fix? I went with the attribute because #1290 asks for it and because fields like NewOrgForm, ClusterName and CreateNewTableModal already use it, so this makes the rest match. Because the attribute on its own truncates silently, each field this PR limits also says when it reaches the limit and when a paste was cut (see Solution). A running character counter could still be added on top later without undoing anything here.

💡 Solution

Each text input whose zod schema has a .max(N), apart from the email and password fields (below), now has maxLength={Schema.shape.<field>.maxLength!}. That's the pattern NewOrgForm/ClusterName/CreateNewTableModal already use: the value is read from the schema, so the attribute can't fall out of sync with the validation.

Under each of those inputs, a hint now says when the limit bites. It is amber rather than a validation error's red, since the value is valid:

  • At the limit: "Maximum 40 characters reached." This appears once the value reaches the limit and clears once it drops back under. Typing below the limit shows nothing.
  • After a paste that was cut: "Maximum 80 characters reached. Pasted text was 312 characters; only the first 80 fit." When the field was already full, it says "none of it fit". The message stays until the value changes, so a cut paste is never silent, even if the user wasn't looking at the field.
  • A value that starts over the limit, such as a Profile name saved under the old 50-character cap: "Maximum 40 characters; this has 45." This shows as the page loads, not only once the field is left.

The hint is announced politely (aria-live="polite") and is part of the input's accessible description (aria-describedby). After a cut paste it announces only the paste message, not "Maximum reached" followed a moment later by the paste.

How a cut is measured: once the browser has inserted the paste, the room left is maxLength minus whatever stays outside the selection.

Password inputs still have no maxLength, as #1290 asks: a browser that clamps a pasted password would leave the password manager holding a password that never worked. They get no hint either. Those fields keep showing the validation error instead, and tests check both.

Email inputs are left uncapped the same way (Dawson's call). A cut address can still be a valid one: an 81-character …@example.com pasted into the organization invite or sign-up became …@example.co, passed z.email(), and the invite would have gone to that mailbox. So sign-up's Email and the invite form's Email have no maxLength and no hint. The schemas keep their .max(80), which refuses an over-long address on submit with "Email cannot be longer than 80 characters." type="email" is also the one input type that hides its selection, so with no capped email left, the hook simply makes no paste claim where the selection is hidden.

Also fixed: Profile's name limits now match central manager and sign-up (first name 40, last name 80), and the error messages use sign-up's wording ("cannot be longer than N characters"). The old wording, "must be less than 50", was also off by one.

⚖️ Alternatives

  • Export a *_MAX_LENGTH constant next to each schema. Rejected: zod 4 already exposes .maxLength on every string schema (including z.email(), and after .trim()/.refine()/.superRefine(); checked against zod 4.6.5), and the repo already reads it that way. A second constant would be one more thing to keep in sync.
  • Only maxLength where central manager enforces the same limit. Rejected: most of these limits are client-only (role names, file names, Harper database/table names), and the issue is about the input agreeing with the form's own validation.
  • A running "12/80" counter under every limited field. Not done: it is on screen all the time, on every field, while the problem is the moment the limit bites. It can still be layered on.
  • Take over the paste and insert the text ourselves, so the cut is known exactly. Rejected: it breaks the browser's undo, and type="email" has no selection API to insert at.
  • Cap the email fields too, and announce the cut. This was the previous revision. It was dropped because the notice doesn't block submit, and a cut address can pass validation and reach someone else's mailbox. Blocking submit while the notice shows was the other way out; leaving emails uncapped is simpler and matches the password fields.

❓ Your call: fields with no known limit are left alone, since I didn't want to make up limits that neither the schema nor the server enforces. The candidates, if you want limits there: sign-in / forgot-password email (central manager caps account emails at 80 in validateAddUser, but Login and ForgotPassword don't check length, and I haven't confirmed that OAuth-created accounts are held to 80); instance usernames (instance Add User, cluster Finish Setup); SSH key name, secret key, certificate name, cluster domain, coupon code, cluster / self-hosted instance host names (central manager's instanceFqdn has no max), and the application import ref.

❓ Your call: the email reasoning, that a cut value can still be valid but not the user's, also fits identifiers that pick where data goes: import database and table, rename target, application name, new table name and primary key. Those keep their cap here and announce a cut, since a cut name there is much more likely to be refused or obviously wrong than to quietly land somewhere else. If you want them treated like the email, each is one prop to remove, plus a schema refusal test like the email ones.

❓ Your call: maxLength counts characters before zod's .trim(). Where a schema trims first (sign-up's first and last name, application name, rename), pasting a value with leading whitespace that would fit once trimmed loses its last characters instead of the whitespace. For example, pasting a space followed by 40 letters into First Name keeps the space and 39 letters, so a 39-letter name gets submitted. The cut is no longer silent: the field now says "Pasted text was 41 characters; only the first 40 fit." The shortened value is still what gets submitted, though. I kept the attribute: it needs both leading whitespace and a value right at the limit, and the fix would mean trimming on paste or dropping maxLength from exactly the fields #1290 names. Easy to revisit: drop the attribute from those four fields.

🔧 Changes

The shared pieces:

The fields, each now through LengthLimitedInput (or AuthInput on sign-up):

Fields that already had a silent maxLength before this PR, now through LengthLimitedInput too (Dawson's call):

Left alone: the organization invite's AddUserModal.tsx Email, now uncapped as above, so this PR no longer changes that file. fieldNameSchema (250) isn't bound to any input in the app.

✅ Verification

End-to-end route: component tests in jsdom that render the real form with the real schema, plus a check in real Chromium of everything jsdom can't do, which is the browser's own truncation and the styling.

  • The hint, in LengthLimitedInput.test.tsx through a minimal real form. It appears at the limit and is in the accessible description. It passes react-hook-form the input's ref, so a refused field can still be focused. It says nothing below the limit, from a live region already in place, and clears once back under. A cut paste gives both numbers, and the paste message is announced alone and dropped on any later change, even one that stays at the limit. A paste that stopped short of a surrogate pair says none of it fit. Also covered: a value that starts over the limit, a schema with no maximum, and a field that hides its selection, where no paste claim is made. jsdom has no paste default action, so these tests supply the value the browser would leave. The Chromium check below covers the insertion itself.
  • The measurement, in measurePasteCut.test.ts: room beside existing text, a replaced selection, a field that was already full, line breaks, and a field left short by something other than the limit. It also counts what landed when the browser stopped short of a surrogate pair.
  • Each touched form has a test that reads maxLength off the rendered input and, now, types to the limit and expects the hint: AddOrganizationRoleModal.test.tsx, AddRoleModal.test.tsx (a case added to Edit roles in a visual editor, with JSON as the fallback #1893's test file), NewApplication.test.tsx, AddDirectoryOrFileModal.test.tsx (new), RenameFileModal.test.tsx, ImportDataModal.permissions.test.tsx, and new ones for the four forms that already had a cap: NewOrgForm.test.tsx, ClusterNameFields.test.tsx (Cluster Name and Host Name, rendered without ClusterForm, which the feat(clusters): org-owned custom regions — picker group and units on the cluster form #1780 stack changes) and CreateNewTableModal.test.tsx. Sign-up and Profile get a test of their own covering every limited field and every password field: SignUp.test.tsx (a real paste into Last Name through AuthInput) and ProfileIndex.test.tsx.
  • Email inputs get the password treatment, and tests check it the same way: no maxLength and no hint, and an 81-character address is typed in full and refused before anything is sent, in SignUp.test.tsx and AddUserModal.email.test.tsx (named so it can't collide with the AddUserModal.test.tsx that The invite-user notice in Add User is readable: dark text on a tinted amber banner instead of white on solid amber #1838 adds).
  • Password inputs are checked for maxLength === -1 in SignUp.test.tsx, ProfileIndex.test.tsx and ResetPassword.test.tsx (new), and for carrying no hint at all after 300 characters in sign-up's and Profile's tests above and Reset Password's. The ResetPassword maxLength test is the one most worth having: its schema has .max(50), so a future pass that adds maxLength wherever there's a .max would hit it first. It also checks that a 51-character password gets the error message rather than being cut short.
  • Profile fix, checked to fail on base: ProfileIndex.test.tsx refuses a 41-character first name and accepts an 80-character last name. With the old 50/50 limits put back, both tests fail.
  • Mutation checks, on the current code with every test green first: 25 of 25 turned a test red. No hint at the limit; a hint one character early; paste handler detached; no reset under the limit; announcing the limit before the paste; FormControl dropping the extra id; no aria-live; the live region rendered only with a message; a hint on password AuthInputs; the selection ignored; line breaks counted raw; no full-field check; a null limit treated as a limit; an over-limit value read as "reached"; the paste notice kept through a same-length change; the count reporting the room rather than what landed; a value short of the limit described as "reached"; a paste measured without a selection; the ref dropped from LengthLimitedInput; each email field capped again (2); and each of the four newly covered files put back to its pre-swap version, which still has the silent maxLength, so only the missing hint can fail the test (4). The earlier email-only mutations went with the code they tested. For the original attribute work, removing maxLength from each of the 9 source files turns that file's test red (9 of 9), and adding maxLength={50} to ResetPassword's password input turns its test red.
  • Real Chromium, on a dev server run from this branch on its own port, against the anonymous sign-up page:
    • Typing 47 characters into First Name stopped at 40 with "Maximum 40 characters reached." The text is --yellow in dark mode, which also shows the auth-page CSS exclusion works, and amber-800 in light mode (about 7:1 on the white card). The empty regions add no height: the First Name and Password items are both 74px.
    • For pastes, I dispatched the paste event and then let Chromium insert the text through its own editing pipeline (execCommand('insertText'), which applies the same maxLength truncation). This was before the email fields were uncapped, so the Email results are history now. Results: Email said "Pasted text was 297 characters; only the first 80 fit". Last Name said "only the first 75 fit" when appending to "Smith". A full field said "none of it fit", including pasting admin into a full admin@… email. A select-all replace that fit said nothing. A CRLF plus trailing-newline paste that fit exactly also said nothing: Chromium turned the inner CRLF into one space and dropped the trailing newline, as the code assumes.
    • I didn't use the system clipboard: the pane denies clipboard writes, and copying would have overwritten yours. One trap for anyone repeating this: while the pane isn't focused, Chromium ignores the selection when sizing an insert, so replace-all tests only behave after a real click into the page.
  • Cross-model review, round 1 at d62bbef: codex (graded), gemini and the Harper domain adjudicator all ran (independent=true).
    • Fixed: the email splice reading (since removed with the email cap), the null limit, the over-limit message for an existing Profile name, and comments that narrated the code.
    • Declined: extensionless imports, which the adjudicator dropped too, since .tsx specifiers fail tsc with TS5097 under this repo's bundler resolution. Also declined: FormControl's three-element array per render, which is accurate but negligible.
    • Kept as a ❓: trim before cap.
  • Closing full round at 9ca70be, again codex, gemini and the Harper domain adjudicator (independent=true, closing-full=done). It confirmed the email splice and the null limit as resolved. Three findings in my change, fixed after the round and pinned by the last three mutations above:
    • The count reported the room left rather than what landed, so a pasted emoji into a 79/80 field claimed "only the first 1 fit".
    • The paste notice outlived an overtype at the limit.
    • The JSDoc narrated.
  • Still open from that round: the trim point and Profile's stored long names, both already ❓ here. Its email-truncation finding is settled by leaving emails uncapped. Also left: the hooks password AuthInputs run for a limit they never set (useWatch is disabled for them) and FormControl's array, both nits.
  • Round after Dawson's two decisions, at e337957 (codex, gemini, Harper domain; independent=true, closing-full=done): convergence=converged. Every surviving finding was one already ruled on: trim before cap, Profile's stored long names, the hook running on uncapped AuthInputs, FormControl's array, and JSDoc narration. It confirmed the email truncation, the surrogate count and the stale paste notice as resolved.
    • Declined: gemini's "LengthLimitedInput needs forwardRef or it drops RHF's ref". The adjudicator dropped it too. This repo is on React 19.3, where ref is an ordinary prop. To check, I had RHF setFocus a field through LengthLimitedInput, and then dropped the ref: focus arrives with it and fails without it. That check is now a test.
    • After the round I trimmed the two comments it still called narration and added that ref test. Nothing else changed.
    • Its other ledger item is the new identifier ❓ above.
  • Gate: the full suite passed through the pre-commit hook (408 files, 3,767 passed, 1 expected fail, 11 skipped), along with oxlint, dprint and commitlint. tsc -b exit 0, oxlint exit 0, dprint check exit 0.
  • Earlier, before the hint: one full cross-model round at 95f57ba, and a merge of stage at ec0f5e07 after Edit roles in a visual editor, with JSON as the fallback #1893 rewrote the Add Role modal (AddRoleModal.tsx merged cleanly; I kept Edit roles in a visual editor, with JSON as the fallback #1893's AddRoleModal.test.tsx and added this PR's case to it). gemini-code-assist left a summary review only, with no inline comments.

Closes #1290

Filed separately: #1883. Reset Password's .max(50) is stricter than sign-up (256 on the server) and Profile (no limit), and what the right limit is still needs deciding. #1892 takes that up and is stacked on this branch.

🤖 Generated by Claude Code (Claude Opus 5.5); posted via @dawsontoth.

Related PRs: #1892 overlaps (stacked on this branch and rebased onto this head; it lands after this one), #1917 overlaps (AddOrganizationRoleModal.tsx; whichever lands second keeps the role-name cap and hint), #1897 independent (it touches only AddOrganizationRoleModal.test.tsx, so at most a textual test conflict), #1838 independent (it also adds a test for AddUserModal; this PR's is AddUserModal.email.test.tsx, so the two new files don't collide), 6 others independent
Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-grok(no-receipt),cursor-composer(no-receipt); declined=cursor-kimi,cursor-muse; rounds=4; full=2 @ 4d32978

Review-Attention: study ~20m (critical: updateUserSchema.ts; sensitive: AuthLayout.css, SignUp.tsx +1; decisions: cap-identifiers-not-emails, profile-name-limits-tightened, jsdom-paste-proof, do-less-alternative) @ 4d32978

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 76.66% 12895 / 16820
🔵 Statements 76.64% 13708 / 17884
🔵 Functions 71.71% 3433 / 4787
🔵 Branches 71.53% 10065 / 14070
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/components/ui/form/FormControl.tsx 100% 100% 100% 100%
src/components/ui/form/LengthLimitedInput.tsx 100% 100% 100% 100%
src/components/ui/form/measurePasteCut.ts 100% 100% 100% 100%
src/components/ui/form/useFormField.tsx 87.5% 50% 100% 87.5% 15
src/components/ui/form/useLengthLimit.tsx 100% 94.73% 100% 100%
src/features/auth/AuthLayout.css 0% 0% 0% 0%
src/features/auth/ResetPassword.tsx 80% 50% 77.77% 80% 35, 59-66
src/features/auth/SignUp.tsx 85.71% 69.23% 86.66% 87.8% 134-138
src/features/auth/components/AuthInput.tsx 100% 100% 100% 100%
src/features/clusters/upsert/fields/ClusterAbbreviatedName.tsx 100% 100% 100% 100%
src/features/clusters/upsert/fields/ClusterName.tsx 100% 100% 100% 100%
src/features/instance/applications/components/NewApplication/CommonInstructions.tsx 100% 100% 100% 100%
src/features/instance/applications/modals/AddDirectoryOrFileModal.tsx 32.5% 4.54% 33.33% 34.21% 34-35, 67-110, 130-131, 145
src/features/instance/applications/modals/RenameFileModal.tsx 37.73% 15.15% 50% 37.25% 38, 45-46, 80-139, 144-145
src/features/instance/config/roles/modals/AddRoleModal.tsx 81.25% 85.71% 75% 81.25% 60, 72-73
src/features/instance/databases/modals/CreateNewTableModal.tsx 55% 33.33% 66.66% 55% 65-83
src/features/instance/databases/modals/ImportDataModal.tsx 54.7% 48.21% 68% 54.86% 73, 76-78, 82, 92, 210-236, 240-244, 258-269, 271-275, 277-285, 295-296, 302-307, 333-334, 434-447
src/features/organization/roles/modals/AddOrganizationRoleModal.tsx 45.16% 27.27% 33.33% 45.16% 72, 79-100, 149, 167-195
src/features/organizations/components/NewOrgForm.tsx 81.81% 50% 71.42% 81.81% 61-68
src/features/profile/index.tsx 60% 57.14% 75% 60% 48-64
src/features/profile/mutations/updateUserSchema.ts 100% 100% 100% 100%
src/lib/zod/password.ts 100% 100% 100% 100%
Generated in workflow #2308 for commit b8b4519 by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enforces input length constraints across multiple forms by adding the maxLength attribute to various text fields, including sign-up, profile updates, file operations, and role creation. It also aligns the profile schema validation limits for first and last names and introduces comprehensive unit tests to verify these constraints. No review comments were provided, so there is no additional feedback.

@dawsontoth
dawsontoth added this pull request to stack #1904 October 11, 2026 21:25
dawsontoth added a commit that referenced this pull request Oct 12, 2026
…was cut

A hard maxLength stops typing at the limit, but it also truncates a longer
paste without a word, so a password-manager or clipboard value can land cut
short with nothing on screen to say so.

Every field #1888 limits now says so under the input, in amber rather than
the red of a validation error, since the value is valid:

- "Maximum N characters reached." once the value reaches the limit, cleared
  again once it drops back under. A value that starts over the limit, such as
  a Profile name saved under the old 50-character cap, says how long it is.
- "... Pasted text was P characters; only the first K fit." (or "none of it
  fit") when a paste was cut, measured once the browser has inserted it: from
  the selection where the input exposes one, and, where type="email" hides
  it, by reading the new value as the old one with the paste spliced in, so
  text already in the field never counts as pasted. It counts what actually
  landed, which is one short of the room when the browser won't split a
  surrogate pair, and it stays until the value changes.

A schema without .max() gives zod's maxLength as null, which leaves the field
alone rather than showing "Maximum null characters".

The hint is an always-rendered aria-live="polite" region tied to the input by
aria-describedby (FormControl now appends a caller's id rather than dropping
it), and shows only the paste message after a cut paste rather than announcing
the limit first. Password fields get neither a maxLength nor a hint.

LengthLimitedInput wraps FormControl + Input for the plain fields; AuthInput
uses the same useLengthLimit hook, so sign-up's names and email get it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dawsontoth and others added 4 commits October 12, 2026 19:22
…ing it on submit

Text inputs whose zod schema caps a length now carry a matching `maxLength`, read from the
schema itself (`Schema.shape.<field>.maxLength`) so the attribute and the validation cannot
come apart. That covers the organization role name, the add/invite user email, the instance
role name, sign-up first/last name and email, profile first/last name, the new application
name, the add/rename file names, and the import modal's database and table names.

Password inputs are deliberately left without `maxLength`: a clamped paste would save a
password in the password manager that never worked, so they keep showing the validation
error instead.

The profile form's name limits (50/50) disagreed with central manager's `validateUpdateUser`
(first name 40, last name 80), so a 41-50 character first name passed the form and was
refused by the server, and a 51-80 character last name that sign-up accepted could never be
saved again. They now match the server and the sign-up form.

Closes #1290

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…was cut

A hard maxLength stops typing at the limit, but it also truncates a longer
paste without a word, so a password-manager or clipboard value can land cut
short with nothing on screen to say so.

Every field #1888 limits now says so under the input, in amber rather than
the red of a validation error, since the value is valid:

- "Maximum N characters reached." once the value reaches the limit, cleared
  again once it drops back under. A value that starts over the limit, such as
  a Profile name saved under the old 50-character cap, says how long it is.
- "... Pasted text was P characters; only the first K fit." (or "none of it
  fit") when a paste was cut, measured once the browser has inserted it: from
  the selection where the input exposes one, and, where type="email" hides
  it, by reading the new value as the old one with the paste spliced in, so
  text already in the field never counts as pasted. It counts what actually
  landed, which is one short of the room when the browser won't split a
  surrogate pair, and it stays until the value changes.

A schema without .max() gives zod's maxLength as null, which leaves the field
alone rather than showing "Maximum null characters".

The hint is an always-rendered aria-live="polite" region tied to the input by
aria-describedby (FormControl now appends a caller's id rather than dropping
it), and shows only the paste message after a cut paste rather than announcing
the limit first. Password fields get neither a maxLength nor a hint.

LengthLimitedInput wraps FormControl + Input for the plain fields; AuthInput
uses the same useLengthLimit hook, so sign-up's names and email get it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d fields hit their limit

A cut email address can still be a valid one: an 81-character
...@example.com pasted into the organization invite or sign-up became
...@example.co, passed z.email(), and the invite would have gone to that
mailbox. So the two email fields now work like the password fields: no
maxLength and no length hint, and the schema's .max(80) refuses an over-long
address on submit ("Email cannot be longer than 80 characters.").

That leaves no capped field with type="email", the one input type that hides
its selection, so measurePasteCut keeps only the selection-known path. The
hook makes no paste claim when the selection is hidden.

NewOrgForm (name, subdomain), ClusterName, ClusterAbbreviatedName and
CreateNewTableModal (table name, primary key, database name) already had a
maxLength that cut silently. They now go through LengthLimitedInput, so they
say when the limit is reached and when a paste was cut, like the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reset Password refused anything over 50 characters, while sign-up and Profile set no limit at
all. None of those matched central manager: its only password cap is
`VALIDATION_CONSTRAINTS.USER.PASSWORD_MAX_LENGTH` (256), applied by `validateAddUser` to sign-up,
while `validateResetPassword` and `validateUpdateUser` take any length. So a password manager's
64-character password worked for sign-up and Profile but could not be reset to, and a sign-up
password over 256 passed the form only to be refused by the server.

All three forms now cap the password at one shared `ACCOUNT_PASSWORD_MAX_LENGTH` (256) with the
same message. Password inputs still have no `maxLength`, so an over-long paste is shown in full
with the error rather than silently clamped.

Closes #1883

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1290-maxlength-attributes branch from 4d32978 to c460f66 Compare October 12, 2026 23:23
@dawsontoth
dawsontoth marked this pull request as ready for review October 12, 2026 23:23
@dawsontoth
dawsontoth requested a review from a team as a code owner October 12, 2026 23:24
@dawsontoth
dawsontoth removed this pull request from stack #1904 October 12, 2026 23:24
@dawsontoth
dawsontoth changed the base branch from stage to claude/1294-placeholder-usability October 12, 2026 23:24
@dawsontoth
dawsontoth merged commit b8b4519 into claude/1294-placeholder-usability Oct 12, 2026
8 checks passed
@dawsontoth
dawsontoth deleted the claude/1290-maxlength-attributes branch October 12, 2026 23:24
dawsontoth added a commit that referenced this pull request Oct 12, 2026
…was cut

A hard maxLength stops typing at the limit, but it also truncates a longer
paste without a word, so a password-manager or clipboard value can land cut
short with nothing on screen to say so.

Every field #1888 limits now says so under the input, in amber rather than
the red of a validation error, since the value is valid:

- "Maximum N characters reached." once the value reaches the limit, cleared
  again once it drops back under. A value that starts over the limit, such as
  a Profile name saved under the old 50-character cap, says how long it is.
- "... Pasted text was P characters; only the first K fit." (or "none of it
  fit") when a paste was cut, measured once the browser has inserted it: from
  the selection where the input exposes one, and, where type="email" hides
  it, by reading the new value as the old one with the paste spliced in, so
  text already in the field never counts as pasted. It counts what actually
  landed, which is one short of the room when the browser won't split a
  surrogate pair, and it stays until the value changes.

A schema without .max() gives zod's maxLength as null, which leaves the field
alone rather than showing "Maximum null characters".

The hint is an always-rendered aria-live="polite" region tied to the input by
aria-describedby (FormControl now appends a caller's id rather than dropping
it), and shows only the paste message after a cut paste rather than announcing
the limit first. Password fields get neither a maxLength nor a hint.

LengthLimitedInput wraps FormControl + Input for the plain fields; AuthInput
uses the same useLengthLimit hook, so sign-up's names and email get it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Take a pass through fields that could use the maxlength attribute

1 participant