Sensitive Input
A masked secret field with a reveal toggle and a copy button.
Installation
Included in poetry-ui — available as
poetry_sensitive_input
the moment you've installed Poetry,
with no per-component step. To own the source and edit it, copy it into your app:
bin/rails g poetry:add sensitive-input
Default
<%# Masked: click the field (or Tab to it and press Enter) to reveal;
blur, Escape, or the eye re-mask. %>
<div class="w-96">
<%= poetry_sensitive_input(name: "api_key", label: "API key",
value: "sk_demo_4eC39HqLyjWDarjtT1zdp7dc") %>
</div>
With copy
<%# copy: true copies the secret WITHOUT revealing it. %>
<div class="w-96">
<%= poetry_sensitive_input(name: "webhook_secret", label: "Webhook secret",
value: "whsec_8a2f61c94d0b57e3", copy: true) %>
</div>
With copy and tooltip
<%# The tooltip treatment for copy confirmation: the copy button lives
beside the field as a tooltip trigger, and a docs-only harness
copies the secret and flips the open tooltip's text to "Copied!".
The built-in copy: affordance (previous example) confirms with a
glyph swap + live-region announcement instead - pick one, not both. %>
<div class="flex w-96 items-end gap-2" data-controller="demo-copy-tooltip">
<div class="flex-1">
<%= poetry_sensitive_input(name: "api_token", label: "API token",
value: "sk_demo_4eC39HqLyjWDarjtT1zdp7dc") %>
</div>
<span data-action="click->demo-copy-tooltip#copy">
<%= poetry_tooltip do |tooltip| %>
<% tooltip.with_trigger(variant: :outline, size: :icon, label: "Copy API token",
"data-demo-copy-tooltip-target": "trigger") do %>
<%= poetry_icon(name: :copy) %>
<% end %>
<span data-demo-copy-label>Copy to clipboard</span>
<% end %>
</span>
</div>
API
Poetry::Ui::SensitiveInput::Component — options are
constructor keywords (the poetry_* helper forwards them);
slots are composed inside the block. Generated from the gem's source documentation.
| Option | Type | Details | Description |
|---|---|---|---|
| copy: | Boolean | defaults to false |
Adds the copy-without-revealing button in the trailing cell. |
| described_by: | String | aria-describedby on the input - Field hint/error wiring. | |
| disabled: | Boolean | defaults to false |
Disables the input and drops the masked group's tab stop. |
| id: | String | The input's DOM id - what a Field label's for: must reference. | |
| invalid: | Boolean | defaults to false |
aria-invalid on the input - set by Field/FormBuilder from model errors. |
| label: | String | The accessible name - feeds the input's aria-label and the masked announcement (\"{label}, masked.\"); pair with a visible Label/Field caption. | |
| name: | String | required | The form field name on the real input. |
| placeholder: | String | Hint text shown while the field is empty. | |
| readonly: | Boolean | defaults to false |
The value can be revealed and copied but not edited. |
| required: | Boolean | defaults to false |
Marks the real input required. |
| value: | String | The secret's current value; present = first paint is masked, blank = the empty state. |
Styling
Every part carries a stable data-slot attribute — target
[data-slot=…] from your own CSS to restyle it. State rides
data attributes on the parts below. This contract is verified against rendered DOM in CI.
| Part | Description |
|---|---|
| [data-slot=sensitive-input] | Root - the state machine rides here |
| [data-slot=sensitive-input-group] | The bordered field surface (InputGroup's chrome) - clicks anywhere on it reveal; wears the focus ring when the mask button inside holds focus |
| [data-slot=input-group-control] | The real input - rendered in every state for layout stability; inert while masked (aria-hidden, tabindex -1, readonly, transparent); type=password unless revealed |
| [data-slot=sensitive-input-mask] | The overlay painting the masked state - while masked it IS the reveal button (role=button, tabindex 0, "{label}, masked.", described by the sr hint; only text spans inside, so no nested-interactive); bullet dots swap to the reveal hint on hover/focus with no layout shift |
| [data-slot=input-group-addon] | The trailing cell holding the eye (and copy: affordance) |
State attributes
| Part | Attribute | Condition | Values |
|---|---|---|---|
| sensitive-input | data-state | always - masked (value hidden, group is the reveal button), revealed, or empty | masked · revealed · empty |
| sensitive-input | data-copied | copy: only - stamped for a beat after a successful copy (the clipboard-text engine) | — |
| sensitive-input | data-disabled | disabled: - the masked group loses its tab stop and pointer affordances | — |
| input-group-addon | data-align | always - inline-end holds the actions | inline-end |
Wiring
The Stimulus surface each element carries — declared in the component, verified against rendered DOM in CI. Bare actions fire on the element's default event.
| Element | Controller | Wiring |
|---|---|---|
| root | poetry--core--sensitive-input | registers · value masked_label · value hidden_message · value read_only (if readonly) · blurred on focusout |
| root | poetry--core--clipboard-text (if copy) | registers · value message |
| group | poetry--core--sensitive-input | reveal on click |
| mask | poetry--core--sensitive-input | maskKeydown on keydown · target mask |
| input | poetry--core--sensitive-input | changed on input · inputKeydown on keydown · target input |
| input | poetry--core--clipboard-text (if copy) | target input |
| toggle | poetry--core--sensitive-input | toggle on click · target toggle |
| copy_button | poetry--core--clipboard-text | copy on click |
| hint | poetry--core--sensitive-input | target hint |