From 9ba3b7ce282b91aa4fe24ce99113d043d0ec902d Mon Sep 17 00:00:00 2001 From: Wattachai Kanawitoon <117723407+wattachai-lseg@users.noreply.github.com> Date: Mon, 9 Oct 2023 19:35:13 +0700 Subject: [PATCH] fix(password-field, phrasebook): improve show password announcement --- .../__snapshots__/PasswordField.md | 41 ++--------- .../__test__/password-field.test.js | 73 ++++++++++++++++++- packages/elements/src/password-field/index.ts | 52 ++++++++++++- .../src/locale/de/password-field.ts | 3 +- .../src/locale/en/password-field.ts | 3 +- .../src/locale/ja/password-field.ts | 5 +- .../src/locale/zh-hant/password-field.ts | 3 +- .../src/locale/zh/password-field.ts | 3 +- packages/utils/src/accessibility.ts | 1 + packages/utils/src/accessibility/helpers.ts | 20 ++++- 10 files changed, 155 insertions(+), 49 deletions(-) diff --git a/packages/elements/src/password-field/__snapshots__/PasswordField.md b/packages/elements/src/password-field/__snapshots__/PasswordField.md index d70a44306a..91e65b0fd1 100644 --- a/packages/elements/src/password-field/__snapshots__/PasswordField.md +++ b/packages/elements/src/password-field/__snapshots__/PasswordField.md @@ -10,48 +10,19 @@ > - -``` - -#### `Can toggle password field` - -```html - - - - -``` - -```html - - - + ``` diff --git a/packages/elements/src/password-field/__test__/password-field.test.js b/packages/elements/src/password-field/__test__/password-field.test.js index a61038adca..48d7c3b7b1 100644 --- a/packages/elements/src/password-field/__test__/password-field.test.js +++ b/packages/elements/src/password-field/__test__/password-field.test.js @@ -4,6 +4,10 @@ import '@refinitiv-ui/elements/password-field'; import '@refinitiv-ui/elemental-theme/light/ef-password-field'; import { elementUpdated, expect, fixture } from '@refinitiv-ui/test-helpers'; +const getTextContent = (el) => { + return el.textContent?.trim() || ''; +}; + describe('password-field/PasswordField', function () { it('Default DOM structure and properties are correct', async function () { const el = await fixture(''); @@ -12,12 +16,75 @@ describe('password-field/PasswordField', function () { it('Can toggle password field', async function () { const el = await fixture(''); - const eyeIconEl = el.shadowRoot.querySelector('[part~=icon]'); + const eyeIconEl = el.shadowRoot.querySelector('[part=icon]'); + const inputEl = el.shadowRoot.querySelector('[part=input]'); + const liveRegionEl = el.shadowRoot.querySelector('[part=live-region]'); + + const visibleMessage = 'Show password on, password is visible'; + const hiddenMessage = 'Show password off, password is hidden'; + + expect(inputEl.getAttribute('type')).to.equal( + 'password', + 'Input type should set to "password" by default' + ); + expect(eyeIconEl.getAttribute('aria-pressed')).to.equal( + 'false', + 'aria-pressed of icon should set to "false" by default' + ); + expect(getTextContent(liveRegionEl)).to.equal( + '', + 'text content of live region should be empty by default' + ); + + eyeIconEl.focus(); + await elementUpdated(el); + expect(getTextContent(liveRegionEl)).to.equal( + hiddenMessage, + `text content of live region should be "${hiddenMessage}" after focusing on show password` + ); + eyeIconEl.click(); await elementUpdated(el); - expect(el).shadowDom.to.equalSnapshot(); + expect(inputEl.getAttribute('type')).to.equal( + 'text', + 'Input type should set to "text" after click show password' + ); + expect(eyeIconEl.getAttribute('aria-pressed')).to.equal( + 'true', + 'aria-pressed of icon should set to "true" after toggling show password' + ); + expect(getTextContent(liveRegionEl)).to.equal( + visibleMessage, + `text content of live region should be "${visibleMessage}" after toggling show password` + ); + + eyeIconEl.blur(); + await elementUpdated(el); + expect(getTextContent(liveRegionEl)).to.equal( + '', + 'text content of live region should be empty after blurring out of show password' + ); + + eyeIconEl.focus(); + await elementUpdated(el); + expect(getTextContent(liveRegionEl)).to.equal( + visibleMessage, + `text content of live region should be "${visibleMessage}" after toggling show password` + ); + eyeIconEl.click(); await elementUpdated(el); - expect(el).shadowDom.to.equalSnapshot(); + expect(inputEl.getAttribute('type')).to.equal( + 'password', + 'Input type should back to "Password" after click hide password' + ); + expect(eyeIconEl.getAttribute('aria-pressed')).to.equal( + 'false', + 'aria-pressed of icon should be "false" after toggling show password for the second time' + ); + expect(getTextContent(liveRegionEl)).to.equal( + hiddenMessage, + `aria-label of icon should back to "${hiddenMessage}" after toggling show password for the second time` + ); }); }); diff --git a/packages/elements/src/password-field/index.ts b/packages/elements/src/password-field/index.ts index 97d5021eaa..8fc16fc7d5 100644 --- a/packages/elements/src/password-field/index.ts +++ b/packages/elements/src/password-field/index.ts @@ -1,10 +1,11 @@ -import { PropertyValues, TemplateResult, html } from '@refinitiv-ui/core'; +import { CSSResultGroup, PropertyValues, TemplateResult, html, unsafeCSS } from '@refinitiv-ui/core'; import { customElement } from '@refinitiv-ui/core/decorators/custom-element.js'; import { state } from '@refinitiv-ui/core/decorators/state.js'; import { TemplateMap } from '@refinitiv-ui/core/directives/template-map.js'; import '@refinitiv-ui/phrasebook/locale/en/password-field.js'; -import { Translate, translate } from '@refinitiv-ui/translate'; +import { Translate, TranslateDirectiveResult, translate } from '@refinitiv-ui/translate'; +import { VISUALLY_HIDDEN_STYLE } from '@refinitiv-ui/utils/accessibility.js'; import { preload } from '../icon/index.js'; import '../icon/index.js'; @@ -62,6 +63,21 @@ export class PasswordField extends TextField { @state() private isPasswordVisible = false; + /** + * live region content presenting password field visibility state + */ + @state() + private liveRegionContent: TranslateDirectiveResult = ''; + + /** + * A `CSSResultGroup` that will be used to style the host, + * slotted children and the internal template of the element. + * @returns CSS template + */ + static override get styles(): CSSResultGroup { + return [super.styles, unsafeCSS(VISUALLY_HIDDEN_STYLE)]; + } + /** * Called when the element’s DOM has been updated and rendered for the first time * @param changedProperties Properties that has changed @@ -100,15 +116,43 @@ export class PasswordField extends TextField { part="icon" role="button" tabindex="0" - aria-label="${this.isPasswordVisible ? this.t('HIDE_PASSWORD') : this.t('SHOW_PASSWORD')}" + aria-pressed="${this.isPasswordVisible}" + aria-label="${this.t('SHOW_PASSWORD')}" icon=${this.isPasswordVisible ? 'eye-off' : 'eye'} ?readonly="${this.readonly}" ?disabled="${this.disabled}" - @tap="${this.togglePasswordVisibility}" + @tap="${this.onTogglePasswordTap}" + @focus="${this.updateLiveRegionContent}" + @blur="${this.updateLiveRegionContent}" > +
${this.liveRegionContent}
`; } + /** + * update live region content describing password visibility state + * @param event event trigging live region content update + * @return void + */ + protected updateLiveRegionContent(event: Event): void { + this.liveRegionContent = + event.type === 'blur' + ? '' + : this.isPasswordVisible + ? this.t('SHOW_PASSWORD_ON') + : this.t('SHOW_PASSWORD_OFF'); + } + + /** + * Handle tap events of toggle password icon + * @param event custom event + * @returns {void} + */ + protected onTogglePasswordTap(event: CustomEvent): void { + this.togglePasswordVisibility(); + this.updateLiveRegionContent(event); + } + /** * Toggles password visibility state * @return void diff --git a/packages/phrasebook/src/locale/de/password-field.ts b/packages/phrasebook/src/locale/de/password-field.ts index 5ca816d990..095a89bac9 100644 --- a/packages/phrasebook/src/locale/de/password-field.ts +++ b/packages/phrasebook/src/locale/de/password-field.ts @@ -2,7 +2,8 @@ import { Phrasebook } from '../../translation.js'; const translations = { SHOW_PASSWORD: 'Kennwort anzeigen', - HIDE_PASSWORD: 'Kennwort ausblenden' + SHOW_PASSWORD_ON: 'Passwort anzeigen ein, Passwort wird angezeigt', + SHOW_PASSWORD_OFF: 'Passwort anzeigen aus, Passwort wird ausgeblendet' }; Phrasebook.define('de', 'ef-password-field', translations); diff --git a/packages/phrasebook/src/locale/en/password-field.ts b/packages/phrasebook/src/locale/en/password-field.ts index 654aef90ec..4589598aaa 100644 --- a/packages/phrasebook/src/locale/en/password-field.ts +++ b/packages/phrasebook/src/locale/en/password-field.ts @@ -2,7 +2,8 @@ import { Phrasebook } from '../../translation.js'; const translations = { SHOW_PASSWORD: 'Show password', - HIDE_PASSWORD: 'Hide password' + SHOW_PASSWORD_ON: 'Show password on, password is visible', + SHOW_PASSWORD_OFF: 'Show password off, password is hidden' }; Phrasebook.define('en', 'ef-password-field', translations); diff --git a/packages/phrasebook/src/locale/ja/password-field.ts b/packages/phrasebook/src/locale/ja/password-field.ts index 299ea1fbc4..ac81ae507a 100644 --- a/packages/phrasebook/src/locale/ja/password-field.ts +++ b/packages/phrasebook/src/locale/ja/password-field.ts @@ -1,8 +1,9 @@ import { Phrasebook } from '../../translation.js'; const translations = { - SHOW_PASSWORD: 'パスワードを表示', - HIDE_PASSWORD: 'パスワードを非表示' + SHOW_PASSWORD: 'パスワード表示', + SHOW_PASSWORD_ON: 'パスワード表示オン、パスワードが表示されています', + SHOW_PASSWORD_OFF: 'パスワード表示オフ、パスワードは表示されていません' }; Phrasebook.define('ja', 'ef-password-field', translations); diff --git a/packages/phrasebook/src/locale/zh-hant/password-field.ts b/packages/phrasebook/src/locale/zh-hant/password-field.ts index 483b0bdc4c..0a1e8450c6 100644 --- a/packages/phrasebook/src/locale/zh-hant/password-field.ts +++ b/packages/phrasebook/src/locale/zh-hant/password-field.ts @@ -2,7 +2,8 @@ import { Phrasebook } from '../../translation.js'; const translations = { SHOW_PASSWORD: '顯示密碼', - HIDE_PASSWORD: '隱藏密碼' + SHOW_PASSWORD_ON: '密碼顯示開啟,密碼可見', + SHOW_PASSWORD_OFF: '密碼顯示關閉,密碼被隱藏' }; Phrasebook.define('zh-Hant', 'ef-password-field', translations); diff --git a/packages/phrasebook/src/locale/zh/password-field.ts b/packages/phrasebook/src/locale/zh/password-field.ts index ed9b5c12c5..965b6f191b 100644 --- a/packages/phrasebook/src/locale/zh/password-field.ts +++ b/packages/phrasebook/src/locale/zh/password-field.ts @@ -2,7 +2,8 @@ import { Phrasebook } from '../../translation.js'; const translations = { SHOW_PASSWORD: '显示密码', - HIDE_PASSWORD: '隐藏密码' + SHOW_PASSWORD_ON: '密码显示开启,密码可见', + SHOW_PASSWORD_OFF: '密码显示关闭,密码被隐藏' }; Phrasebook.define('zh', 'ef-password-field', translations); diff --git a/packages/utils/src/accessibility.ts b/packages/utils/src/accessibility.ts index cbb28c5806..43597a575f 100644 --- a/packages/utils/src/accessibility.ts +++ b/packages/utils/src/accessibility.ts @@ -1,3 +1,4 @@ export { label } from './accessibility/label.js'; export { description } from './accessibility/description.js'; export { required } from './accessibility/required.js'; +export { VISUALLY_HIDDEN_STYLE } from './accessibility/helpers.js'; diff --git a/packages/utils/src/accessibility/helpers.ts b/packages/utils/src/accessibility/helpers.ts index 2c86a134ed..372b3d5ecb 100644 --- a/packages/utils/src/accessibility/helpers.ts +++ b/packages/utils/src/accessibility/helpers.ts @@ -3,6 +3,24 @@ */ const SEPARATOR = ' '; +/** + * a style hiding elements visually with `.visually-hidden` selector. + * These elements would be available to screen readers only. + */ +const VISUALLY_HIDDEN_STYLE = ` + .visually-hidden { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + margin: -1px; + border: 0; + padding: 0; + } +`; + /** * Get innerText from element ids * @param rootNode Root node @@ -28,4 +46,4 @@ const textFromElementIds = (rootNode: Document | DocumentFragment, ids: string): return labels.join(SEPARATOR); }; -export { textFromElementIds }; +export { textFromElementIds, VISUALLY_HIDDEN_STYLE };