Repository navigation
fix(forms): stop typing at each field's length limit instead of refusing it on submit - #1888
Merged
dawsontoth merged 4 commits intoOct 12, 2026
Conversation
Contributor
There was a problem hiding this comment.
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.
This was referenced Oct 11, 2026
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>
…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
force-pushed
the
claude/1290-maxlength-attributes
branch
from
October 12, 2026 23:23
4d32978 to
c460f66
Compare
dawsontoth
marked this pull request as ready for review
October 12, 2026 23:23
dawsontoth
removed this pull request from stack #1904
October 12, 2026 23:24
dawsontoth
changed the base branch from
stage
to
claude/1294-placeholder-usability
October 12, 2026 23:24
dawsontoth
merged commit Oct 12, 2026
b8b4519
into
claude/1294-placeholder-usability
8 checks passed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ 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
validateUpdateUserallows 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
maxLengthhas 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.💡 Solution
Each text input whose zod schema has a
.max(N), apart from the email and password fields (below), now hasmaxLength={Schema.shape.<field>.maxLength!}. That's the patternNewOrgForm/ClusterName/CreateNewTableModalalready 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:
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
maxLengthminus 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.compasted into the organization invite or sign-up became…@example.co, passedz.email(), and the invite would have gone to that mailbox. So sign-up's Email and the invite form's Email have nomaxLengthand 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
*_MAX_LENGTHconstant next to each schema. Rejected: zod 4 already exposes.maxLengthon every string schema (includingz.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.maxLengthwhere 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.type="email"has no selection API to insert at.🔧 Changes
The shared pieces:
useLengthLimit.tsx: the hook. It measures each paste once the browser has inserted it and words the hint. The hint is an always-rendered live region, so screen readers already track it when the message arrives. While empty it issr-only, which adds no height to the field. A schema without.max()gives a null limit, which leaves the field alone.measurePasteCut.ts: how much of a paste the limit cut. It works from the selection. It counts a pasted line break the way Chromium inserts one: trailing breaks dropped, inner ones turned into a space. It also counts what actually landed, which is one short of the room when the browser won't split a surrogate pair.LengthLimitedInput.tsx:FormControlplusInputplus the hint. Every plain field below uses it.FormControl.tsxnow appends a caller'saria-describedbyinstead of dropping it, so the hint joins the field's description and error ids. Without one the attribute comes out exactly as before (no caller in the app passed one), anduseFormField.tsxgains the hint's id.AuthInput.tsxcalls the hook, so sign-up's names get the hint. The email and password inputs pass nomaxLengthand get none.AuthLayout.csskeeps the auth pages' muted paragraph colour off the hint, as it already did for the error message.The fields, each now through
LengthLimitedInput(orAuthInputon sign-up):AddOrganizationRoleModal.tsx: Role Name capped atOrganizationRoleOverviewSchema's 30. The Edit modal's name is read-only, so it's left alone.AddRoleModal.tsx(instance): role Name capped atAddRoleFormSchema's 30.SignUp.tsx: First Name 40 and Last Name 80, fromSignUpSchema; the email and password fields have no cap.profile/index.tsx: First Name and Last Name capped fromUpdateUserSchema; the password fields have no cap.updateUserSchema.ts: first name 50 → 40 and last name 50 → 80, matching central manager'svalidateUpdateUser.CommonInstructions.tsx: new-application Name capped atNewApplicationSchema's 75.AddDirectoryOrFileModal.tsxandRenameFileModal.tsx: file/folder Name capped at the component schema's 50.ImportDataModal.tsx: Database 75 and Table 250, fromImportDataFormSchema(that is,databaseNameSchema/tableNameSchema).Fields that already had a silent
maxLengthbefore this PR, now throughLengthLimitedInputtoo (Dawson's call):NewOrgForm.tsx: Name 255 and Subdomain 62.ClusterName.tsx(Cluster Name 255) andClusterAbbreviatedName.tsx(Host Name 20).CreateNewTableModal.tsx: Table Name 250, Primary Key 250 and Database Name 75.Left alone: the organization invite's
AddUserModal.tsxEmail, 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.
LengthLimitedInput.test.tsxthrough 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.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.maxLengthoff 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 withoutClusterForm, which the feat(clusters): org-owned custom regions — picker group and units on the cluster form #1780 stack changes) andCreateNewTableModal.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 throughAuthInput) andProfileIndex.test.tsx.maxLengthand no hint, and an 81-character address is typed in full and refused before anything is sent, inSignUp.test.tsxandAddUserModal.email.test.tsx(named so it can't collide with theAddUserModal.test.tsxthat The invite-user notice in Add User is readable: dark text on a tinted amber banner instead of white on solid amber #1838 adds).maxLength === -1inSignUp.test.tsx,ProfileIndex.test.tsxandResetPassword.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 ResetPasswordmaxLengthtest is the one most worth having: its schema has.max(50), so a future pass that addsmaxLengthwherever there's a.maxwould hit it first. It also checks that a 51-character password gets the error message rather than being cut short.ProfileIndex.test.tsxrefuses a 41-character first name and accepts an 80-character last name. With the old 50/50 limits put back, both tests fail.FormControldropping the extra id; noaria-live; the live region rendered only with a message; a hint on passwordAuthInputs; 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 fromLengthLimitedInput; 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 silentmaxLength, 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, removingmaxLengthfrom each of the 9 source files turns that file's test red (9 of 9), and addingmaxLength={50}to ResetPassword's password input turns its test red.--yellowin dark mode, which also shows the auth-page CSS exclusion works, andamber-800in light mode (about 7:1 on the white card). The empty regions add no height: the First Name and Password items are both 74px.execCommand('insertText'), which applies the samemaxLengthtruncation). 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 pastingadmininto a fulladmin@…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.d62bbef: codex (graded), gemini and the Harper domain adjudicator all ran (independent=true)..tsxspecifiers failtscwith TS5097 under this repo's bundler resolution. Also declined:FormControl's three-element array per render, which is accurate but negligible.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:AuthInputs run for a limit they never set (useWatchis disabled for them) andFormControl's array, both nits.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 uncappedAuthInputs,FormControl's array, and JSDoc narration. It confirmed the email truncation, the surrogate count and the stale paste notice as resolved.LengthLimitedInputneedsforwardRefor it drops RHF's ref". The adjudicator dropped it too. This repo is on React 19.3, whererefis an ordinary prop. To check, I had RHFsetFocusa field throughLengthLimitedInput, and then dropped the ref: focus arrives with it and fails without it. That check is now a test.tsc -bexit 0,oxlintexit 0,dprint checkexit 0.95f57ba, and a merge ofstageatec0f5e07after Edit roles in a visual editor, with JSON as the fallback #1893 rewrote the Add Role modal (AddRoleModal.tsxmerged cleanly; I kept Edit roles in a visual editor, with JSON as the fallback #1893'sAddRoleModal.test.tsxand 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 onlyAddOrganizationRoleModal.test.tsx, so at most a textual test conflict), #1838 independent (it also adds a test forAddUserModal; this PR's isAddUserModal.email.test.tsx, so the two new files don't collide), 6 others independentComplexity: 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