Skip to content

PasswordInput: a password field with reveal and copy, instead of a hand-built row - #1253

Open
JeanMarcMilletScality wants to merge 17 commits into
development/1.0from
improvement/CUI-password-field
Open

JeanMarcMilletScality wants to merge 17 commits into
development/1.0from
improvement/CUI-password-field

Conversation

@JeanMarcMilletScality

@JeanMarcMilletScality JeanMarcMilletScality commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR — Asking a user for a password meant assembling a field, a reveal toggle and a copy button by hand every time; PasswordInput and PasswordFields now ship that row.

📷 Screenshots

Components/Inputs/PasswordInput › Set A Password — the pair inside a responsive Form, before and after one press of the eye. The reveal belongs to the pair, not to each field: the confirmation has no toggle of its own and follows the password's.

The password pair masked, then revealed by a single toggle

Components/Inputs/PasswordInput › In A Narrow Container — the same row at three container widths. The field gives ground alone until it reaches its 84px floor; the buttons hold 32px throughout, and past the floor the row overflows and the parent clips it rather than the field collapsing.

The password row at container widths 312, 176 and 128 pixels

Context / Why

Three applications were found to have written the same row independently, and none of the three agreed with the others. What they shared was written out by hand and identically: a reveal button in the outline variant, spacing.r36 wide, with Show password / Hide password. What they disagreed on is the part that fails silently — the autoComplete value, whether a copy affordance belonged on the row at all, and whether the row could shrink. One of the three also sits outside a responsive form and overflows rather than giving ground.

🧩 Approach

One internal row holds a field and its affordances and is written once. PasswordInput is a field, not a labelled row — the caller still wraps it in a FormGroup, which is what keeps it usable in a two-column form, in a modal, and in a layout nobody has designed yet.

PasswordRow                 width: 100%; min-width: 0
└── PasswordFieldSlot       flex: 0 1 auto; min-width: FIELD_MIN_WIDTH   ← the floor
    └── Input fluid
    Button  (reveal)        flex-shrink: 0
    CopyButton (optional)   flex-shrink: 0

The row is a plain styled.div rather than core-ui's Stack: Stack types its children as an array, which the pair's single-child confirmation row cannot satisfy, and its API has no way to express the min-width: 0 the row depends on.

Sweeping In A Narrow Container from 320px down to 112px: the two buttons hold 32px at every width and never shrink, the field alone gives ground until it reaches its 84px floor at a 160px container, and below that the overflow grows one pixel per pixel lost. Overflow therefore begins exactly at the floor plus the gap plus the two buttons, which is the behaviour the floor exists to produce. Set A Password inside a responsive Form overflows at no width between 1200 and 320px: the label track stacks around 440–500px and hands the width back to the field, so inside a form the field never reaches its floor at all.

The field is always shrinkable, so fluid leaves the prop surface; type leaves it too, because the reveal button owns it. revealed and onToggleReveal are a pair or neither, so a controlled reveal cannot be passed without the handler that makes the button work — PasswordFields is the first user of that pair.

autoComplete becomes required, with no default: current-password and new-password both look right on screen and only one of them behaves, so there is no value that can be assumed. PasswordFields is the one exception to "a field, not a labelled row" — it is the whole pair, so it renders its own two FormGroups and registers both fields into the caller's FormProvider.

🔧 Usage

CopyButton.textToCopy widens to accept a getter, evaluated at the click:

// before
textToCopy: string;
onClick={() => { if (!isSuccess) copy(textToCopy, copyAsHtml); }}

// after
textToCopy: string | (() => string);
onClick={() => {
  if (isSuccess) return;
  const text = typeof textToCopy === 'function' ? textToCopy() : textToCopy;
  if (text) copy(text, copyAsHtml);                                   // ←
}}

An uncontrolled field does not re-render as it is typed into, so a string captured at render is the one from before the typing. Reading at click time also removes the need to subscribe the whole form to every keystroke through watch().

