diff --git a/packages/components/src/components/checkbox/checkbox.lite.tsx b/packages/components/src/components/checkbox/checkbox.lite.tsx index 2990ddb59db..98b658e44b2 100644 --- a/packages/components/src/components/checkbox/checkbox.lite.tsx +++ b/packages/components/src/components/checkbox/checkbox.lite.tsx @@ -7,7 +7,7 @@ import { useStore } from '@builder.io/mitosis'; import { DBCheckboxProps, DBCheckboxState } from './model'; -import { cls, uuid } from '../../utils'; +import { cls, delay, hasVoiceOver, uuid } from '../../utils'; import { DEFAULT_INVALID_MESSAGE, DEFAULT_INVALID_MESSAGE_ID_SUFFIX, @@ -33,6 +33,7 @@ export default function DBCheckbox(props: DBCheckboxProps) { _validMessageId: this._id + DEFAULT_VALID_MESSAGE_ID_SUFFIX, _invalidMessageId: this._id + DEFAULT_INVALID_MESSAGE_ID_SUFFIX, _descByIds: '', + _voiceOverFallback: '', handleChange: (event: ChangeEvent) => { if (props.onChange) { props.onChange(event); @@ -46,11 +47,23 @@ export default function DBCheckbox(props: DBCheckboxProps) { /* For a11y reasons we need to map the correct message with the checkbox */ if (!ref?.validity.valid || props.customValidity === 'invalid') { state._descByIds = state._invalidMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.invalidMessage ?? + ref?.validationMessage ?? + DEFAULT_INVALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && props.required) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -179,6 +192,13 @@ export default function DBCheckbox(props: DBCheckboxProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + {/* * https://www.davidmacd.com/blog/test-aria-describedby-errormessage-aria-live.html + * Currently VoiceOver isn't supporting changes from aria-describedby. + * This is an internal Fallback */} + + {state._voiceOverFallback} + ); } diff --git a/packages/components/src/components/drawer/drawer.lite.tsx b/packages/components/src/components/drawer/drawer.lite.tsx index 0b3bab71b2d..ce6b302b0d1 100644 --- a/packages/components/src/components/drawer/drawer.lite.tsx +++ b/packages/components/src/components/drawer/drawer.lite.tsx @@ -10,7 +10,7 @@ import { import { DBDrawerProps, DBDrawerState } from './model'; import { DBButton } from '../button'; import { DEFAULT_CLOSE_BUTTON } from '../../shared/constants'; -import { cls } from '../../utils'; +import { cls, delay } from '../../utils'; useMetadata({ isAttachedToShadowDom: true @@ -54,7 +54,7 @@ export default function DBDrawer(props: DBDrawerProps) { if (dialogContainerRef) { dialogContainerRef.hidden = true; } - setTimeout(() => { + delay(() => { if (dialogContainerRef) { dialogContainerRef.hidden = false; } diff --git a/packages/components/src/components/input/input.lite.tsx b/packages/components/src/components/input/input.lite.tsx index 6f4918fdd29..494adde355c 100644 --- a/packages/components/src/components/input/input.lite.tsx +++ b/packages/components/src/components/input/input.lite.tsx @@ -7,7 +7,7 @@ import { useRef, useStore } from '@builder.io/mitosis'; -import { cls, isArrayOfStrings, uuid } from '../../utils'; +import { cls, delay, hasVoiceOver, isArrayOfStrings, uuid } from '../../utils'; import { DBInputProps, DBInputState } from './model'; import { DEFAULT_DATALIST_ID_SUFFIX, @@ -42,6 +42,7 @@ export default function DBInput(props: DBInputProps) { _dataListId: this._id + DEFAULT_DATALIST_ID_SUFFIX, _descByIds: '', _value: '', + _voiceOverFallback: '', defaultValues: { label: DEFAULT_LABEL, placeholder: ' ' @@ -69,6 +70,13 @@ export default function DBInput(props: DBInputProps) { /* For a11y reasons we need to map the correct message with the input */ if (!ref?.validity.valid || props.customValidity === 'invalid') { state._descByIds = state._invalidMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.invalidMessage ?? + ref?.validationMessage ?? + DEFAULT_INVALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && @@ -78,6 +86,11 @@ export default function DBInput(props: DBInputProps) { props.pattern)) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -230,6 +243,13 @@ export default function DBInput(props: DBInputProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + {/* * https://www.davidmacd.com/blog/test-aria-describedby-errormessage-aria-live.html + * Currently VoiceOver isn't supporting changes from aria-describedby. + * This is an internal Fallback */} + + {state._voiceOverFallback} + ); // jscpd:ignore-end diff --git a/packages/components/src/components/select/select.lite.tsx b/packages/components/src/components/select/select.lite.tsx index 6adb6394be2..78939a0ec37 100644 --- a/packages/components/src/components/select/select.lite.tsx +++ b/packages/components/src/components/select/select.lite.tsx @@ -8,7 +8,7 @@ import { useStore } from '@builder.io/mitosis'; import { DBSelectOptionType, DBSelectProps, DBSelectState } from './model'; -import { cls, uuid } from '../../utils'; +import { cls, delay, hasVoiceOver, uuid } from '../../utils'; import { DEFAULT_INVALID_MESSAGE, DEFAULT_INVALID_MESSAGE_ID_SUFFIX, @@ -47,6 +47,7 @@ export default function DBSelect(props: DBSelectProps) { _descByIds: '', _value: '', initialized: false, + _voiceOverFallback: '', handleClick: (event: ClickEvent) => { if (props.onClick) { props.onClick(event); @@ -75,11 +76,23 @@ export default function DBSelect(props: DBSelectProps) { /* For a11y reasons we need to map the correct message with the select */ if (!ref?.validity.valid || props.customValidity === 'invalid') { state._descByIds = state._invalidMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.invalidMessage ?? + ref?.validationMessage ?? + DEFAULT_INVALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && props.required) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -156,7 +169,7 @@ export default function DBSelect(props: DBSelectProps) { name={props.name} value={props.value ?? state._value} autocomplete={props.autocomplete} - onInput={(event: ChangeEvent) => + onInput={(event: ChangeEvent) => state.handleInput(event) } onClick={(event: ClickEvent) => @@ -243,6 +256,13 @@ export default function DBSelect(props: DBSelectProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + {/* * https://www.davidmacd.com/blog/test-aria-describedby-errormessage-aria-live.html + * Currently VoiceOver isn't supporting changes from aria-describedby. + * This is an internal Fallback */} + + {state._voiceOverFallback} + ); // jscpd:ignore-end diff --git a/packages/components/src/components/textarea/textarea.lite.tsx b/packages/components/src/components/textarea/textarea.lite.tsx index fd0dd74032d..fbf639c965a 100644 --- a/packages/components/src/components/textarea/textarea.lite.tsx +++ b/packages/components/src/components/textarea/textarea.lite.tsx @@ -8,7 +8,7 @@ import { } from '@builder.io/mitosis'; import { DBTextareaProps, DBTextareaState } from './model'; import { DBInfotext } from '../infotext'; -import { cls, uuid } from '../../utils'; +import { cls, delay, hasVoiceOver, uuid } from '../../utils'; import { DEFAULT_INVALID_MESSAGE, DEFAULT_INVALID_MESSAGE_ID_SUFFIX, @@ -40,6 +40,7 @@ export default function DBTextarea(props: DBTextareaProps) { placeholder: ' ', rows: '4' }, + _voiceOverFallback: '', handleInput: (event: InputEvent) => { if (props.onInput) { props.onInput(event); @@ -63,12 +64,24 @@ export default function DBTextarea(props: DBTextareaProps) { /* For a11y reasons we need to map the correct message with the textarea */ if (!ref?.validity.valid || props.customValidity === 'invalid') { state._descByIds = state._invalidMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.invalidMessage ?? + ref?.validationMessage ?? + DEFAULT_INVALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && (props.required || props.minLength || props.maxLength)) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + delay(() => (state._voiceOverFallback = ''), 1000); + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -143,7 +156,7 @@ export default function DBTextarea(props: DBTextareaProps) { wrap={props.wrap} spellcheck={props.spellCheck} autocomplete={props.autocomplete} - onInput={(event: ChangeEvent) => + onInput={(event: ChangeEvent) => state.handleInput(event) } onChange={(event: ChangeEvent) => @@ -188,6 +201,13 @@ export default function DBTextarea(props: DBTextareaProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + {/* * https://www.davidmacd.com/blog/test-aria-describedby-errormessage-aria-live.html + * Currently VoiceOver isn't supporting changes from aria-describedby. + * This is an internal Fallback */} + + {state._voiceOverFallback} + ); // jscpd:ignore-end diff --git a/packages/components/src/shared/model.ts b/packages/components/src/shared/model.ts index 06d9dfaa316..c7f82958cab 100644 --- a/packages/components/src/shared/model.ts +++ b/packages/components/src/shared/model.ts @@ -355,6 +355,13 @@ export type FormState = { _invalidMessageId?: string; _descByIds?: string; _value?: string; + + /** + * https://www.davidmacd.com/blog/test-aria-describedby-errormessage-aria-live.html + * Currently VoiceOver isn't supporting changes from aria-describedby. + * This is an internal Fallback + */ + _voiceOverFallback?: string; }; export type InitializedState = { diff --git a/packages/components/src/styles/_form-components.scss b/packages/components/src/styles/_form-components.scss index d0ffa9b2db7..39a73547a53 100644 --- a/packages/components/src/styles/_form-components.scss +++ b/packages/components/src/styles/_form-components.scss @@ -7,6 +7,8 @@ @use "@db-ui/foundations/build/scss/helpers"; @use "component"; +@forward "visually-hidden"; + $dropdown-icon-transition: transform variables.$db-transition-straight-emotional; $dropdown-icon-transform: rotate(-180deg); diff --git a/packages/components/src/styles/visually-hidden.scss b/packages/components/src/styles/visually-hidden.scss new file mode 100644 index 00000000000..6a175b4c45b --- /dev/null +++ b/packages/components/src/styles/visually-hidden.scss @@ -0,0 +1,6 @@ +@use "@db-ui/foundations/build/scss/helpers"; + +.db-visually-hidden, +[data-visually-hidden="true"] { + @extend %a11y-visually-hidden; +} diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 20195b4387b..f18e961eb65 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -194,6 +194,14 @@ export const handleDataOutside = (el: Element): DBDataOutsidePair => { export const isArrayOfStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === 'string'); +const appleOs = ['Mac', 'iPhone', 'iPad', 'iPod']; +export const hasVoiceOver = (): boolean => + typeof window !== 'undefined' && + appleOs.some((os) => window.navigator.userAgent.includes(os)); + +export const delay = (fn: () => void, ms: number) => + new Promise(() => setTimeout(fn, ms)); + export default { filterPassingProps, cls, @@ -203,5 +211,7 @@ export default { visibleInVY, isInView, handleDataOutside, - isArrayOfStrings + isArrayOfStrings, + hasVoiceOver, + delay }; diff --git a/packages/foundations/scss/helpers/_a11y.scss b/packages/foundations/scss/helpers/_a11y.scss index f382bbbf126..e7ff6b4d5ac 100644 --- a/packages/foundations/scss/helpers/_a11y.scss +++ b/packages/foundations/scss/helpers/_a11y.scss @@ -1,8 +1,17 @@ %a11y-visually-hidden { - clip: rect(0, 0, 0, 0); - block-size: 1px; - overflow: hidden; + clip: rect(0, 0, 0, 0) !important; + overflow: hidden !important; + white-space: nowrap !important; + font-size: 0 !important; + all: initial; + inset-block-start: 0 !important; + block-size: 1px !important; position: absolute !important; - white-space: nowrap; - inline-size: 1px; + inline-size: 1px !important; + border-width: 0 !important; + border-style: initial !important; + border-color: initial !important; + border-image: initial !important; + padding: 0 !important; + pointer-events: none !important; } diff --git a/showcases/screen-reader/__snapshots__/macos/webkit/DBInput-required-1.txt b/showcases/screen-reader/__snapshots__/macos/webkit/DBInput-required-1.txt new file mode 100644 index 00000000000..28a6a206d61 --- /dev/null +++ b/showcases/screen-reader/__snapshots__/macos/webkit/DBInput-required-1.txt @@ -0,0 +1 @@ +["Label * Label* Required required edit text","TODO: Add a validMessage. Test","Test selected","Test. Fill out this field","TODO: Add a validMessage. Test"] \ No newline at end of file diff --git a/showcases/screen-reader/__snapshots__/windows/chromium/DBInput-required-1.txt b/showcases/screen-reader/__snapshots__/windows/chromium/DBInput-required-1.txt new file mode 100644 index 00000000000..72bb6bae613 --- /dev/null +++ b/showcases/screen-reader/__snapshots__/windows/chromium/DBInput-required-1.txt @@ -0,0 +1 @@ +["Label star, edit, required, Required, blank","T. e. s. t. TODO: Add a valid Message","Test selected","blank. Please fill out this field.. unselected","T. e. s. t. TODO: Add a valid Message"] \ No newline at end of file diff --git a/showcases/screen-reader/default.ts b/showcases/screen-reader/default.ts index 8d16adba149..ad2cdf09df2 100644 --- a/showcases/screen-reader/default.ts +++ b/showcases/screen-reader/default.ts @@ -13,20 +13,7 @@ import { type RunTestType, type ScreenReaderTestType } from './data'; - -const translations: Record = { - button: ['Schalter'], - edit: ['Eingabefeld'], - 'radio button': ['Auswahlschalter'], - blank: ['Leer'], - checked: ['aktiviert'], - ' of ': [' von '], - clickable: ['anklickbar'], - 'has auto complete': ['mit Auto Vervollständigung'], - unknown: ['Unbekannt'], - dialog: ['Dialogfeld'], - document: ['Dokument'] -}; +import { translations } from './translations'; const standardPhrases = [ 'You are currently', diff --git a/showcases/screen-reader/tests/input.spec.ts b/showcases/screen-reader/tests/input.spec.ts index 2adfcb07547..0c29cbccfd7 100644 --- a/showcases/screen-reader/tests/input.spec.ts +++ b/showcases/screen-reader/tests/input.spec.ts @@ -1,5 +1,5 @@ import { NVDAKeyCodeCommands } from '@guidepup/guidepup'; -import { getTest, testDefault } from '../default'; +import { generateSnapshot, getTest, testDefault } from '../default'; const test = getTest(); test.describe('DBInput', () => { @@ -22,7 +22,6 @@ test.describe('DBInput', () => { await voiceOver?.next(); } }); - // We don't test default "next" here because we will be locked inside the textarea testDefault({ test, title: 'tab', @@ -40,4 +39,41 @@ test.describe('DBInput', () => { await nvda?.press('Tab'); } }); + testDefault({ + test, + title: 'required', + description: 'should inform user for changes', + url: './#/03/input?page=requirement', + async testFn(voiceOver, nvda) { + if (voiceOver) { + /* Goto desired input */ + await voiceOver?.next(); + await voiceOver?.next(); + await voiceOver?.clearSpokenPhraseLog(); + await voiceOver?.next(); + await voiceOver?.type('Test'); + await voiceOver?.press('Command+A'); + await voiceOver?.press('Delete'); + await voiceOver?.type('Test'); + } else { + await nvda?.press('Tab'); + await nvda?.type('Test'); + await nvda?.press('Control+A'); + await nvda?.press('Delete'); + await nvda?.type('Test'); + } + }, + async postTestFn(voiceOver, nvda, retry) { + if (nvda) { + await generateSnapshot(nvda, retry); + } else if (voiceOver) { + /* + * There is a timing issue for macOS for typing in input we clean the result + */ + await generateSnapshot(nvda, retry, (phraseLog) => + phraseLog.map((log) => log.replace('t. ', '')) + ); + } + } + }); }); diff --git a/showcases/screen-reader/translations.ts b/showcases/screen-reader/translations.ts new file mode 100644 index 00000000000..4c3ab2b5c0e --- /dev/null +++ b/showcases/screen-reader/translations.ts @@ -0,0 +1,19 @@ +export const translations: Record = { + star: ['Stern'], + button: ['Schalter'], + edit: ['Eingabefeld'], + 'radio button': ['Auswahlschalter'], + blank: ['Leer'], + checked: ['aktiviert'], + ' of ': [' von '], + clickable: ['anklickbar'], + 'has auto complete': ['mit Auto Vervollständigung'], + required: ['erforderlich'], + 'Please fill out this field..': ['Fülle dieses Feld aus..'], + unselected: ['nicht ausgewählt'], + selected: ['ausgewählt'], + '': ['. Nummernblock eingeschaltet'], + unknown: ['Unbekannt'], + dialog: ['Dialogfeld'], + document: ['Dokument'] +};