diff --git a/packages/web-components/src/components/fluid-password-input/__tests__/fluid-password-input-test.js b/packages/web-components/src/components/fluid-password-input/__tests__/fluid-password-input-test.js new file mode 100644 index 000000000000..b15e3ca41cca --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/__tests__/fluid-password-input-test.js @@ -0,0 +1,222 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import '@carbon/web-components/es/components/fluid-password-input/index.js'; +import { fixture, html, expect } from '@open-wc/testing'; + +describe('cds-fluid-password-input', () => { + const defaultInput = html` + + + `; + + it('should render', async () => { + const el = await fixture(defaultInput); + const input = el.shadowRoot.querySelector('input'); + expect(input).to.exist; + }); + + it('should support a custom class', async () => { + const el = await fixture( + html`` + ); + expect(el.classList.contains('test-class')).to.be.true; + }); + + it('should render label and placeholder', async () => { + const el = await fixture(defaultInput); + const label = el.shadowRoot.querySelector('label'); + const input = el.shadowRoot.querySelector('input'); + expect(label.textContent).to.include('Password input label'); + expect(input.placeholder).to.equal('Placeholder text'); + }); + + it('should reflect value attribute to input', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot.querySelector('input'); + expect(input.value).to.equal('test123'); + }); + + it('should apply disabled attribute', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot.querySelector('input'); + expect(input.disabled).to.be.true; + }); + + it('should apply hide-label attribute', async () => { + const el = await fixture(html` + + `); + const label = el.shadowRoot.querySelector('label'); + const classList = label?.classList || []; + expect( + Array.from(classList).some((cls) => cls.includes('--visually-hidden')) + ).to.be.true; + }); + + it('should apply hide-password-label attribute', async () => { + const el = await fixture(html` + + `); + + const btn = el.shadowRoot.querySelector('button'); + btn.click(); + + await el.updateComplete; + const tooltipContent = el.shadowRoot.querySelector( + 'cds-tooltip-content#content' + ); + expect(tooltipContent.textContent.trim()).to.equal('Hide Password'); + }); + + it('should apply show-password-label attribute', async () => { + const el = await fixture(html` + + `); + + const tooltipContent = el.shadowRoot.querySelector( + 'cds-tooltip-content#content' + ); + expect(tooltipContent.textContent.trim()).to.equal('Show Password'); + }); + + it('should apply inline attribute', async () => { + const el = await fixture(html` + + `); + const wrapper = el.shadowRoot.querySelector('.cds--text-input-wrapper'); + const classList = wrapper?.classList || []; + expect( + Array.from(classList).some((cls) => + cls.includes('--text-input-wrapper--inline') + ) + ).to.be.true; + }); + + it('should apply invalid attribute', async () => { + const el = await fixture(html` + + `); + + const error = el.shadowRoot.querySelector('.cds--form-requirement'); + expect(error.textContent).to.include('This is invalid text'); + }); + + it('should apply type attribute', async () => { + const el = await fixture(html` + + `); + + expect(el.getAttribute('type')).to.equal('text'); + }); + + it('should apply warn attribute', async () => { + const el = await fixture(html` + + `); + + const warning = el.shadowRoot.querySelector('.cds--form-requirement'); + expect(warning.textContent).to.include('This is warning text'); + }); + + it('should call onTogglePasswordVisibility when visibility button is clicked', async () => { + const el = await fixture(html` + + `); + + const tooltip = el.shadowRoot.querySelector('cds-tooltip-content#content'); + expect(tooltip.textContent.trim()).to.equal('Show password'); + + const btn = el.shadowRoot.querySelector('button'); + btn.click(); + await el.updateComplete; + + expect(tooltip.textContent.trim()).to.equal('Hide password'); + }); + + it('should apply readonly attribute', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot.querySelector('input'); + expect(input.readOnly).to.be.true; + }); + + it('should disable hide/show password toggle button when readonly is true', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + + const toggleButton = el.shadowRoot.querySelector('button[type="button"]'); + expect(toggleButton.disabled).to.be.true; + }); + + it('should render divider when isFluid is true', async () => { + const el = await fixture(html` + + `); + + const divider = el.shadowRoot.querySelector('.cds--text-input__divider'); + expect(divider).to.exist; + expect(divider.tagName.toLowerCase()).to.equal('hr'); + }); + + it('should not allow input change when readOnly is true', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + + const inputElement = el.shadowRoot.querySelector('input'); + inputElement.focus(); + + // simulate a user typing “a” + inputElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + cancelable: true, + }) + ); + inputElement.dispatchEvent( + new KeyboardEvent('keypress', { + key: 'a', + bubbles: true, + cancelable: true, + }) + ); + inputElement.dispatchEvent( + new InputEvent('input', { + data: 'a', + bubbles: true, + cancelable: true, + }) + ); + + await el.updateComplete; + + expect(el.value).to.equal('', 'host.value remains empty'); + expect(inputElement.value).to.equal('', 'input.value remains empty'); + }); +}); diff --git a/packages/web-components/src/components/fluid-password-input/fluid-password-input.mdx b/packages/web-components/src/components/fluid-password-input/fluid-password-input.mdx new file mode 100644 index 000000000000..cd59f03cb90a --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/fluid-password-input.mdx @@ -0,0 +1,24 @@ +import { ArgTypes, Canvas, Markdown, Meta } from '@storybook/addon-docs/blocks'; +import { cdnJs } from '../../globals/internal/storybook-cdn'; +import * as FluidPasswordInputStories from './fluid-password-input.stories'; + + + +# Fluid Password Input + + + +## Component API + +## `cds-fluid-password-input` + + + + +{`${cdnJs({ components: ['fluid-password-input'] })}`} + +## Feedback + +Help us improve this component by providing feedback, asking questions on Slack, +or updating this file on +[GitHub](https://github.com/carbon-design-system/carbon/edit/main/packages/web-components/src/components/fluid-password-input/fluid-password-input.mdx). diff --git a/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss b/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss new file mode 100644 index 000000000000..bb8824c709c3 --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/fluid-password-input.scss @@ -0,0 +1,25 @@ +/** + * Copyright IBM Corp.2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +$css--plex: true !default; + +@use '@carbon/styles/scss/layout' as *; +@use '@carbon/styles/scss/type' as *; +@use '@carbon/styles/scss/config' as *; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/components/text-input'; +@use '@carbon/styles/scss/colors'; +@use '@carbon/styles/scss/utilities/convert' as *; +@use '@carbon/styles/scss/components/fluid-text-input/index'; + +:host(#{$prefix}-fluid-password-input) { + @include emit-layout-tokens(); + .#{$prefix}--toggle-password-tooltip { + block-size: 100%; + } +} diff --git a/packages/web-components/src/components/fluid-password-input/fluid-password-input.stories.ts b/packages/web-components/src/components/fluid-password-input/fluid-password-input.stories.ts new file mode 100644 index 000000000000..0c6939f126be --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/fluid-password-input.stories.ts @@ -0,0 +1,137 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import './index'; + +const args = { + defaultWidth: 300, + placeholder: 'Placeholder text', + showPasswordLabel: 'Show password label', + hidePasswordLabel: 'Hide password label', + readonly: false, + invalid: false, + invalidText: + 'Error message that is really long can wrap to more lines but should not be excessively long.', + disabled: false, + labelText: 'Label', + warn: false, + warnText: + 'Warning message that is really long can wrap to more lines but should not be excessively long.', +}; + +const argTypes = { + defaultWidth: { + control: { type: 'range', min: 300, max: 800, step: 50 }, + }, + showPasswordLabel: { + description: 'Show password" tooltip text on password visibility toggle', + }, + hidePasswordLabel: { + description: 'Hide password" tooltip text on password visibility toggle', + }, + placeholder: { + control: { + type: 'text', + }, + }, + invalid: { + control: { + type: 'boolean', + }, + }, + invalidText: { + control: { + type: 'text', + }, + }, + disabled: { + control: { + type: 'boolean', + }, + }, + labelText: { + control: { + type: 'text', + }, + }, + warn: { + control: { + type: 'boolean', + }, + }, + warnText: { + control: { + type: 'text', + }, + }, + value: { + control: { + type: 'text', + }, + }, + readonly: { + control: 'boolean', + description: 'Read only (readonly)', + }, +}; + +export const Default = { + args, + argTypes, + render: ({ + defaultWidth, + disabled, + hideLabel, + hidePasswordLabel, + inline, + invalid, + invalidText, + labelText, + placeholder, + readonly, + showPasswordLabel, + tooltipAlignment, + tooltipPosition, + type, + value, + warn, + warnText, + }) => html` +
+ + +
+ `, +}; +const meta = { + decorators: [ + (story) => { + return html`
${story()}
`; + }, + ], + title: 'Components/Fluid Components/FluidPasswordInput', +}; + +export default meta; diff --git a/packages/web-components/src/components/fluid-password-input/fluid-password-input.ts b/packages/web-components/src/components/fluid-password-input/fluid-password-input.ts new file mode 100644 index 000000000000..542dbe1925ee --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/fluid-password-input.ts @@ -0,0 +1,39 @@ +/** + * Copyright IBM Corp.2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { prefix } from '../../globals/settings'; +import { html } from 'lit'; +import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import styles from './fluid-password-input.scss?lit'; +import CDSPasswordInput from '../password-input/password-input'; + +/** + * Fluid text select. + * + * @element cds-fluid-select + */ +@customElement(`${prefix}-fluid-password-input`) +class CDSFluidPasswordInput extends CDSPasswordInput { + connectedCallback() { + this.setAttribute('isFluid', 'true'); + super.connectedCallback(); + } + updated() { + const formItem = this.shadowRoot?.querySelector(`.${prefix}--form-item`); + if (formItem) { + formItem.classList.add(`${prefix}--text-input--fluid`); + } + } + + render() { + return html`${super.render()}`; + } + + static styles = [CDSPasswordInput.styles, styles]; +} + +export default CDSFluidPasswordInput; diff --git a/packages/web-components/src/components/fluid-password-input/index.ts b/packages/web-components/src/components/fluid-password-input/index.ts new file mode 100644 index 000000000000..92b53362f913 --- /dev/null +++ b/packages/web-components/src/components/fluid-password-input/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import './fluid-password-input'; diff --git a/packages/web-components/src/components/password-input/__tests__/password-input-test.js b/packages/web-components/src/components/password-input/__tests__/password-input-test.js index c1f1b91a1964..9ce7cd37e4b9 100644 --- a/packages/web-components/src/components/password-input/__tests__/password-input-test.js +++ b/packages/web-components/src/components/password-input/__tests__/password-input-test.js @@ -75,6 +75,16 @@ describe('cds-password-input', () => { ).to.be.true; }); + it('should render divider when isFluid is true', async () => { + const el = await fixture(html` + + `); + + const divider = el.shadowRoot.querySelector('.cds--text-input__divider'); + expect(divider).to.exist; + expect(divider.tagName.toLowerCase()).to.equal('hr'); + }); + it('should apply hide-password-label attribute', async () => { const el = await fixture(html` ` : null; + const validationMessage = + normalizedProps.invalid || normalizedProps.warn + ? html`
+ + ${normalizedProps['slot-text']} + +
` + : null; + let align = ''; if ( @@ -313,15 +331,13 @@ class CDSPasswordInput extends CDSTextInput { : this.showPasswordLabel} + ${isFluid + ? html`
` + : null} + ${isFluid && !inline ? validationMessage : null} - ${!inline ? helper : null} -
- - ${normalizedProps['slot-text']} - -
+ ${/* Non-fluid: validation and helper outside field wrapper */ ''} + ${!isFluid && !inline ? validationMessage || helper : null} `;