diff --git a/packages/components/src/components/checkbox/checkbox.lite.tsx b/packages/components/src/components/checkbox/checkbox.lite.tsx index 2990ddb59db..87c2f6420b1 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, 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,21 @@ 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; + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && props.required) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -179,6 +190,10 @@ export default function DBCheckbox(props: DBCheckboxProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + + {state._voiceOverFallback} + ); } diff --git a/packages/components/src/components/input/input.lite.tsx b/packages/components/src/components/input/input.lite.tsx index 6f4918fdd29..08472b9fcb4 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, 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,12 @@ 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; + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && @@ -78,6 +85,10 @@ export default function DBInput(props: DBInputProps) { props.pattern)) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -230,6 +241,10 @@ export default function DBInput(props: DBInputProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + + {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..0b228ec7829 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, 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,21 @@ 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; + } } else if ( props.customValidity === 'valid' || (ref?.validity.valid && props.required) ) { state._descByIds = state._validMessageId; + if (hasVoiceOver()) { + state._voiceOverFallback = + props.validMessage ?? DEFAULT_VALID_MESSAGE; + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -243,6 +254,10 @@ export default function DBSelect(props: DBSelectProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + + {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..eb39b2dde67 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, 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,22 @@ 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; + } } 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; + } } else if (props.message) { state._descByIds = state._messageId; } else { @@ -188,6 +199,10 @@ export default function DBTextarea(props: DBTextareaProps) { ref?.validationMessage ?? DEFAULT_INVALID_MESSAGE} + + + {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 287382169b5..ffb909b3bba 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..e919a1fc4f8 --- /dev/null +++ b/packages/components/src/styles/visually-hidden.scss @@ -0,0 +1,21 @@ +%visually-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; + 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; +} + +.visually-hidden { + @extend %visually-hidden; +} diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 20195b4387b..e1b22666291 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -194,6 +194,11 @@ 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 default { filterPassingProps, cls, @@ -203,5 +208,6 @@ export default { visibleInVY, isInView, handleDataOutside, - isArrayOfStrings + isArrayOfStrings, + hasVoiceOver }; diff --git a/showcases/screen-reader/tests/input.spec.ts b/showcases/screen-reader/tests/input.spec.ts index eb05684dfc6..da1114aae56 100644 --- a/showcases/screen-reader/tests/input.spec.ts +++ b/showcases/screen-reader/tests/input.spec.ts @@ -46,8 +46,8 @@ test.describe('DBInput', () => { /* Goto desired input */ await voiceOver?.next(); await voiceOver?.next(); + await nvda?.clearSpokenPhraseLog(); await voiceOver?.next(); - await voiceOver?.type('Test'); await voiceOver?.press('Command+A'); await voiceOver?.press('Delete');