🔍 Review focus

  • 🔴 buttonv2/CopyButton.component.tsx › onClick — behaviour change for every existing caller, not just the new one: a source that resolves empty no longer flips the button to its success state. Until now writeText('') resolved, so the button claimed Copied! and replaced whatever was in the clipboard with nothing. The guard is on the collapsed union rather than on the getter alone, and that is the decision to check: narrowing it to typeof textToCopy === 'function' would leave every shipped string caller still overwriting the clipboard on an empty source, and would give one prop two behaviours depending on the shape it is passed. The cost of guarding is that the click becomes silent — a visible empty state is worth having and is deliberately not in this PR.
  • 🔴 passwordinput/PasswordFields.component.tsx › requiredRule — required is enforced as a react-hook-form rule because Form renders noValidate, so the DOM attribute enforces nothing. A form with a resolver takes a different path through handleSubmit and never runs field-level rules, so required is inert there and the schema has to carry it — the same caveat confirmationRules already documents. That caveat now sits on the prop's own documentation rather than only on the component's: the props table and the editor's hover are where a caller meets required, and what it guards against fails silently.
  • 🟡 passwordinput/PasswordInput.component.tsx › textToCopy — the forwarded ref reaches the field untouched, so the copy button reads the live value through the row instead: rowRef.current?.querySelector('input'). Putting that ref back on the field would take the slot the caller's registration needs. The row holds one input and is not exported, and a second one there makes the two copy tests fail.
  • 🟡 passwordinput/PasswordRow.tsx › PasswordFieldSlot — flex: 0 1 auto is load-bearing. Input caps itself at max-width: 100% but never grows past the width its size asks for, so a growing slot stretches away from the field and leaves the gap between the field and its buttons instead of after them.
  • 🟡 passwordinput/PasswordFields.component.tsx › errorOf — errors are read through getFieldState, not by indexing formState.errors. A field name may be a path, and errors['credentials.password'] is undefined, which renders a field with no message while the form refuses to submit.

🧪 How to test

  1. Open Components/Inputs/PasswordInput in Storybook.
  2. In Set A Password, type into the password field and press the eye: both fields reveal together. Press copy and paste elsewhere — it should be what is on screen now, not what was there at the last render. Press copy on an empty field: nothing should be copied and the button should not turn green.
  3. In Authenticate, press the eye and confirm the surrounding form does not submit.
  4. In In A Narrow Container, drag the container's bottom-right corner. The field should give ground down to its floor, then the row should overflow its parent rather than the field collapsing, with the two buttons keeping their width throughout.

Follow-up

  • The two fields of the pair stop lining up as the form narrows, and that is accepted rather than fixed. The confirmation's row carries no buttons, so its field has the width of two buttons and their gaps — 77px — more to spend than the password's: both are 287px at a 1200px viewport, but 134px against 211px at 500px. Both still stop shrinking at the same floor, which is what the pair was asked to do. Aligning the right edges would mean reserving a button-width spacer under the confirmation, which spends those same 77px exactly where they are scarcest and brings the confirmation to its own floor 77px earlier — so the row would start overflowing at a wider container than it does today.
  • A read-only secret shown in a detail panel is deliberately out of scope. Its design turns on whether the secret is retrievable after creation at all, which is a product decision being settled separately; if it is not retrievable, that case needs a one-time display at creation rather than a revealable value.
What changed alongside

Form's responsive JSDoc still claimed SearchInput kept a fixed width. It has shrunk since that sentence was written, and a password field now does too, so the sentence named the wrong components twice over. It now names TextArea — sized from its own cols — as the one that genuinely keeps its width.

The row's floor is convertSizeToRem('1/3'), read from inputv2 where the sizes are defined rather than spelled out a second time, and the constant lives in PasswordRow.tsx with its only reader. inputv2 itself is unchanged by this branch.

The Set A Password story hides its username input off-screen rather than with display: none, which several password managers skip. The field itself stays the application's to render — it is the only side that knows the username — so the story is the only place this library can show the shape.

The branch adds the guideline page CONTRIBUTING.md asks for, and the two rows it adds to ComponentIndex.mdx link to it, as every other component with a guideline does.

Both components are exported from next.ts rather than index.ts. They are new rather than redesigns, which by the letter of the contributing rules points at index.ts; next.ts is chosen because Input, Button and CopyButton — everything these compose — already live there, and a password field imported from the root while its own field comes from next reads as an oversight.

@bert-e

bert-e commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Hello jeanmarcmilletscality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval ⭐
/bypass_build_status Bypass the build and test status ⭐
/bypass_commit_size Bypass the check on the size of the changeset TBA ⭐
/bypass_incompatible_branch Bypass the check on the source branch prefix ⭐
/bypass_jira_check Bypass the Jira issue check ⭐
/bypass_peer_approval Bypass the pull request peers' approval ⭐
/bypass_leader_approval Bypass the pull request leaders' approval ⭐
/bypass_source_branch_lineage Bypass the cross-branch contamination check ⭐
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request.
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e

bert-e commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

// The confirmation's rule reads the password, so it has to be re-run
// when the password changes; without this a "do not match" error stays
// on screen after the user has already fixed it.
onChange: () => trigger(names.confirmation),

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.

This validates the confirmation on every keystroke in the password field, even if the user hasn't reached it yet. With a resolver or a required/match rule, a "required" or "do not match" error shows under the empty confirmation as soon as the first character is typed. Only re-run it once the confirmation has a value or has been touched.

Suggested change
onChange: () => trigger(names.confirmation),
onChange: () => {
if (getFieldState(names.confirmation).isDirty) trigger(names.confirmation);
},

revealed={revealed}
onToggleReveal={toggle}
required={required}
{...register(names.password, {

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.

required only sets the asterisk and the HTML attribute. Form renders with noValidate, so in a form without a resolver an empty password still submits. Either pass required through to register (both fields), or say in the JSDoc that required is visual only and must be enforced in the schema.

…onfirmation alone

`Form` renders with `noValidate`, so the `required` prop only produced an
asterisk and an inert DOM attribute — without a resolver an empty field
submitted in silence. It is now registered as a rule on both fields, with the
caller's own confirmation rules keeping priority.

The password's change handler re-validated the confirmation unconditionally,
so the first keystroke raised the confirmation's error under a field the user
had not reached yet. It now waits for that field to be dirty.
… point, and cover the copy change

The In A Narrow Container story was fixed at 18rem, wider than the row's own
minimum, so the field never reached its floor and the story demonstrated
nothing. It is now narrower and resizable.

CopyButton's test file gains the two cases the widened textToCopy introduced:
a getter read at the click rather than at the render, and an empty source that
copies nothing and leaves the button idle.

Also adds the guideline page CONTRIBUTING.md asks for, says in PasswordFields'
JSDoc when to reach for it, and hides the story's username input off-screen
rather than with display: none, which several password managers skip.
…oor next to its reader

getFieldState read dirtyFields without passing formState, so the confirmation's
re-validation worked only because the error read beside it happened to subscribe
that slice. Changing the error read would have silently stopped the
re-validation, with no test to say so.

The floor constant moves from inputv2, which never used it, into the row that is
its only reader; inputv2 is no longer touched by this branch. The narrow
container story clips instead of scrolling and starts wide enough to have travel
before the floor, and the guideline drops imports it never used and controls that
drive a story with no args.

Adds the test that the caller's own confirmation rules outrank the required rule.
application, which is the only side that has the username. */}
<input
type="text"
autoComplete="username"

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.

This off-screen input can still be focused and has no label. Keyboard users tab into a field they can't see, and screen readers read out an unnamed text box. This story is the pattern callers will copy, so take it out of the tab order and hide it from assistive tech.

Suggested change
autoComplete="username"
autoComplete="username"
tabIndex={-1}
aria-hidden="true"

Comment thread src/lib/components/inputv2/inputv2.tsx Outdated
else return '20.5rem';
};


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.

Stray blank line. It is the only change to inputv2.tsx, and the PR description says this file is unchanged. Remove it.

Suggested change

@eve-scality

eve-scality commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

🟡 Approve with nits

Good to merge as it stands — the point(s) below are nits: worth closing, not required.

Findings

  • ⚠️ Add a CopyButton test for a getter that evaluates to empty at click time

  • ⚠️ Add a test that a stale confirmation error clears once the password is corrected

This review is advisory and does not block the merge.

Full review

Findings

  • ⚠️ Add a CopyButton test for a getter that evaluates to empty at click time
    src/lib/components/buttonv2/CopyButton.component.test.tsx:290
    The PR documents 'textToCopy is a getter that returns "" because the field has not been filled yet' as an explicit edge case of the new empty-source guard (line 140 of CopyButton.component.tsx: if (text) copy(text, copyAsHtml)). The test at line 290 covers the static empty string path; the test at line 273 covers a getter returning a non-empty string. Their intersection — a getter that evaluates to the empty string at click time — is present only in PasswordInput.component.test.tsx line 186, which exercises it indirectly through a full PasswordInput mount. A direct CopyButton test with textToCopy={() => ''} would confirm the guard applies regardless of how the empty value arrives and would catch any future conditional that accidentally skips the guard on the getter branch (e.g., typeof textToCopy === 'function' && !text instead of !text).
    Suggestion: Render CopyButton with textToCopy={() => ''}, click the button, and assert that writeText was not called and the button does not show the success accessible name.
  • ⚠️ Add a test that a stale confirmation error clears once the password is corrected
    src/lib/components/passwordinput/PasswordFields.component.test.tsx:64
    The test 're-runs the confirmation rule when the password changes' (lines 64-77) verifies the cross-field trigger fires by counting calls to a mock validator that always returns true (line 65). Because the mock never produces an error string, the test cannot show that a visible confirmation error actually disappears when the user corrects the password. If trigger(names.confirmation) were called but its result were never applied to the rendered field — for example if the onChange callback were wired incorrectly — the test would still pass. The user-facing behavior this feature exists to provide (type mismatched passwords, see an error, fix the password, watch the error clear) is untested anywhere in the suite. This is testable with real RHF in JSDOM: supply a cross-field validator via confirmationRules that compares the confirmation value to the current password field value, type mismatched values into both fields, assert the error appears, then change the password field to match, and assert the error message is gone.
    Suggestion: Render with confirmationRules: { validate: (v, vals) => v === vals.password || 'Passwords do not match' }, type 'abc' into confirmation and 'xyz' into password, assert the error appears, then clear the password and type 'abc', and assert the error is no longer in the document.

@bert-e

bert-e commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@bert-e

bert-e commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

…tory's username field

The reveal toggle changed the field and its own icon without saying anything a
screen reader could hear. It now carries aria-pressed, so both states are
announced rather than left to be inferred from the tooltip.

The username field the Set A Password story carries for password managers was
off screen but still focusable and unnamed, so a keyboard user landed in a field
they could not see — in the pattern callers copy. It now leaves the tab order
and the accessibility tree.

The two copy tests replaced navigator.clipboard and never put it back, and the
field-group test stopped at the buttons: it also pins the field's own disabled
state now, which is the state a review reported as missing and the code already
had.
@bert-e

bert-e commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

export type PasswordFieldsProps = {
names?: { password: string; confirmation: string };
labels?: { password: string; confirmation: string };
required?: boolean;

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.

required is enforced as a field-level rule, so in a form with a resolver it only draws the asterisk and an empty password still passes unless the schema rejects it. Hover help is the only doc most callers read, so say so on the prop.

Suggested change
required?: boolean;
/**
* Marks both fields required and adds a `required` rule to each. A form with a
* `resolver` skips field-level rules, so there the schema must enforce it.
*/
required?: boolean;

PasswordFields renders its own two FormGroups, so a caller had no way in: the
prop did not exist, and wrapping the component in a disabled group is not open
to them. It takes `disabled` now and passes it to both groups, which reaches the
two inputs and the shared reveal and copy buttons through the field context.
@bert-e

bert-e commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

… visual only

`required` registers a react-hook-form rule, since Form renders noValidate and
the DOM attribute enforces nothing on its own. A form supplying a resolver never
runs field-level rules, so there the prop only draws the asterisk and an empty
field submits. The component's own JSDoc said this about the match rule, but the
props table renders the `required` row on its own, where a caller meets the prop
and the component blurb is nowhere in sight.
// `Form` renders with `noValidate`, so the `required` attribute enforces nothing
// on its own: without a rule an empty field submits silently.
const requiredRule = (label: string) =>
required ? { required: `${label} is required` } : {};

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.

disabled only reaches the DOM through FormGroup. React Hook Form still runs register rules on a field that is disabled only by the HTML attribute. So <PasswordFields required disabled /> shows "Password is required" on fields the user cannot edit, and blocks submit. Pass disabled to register too, so RHF skips validation and leaves the values out of the submit.

Suggested change
required ? { required: `${label} is required` } : {};
({ disabled, ...(required ? { required: `${label} is required` } : {}) });

disabled reached the DOM through FormGroup only, and react-hook-form keeps
evaluating a field's rules when the attribute is the only thing disabling it.
A required pair rendered disabled therefore raised its own required errors on
two fields nobody could fill, and held the submit back for good. Passing
disabled to register skips those rules and leaves both values out of the
submit, which is what a disabled control does in a plain form.
@JeanMarcMilletScality
JeanMarcMilletScality marked this pull request as ready for review October 15, 2026 12:57
* below its own content; without it the field's `max-width: 100%` has nothing to
* resolve against and the row refuses to give ground.
*/
export const PasswordRow = styled.div`

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.

Could it be core-ui stack ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not really for the usage here 🫤

@bert-e

bert-e commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

* the submit forever.
*/
disabled?: boolean;
copyable?: boolean;

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.

When I read copyable I imagine that when it's false we prevent copying by preventing selection or other shenanigans. I think the name is very unclear for what it does and never explained in js doc

@damiengillesscality

Copy link
Copy Markdown
Contributor

It's strange to not give a default value to that autoComplete field, why ?

if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
},
[ref],

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.

ref is a new function on every render, this callback is always recomputed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I changed the way the value is get from the input to avoid this

@bert-e

bert-e commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@JeanMarcMilletScality

Copy link
Copy Markdown
Contributor Author

It's strange to not give a default value to that autoComplete field, why ?

It's up to the user to put if it's for setting a new password or asking the current one, so I did not set a default. But we could decide which one is the default

@bert-e

bert-e commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

This branch has not been deployed

No deployments
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.

4 participants