Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ describe('OcColorInput', () => {
})
})

describe('required mark', () => {
it('marks the input as required for assistive technologies', () => {
const wrapper = getWrapper({ requiredMark: true })
expect(wrapper.find('input').attributes('aria-required')).toBe('true')
})
it('does not mark the input as required without the mark', () => {
const wrapper = getWrapper()
expect(wrapper.find('input').attributes('aria-required')).toBeUndefined()
})
})

describe('description message', () => {
it('should be displayed below the input if given', () => {
const wrapper = getWrapper({ descriptionMessage: 'description' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ const additionalAttributes = computed(() => {
if (!!errorMessage || !!descriptionMessage) {
additionalAttrs['aria-describedby'] = messageId.value
}
if (requiredMark) {
additionalAttrs['aria-required'] = 'true'
}
// note: we spread out the attrs we don't want to be present in the resulting object
const { change, input, class: classes, ...attrs } = tmpAttrs
return { ...attrs, ...additionalAttrs }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ describe('OcFileInput', () => {
})
})

describe('required mark', () => {
it('marks the input as required for assistive technologies', () => {
const wrapper = getWrapper({ requiredMark: true })
expect(wrapper.find('input').attributes('aria-required')).toBe('true')
})
it('does not mark the input as required without the mark', () => {
const wrapper = getWrapper()
expect(wrapper.find('input').attributes('aria-required')).toBeUndefined()
})
})

describe('description message', () => {
it('should be displayed below the input if given', () => {
const wrapper = getWrapper({ descriptionMessage: 'description' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ const additionalAttributes = computed(() => {
if (!!errorMessage || !!descriptionMessage) {
additionalAttrs['aria-describedby'] = unref(messageId)
}
if (requiredMark) {
additionalAttrs['aria-required'] = 'true'
}

// note: we spread out the attrs we don't want to be present in the resulting object
const { change, input, focus, class: classes, ...attrs } = tmpAttrs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { defaultPlugins, mount } from '@opencloud-eu/web-test-helpers'
import OcFilterChip from './OcFilterChip.vue'
import { useGettext } from 'vue3-gettext'

vi.mock('vue3-gettext', async (importOriginal) => {
const original = await importOriginal<typeof import('vue3-gettext')>()
return { ...original, useGettext: vi.fn(original.useGettext) }
})

const selectors = {
filterChipBtn: '.oc-filter-chip-button',
Expand All @@ -25,11 +31,42 @@ describe('OcFilterChip', () => {
await wrapper.find(selectors.clearBtn).trigger('click')
expect(wrapper.emitted('clearFilter')).toBeTruthy()
})
describe('accessible names', () => {
it('names the chip after the filter and its selection', () => {
const { wrapper } = getWrapper({ props: { selectedItemNames: ['Einstein', 'Marie'] } })
expect(wrapper.find(selectors.filterChipBtn).attributes('aria-label')).toBe(
'Users: Einstein +1'
)
})
it('translates the chip name', () => {
const $gettext = vi.fn((msg: string) => msg)
vi.mocked(useGettext).mockReturnValueOnce({ $gettext } as any)
getWrapper({ props: { selectedItemNames: ['Einstein'] } })
expect($gettext).toHaveBeenCalledWith('%{filter}: %{selection}', {
filter: 'Users',
selection: 'Einstein'
})
})
it('keeps the visible label as name without a selection', () => {
const { wrapper } = getWrapper()
expect(wrapper.find(selectors.filterChipBtn).attributes('aria-label')).toBeUndefined()
})
it('names the clear button after the filter', () => {
const { wrapper } = getWrapper({ props: { selectedItemNames: ['Einstein'] } })
expect(wrapper.find(selectors.clearBtn).attributes('aria-label')).toBe('Clear filter Users')
})
})
describe('isToggle is true', () => {
it('does not render a dropdown', () => {
const { wrapper } = getWrapper({ props: { isToggle: true } })
expect(wrapper.find(selectors.filterChipDrop).exists()).toBeFalsy()
})
it.each([true, false])('exposes the toggle state as pressed (%s)', (isToggleActive) => {
const { wrapper } = getWrapper({ props: { isToggle: true, isToggleActive } })
expect(wrapper.find(selectors.filterChipBtn).attributes('aria-pressed')).toBe(
isToggleActive.toString()
)
})
it('marks filter as active when isToggleActive is true', () => {
const { wrapper } = getWrapper({ props: { isToggle: true, isToggleActive: true } })
expect(wrapper.find(selectors.filterChipBtnActive).exists()).toBeTruthy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
:color-role="buttonColorRole"
:no-hover="filterActive || !hasActiveState"
:size="size"
:aria-pressed="isToggle ? isToggleActive.toString() : undefined"
:aria-label="selectionLabel"
@click="isToggle ? emit('toggleFilter') : false"
>
<oc-icon
Expand Down Expand Up @@ -59,7 +61,7 @@
class="oc-filter-chip-clear px-1 rounded-l-none rounded-r-md h-[32px] not-[.oc-filter-chip-toggle_.oc-filter-chip-clear]:ml-[1px] focus:z-90"
appearance="filled"
color-role="secondaryContainer"
:aria-label="$gettext('Clear filter')"
:aria-label="$gettext('Clear filter %{filterLabel}', { filterLabel })"
:no-hover="filterActive"
@click="emit('clearFilter')"
>
Expand All @@ -70,6 +72,7 @@

<script setup lang="ts">
import { computed, unref, useTemplateRef } from 'vue'
import { useGettext } from 'vue3-gettext'
import { uniqueId } from '../../helpers'
import OcDrop from '../OcDrop/OcDrop.vue'

Expand Down Expand Up @@ -159,6 +162,8 @@ const {
const emit = defineEmits<Emits>()
defineSlots<Slot>()

const { $gettext } = useGettext()

const dropRef = useTemplateRef<typeof OcDrop>('dropRef')

const filterActive = computed(() => {
Expand All @@ -171,6 +176,18 @@ const filterActive = computed(() => {
return !!selectedItemNames.length
})

// names the chip after the filter as well, because the visible label is replaced by the selection
const selectionLabel = computed(() => {
if (!selectedItemNames.length) {
return undefined
}
const selection =
selectedItemNames.length > 1
? `${selectedItemNames[0]} +${selectedItemNames.length - 1}`
: selectedItemNames[0]
return $gettext('%{filter}: %{selection}', { filter: filterLabel, selection })
})

const hideDrop = () => {
unref(dropRef)?.hide()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,17 @@ describe('OcSearchBar', () => {
})
})
describe('button hidden prop', () => {
it('should add invisible class to search button if enabled', () => {
it('should not render the search button if enabled', () => {
const wrapper = getWrapper({ buttonHidden: true })
const searchButtonWrapper = wrapper.find(selectors.searchButtonWrapper)
expect(searchButtonWrapper.attributes('class')).toContain('sr-only')
expect(wrapper.find(selectors.searchButtonWrapper).exists()).toBeFalsy()
expect(wrapper.find(selectors.searchButton).exists()).toBeFalsy()
})
it('should still search on enter if enabled', async () => {
const wrapper = getWrapper({ buttonHidden: true })
const input = wrapper.find(selectors.searchInput)
await input.setValue('query')
await input.trigger('keydown.enter')
expect(wrapper.emitted('search')).toEqual([['query']])
})
it('should add button class to input if disabled', () => {
const wrapper = getWrapper({ buttonHidden: false })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
/>
</oc-button>
</div>
<div class="oc-search-button-wrapper" :class="{ 'sr-only': buttonHidden }">
<div v-if="!buttonHidden" class="oc-search-button-wrapper">
<oc-button
class="oc-search-button z-0 ml-4 rounded-l-none transform-[translateX(-1px)]"
appearance="filled"
Expand Down
10 changes: 10 additions & 0 deletions packages/design-system/src/components/OcSelect/OcSelect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ describe('OcSelect', () => {
expect(wrapper.find(selectors.deselectLockIcon).exists()).toBeTruthy()
})
})
describe('required mark', () => {
it('marks the search input as required for assistive technologies', () => {
const wrapper = getWrapper({ requiredMark: true })
expect(wrapper.find(selectors.searchInput).attributes('aria-required')).toBe('true')
})
it('does not mark the search input as required without the mark', () => {
const wrapper = getWrapper()
expect(wrapper.find(selectors.searchInput).attributes('aria-required')).toBeUndefined()
})
})
describe('message', () => {
it('displays an error message', () => {
const wrapper = getWrapper({ errorMessage: 'foo' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
<input
class="vs__search"
v-bind="attributes"
:aria-required="requiredMark ? 'true' : undefined"
@input="emit('search:input', ($event.target as HTMLInputElement).value)"
v-on="events"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,5 +359,13 @@ describe('OcTextInput', () => {
const wrapper = getShallowWrapper({ requiredMark: true })
expect(wrapper.find('label span').text()).toContain('*')
})
it('marks the input as required for assistive technologies', () => {
const wrapper = getShallowWrapper({ requiredMark: true })
expect(wrapper.find('input').attributes('aria-required')).toBe('true')
})
it('does not mark the input as required without the mark', () => {
const wrapper = getShallowWrapper()
expect(wrapper.find('input').attributes('aria-required')).toBeUndefined()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ const additionalAttributes = computed(() => {
if (defaultValue) {
additionalAttrs['placeholder'] = defaultValue
}
if (requiredMark) {
additionalAttrs['aria-required'] = 'true'
}
if (type === 'password') {
additionalAttrs['password-policy'] = passwordPolicy
additionalAttrs['generate-password-method'] = generatePasswordMethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ exports[`OcTextInput > password input field > password policy > displays error s
</label>
<div class="relative">
<!--v-if-->
<div class="oc-text-input-password-wrapper flex flex-row border rounded-sm border-role-outline"><input id="oc-textinput-20" aria-invalid="false" class="oc-text-input oc-input h-9 grow-2 border-0 focus:border-0 focus:outline-0" type="password"> <button type="button" aria-label="Show password" class="oc-button-secondary oc-button-raw oc-button-secondary-raw gap-2 justify-center text-sm min-h-3 oc-button cursor-pointer disabled:opacity-60 disabled:cursor-default oc-text-input-show-password-toggle px-2">
<div class="oc-text-input-password-wrapper flex flex-row border rounded-sm border-role-outline"><input id="oc-textinput-20" aria-invalid="true" class="oc-text-input oc-input h-9 grow-2 border-0 focus:border-0 focus:outline-0" type="password" aria-describedby="v-0"> <button type="button" aria-label="Show password" class="oc-button-secondary oc-button-raw oc-button-secondary-raw gap-2 justify-center text-sm min-h-3 oc-button cursor-pointer disabled:opacity-60 disabled:cursor-default oc-text-input-show-password-toggle px-2">
<!--v-if-->
<!-- @slot Content of the button --> <span class="oc-icon box-content inline-block align-baseline [&amp;_svg]:block size-4"><svg data-testid="inline-svg-stub" src="icons/eye-fill.svg" unique-ids="false" transform-source="(svg) => {
if (__props.accessibleLabel !== &quot;&quot;) {
Expand Down Expand Up @@ -41,7 +41,7 @@ exports[`OcTextInput > password input field > password policy > displays error s
svg.insertBefore(title, svg.firstChild);
};
return svg;
}" aria-hidden="true" focusable="false" style="fill: var(--oc-role-error);" class="size-4"></svg></span> <span class="oc-text-input-danger">8+ letters</span>
}" aria-hidden="true" focusable="false" style="fill: var(--oc-role-error);" class="size-4"></svg></span> <span class="oc-text-input-danger">8+ letters</span> <span class="sr-only">Requirement not met</span>
<!--v-if-->
</div>
</div>
Expand All @@ -59,7 +59,7 @@ exports[`OcTextInput > password input field > password policy > displays success
</label>
<div class="relative">
<!--v-if-->
<div class="oc-text-input-password-wrapper flex flex-row border rounded-sm border-role-outline"><input id="oc-textinput-21" aria-invalid="false" class="oc-text-input oc-input h-9 grow-2 border-0 focus:border-0 focus:outline-0" type="password"> <button type="button" aria-label="Show password" class="oc-button-secondary oc-button-raw oc-button-secondary-raw gap-2 justify-center text-sm min-h-3 oc-button cursor-pointer disabled:opacity-60 disabled:cursor-default oc-text-input-show-password-toggle px-2">
<div class="oc-text-input-password-wrapper flex flex-row border rounded-sm border-role-outline"><input id="oc-textinput-21" aria-invalid="false" class="oc-text-input oc-input h-9 grow-2 border-0 focus:border-0 focus:outline-0" type="password" aria-describedby="v-0"> <button type="button" aria-label="Show password" class="oc-button-secondary oc-button-raw oc-button-secondary-raw gap-2 justify-center text-sm min-h-3 oc-button cursor-pointer disabled:opacity-60 disabled:cursor-default oc-text-input-show-password-toggle px-2">
<!--v-if-->
<!-- @slot Content of the button --> <span class="oc-icon box-content inline-block align-baseline [&amp;_svg]:block size-4"><svg data-testid="inline-svg-stub" src="icons/eye-fill.svg" unique-ids="false" transform-source="(svg) => {
if (__props.accessibleLabel !== &quot;&quot;) {
Expand Down Expand Up @@ -94,7 +94,7 @@ exports[`OcTextInput > password input field > password policy > displays success
svg.insertBefore(title, svg.firstChild);
};
return svg;
}" aria-hidden="true" focusable="false" style="fill: var(--oc-role-on-surface);" class="size-4"></svg></span> <span class="">8+ letters</span>
}" aria-hidden="true" focusable="false" style="fill: var(--oc-role-on-surface);" class="size-4"></svg></span> <span class="">8+ letters</span> <span class="sr-only">Requirement met</span>
<!--v-if-->
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,51 @@ describe('OcTextInputPassword', () => {
expect(classes).toContain('border-role-outline')
expect(classes).not.toContain('border-role-error')
})
describe('password policy', () => {
function getPolicyWrapper(value: string, verified: boolean, attrs = {}) {
const policy: PasswordPolicy = {
rules: [{}],
check: () => verified,
missing: () => ({
rules: [{ code: 'minLength', message: '%{param1}+ letters', format: [8], verified }]
})
}
return mount(OcTextInputPassword, {
props: { value, passwordPolicy: policy, teleportId: 'policy-rules' },
attrs,
global: { plugins: [...defaultPlugins()], stubs: { Teleport: true } }
})
}

it('describes the input with the policy rules and their state', () => {
const wrapper = getPolicyWrapper('ab', false, { 'aria-describedby': 'message-id' })
expect(wrapper.find('input').attributes('aria-describedby')).toBe('message-id policy-rules')
expect(wrapper.find('.oc-text-input-password-policy-rule .sr-only').text()).toBe(
'Requirement not met'
)
})
it('keeps the policy rules when the described-by attribute changes', async () => {
const wrapper = getPolicyWrapper('ab', false)
expect(wrapper.find('input').attributes('aria-describedby')).toBe('policy-rules')
await wrapper.setProps({ 'aria-describedby': 'message-id' } as any)
expect(wrapper.find('input').attributes('aria-describedby')).toBe('message-id policy-rules')
})
it('announces a met rule', () => {
const wrapper = getPolicyWrapper('abcdefghij', true)
expect(wrapper.find('.oc-text-input-password-policy-rule .sr-only').text()).toBe(
'Requirement met'
)
expect(wrapper.find('input').attributes('aria-invalid')).not.toBe('true')
})
it('marks the input invalid while a rule fails', () => {
const wrapper = getPolicyWrapper('ab', false, { 'aria-invalid': 'false' })
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true')
})
it('does not mark an empty input invalid', () => {
const wrapper = getPolicyWrapper('', false, { 'aria-invalid': 'false' })
expect(wrapper.find('input').attributes('aria-invalid')).toBe('false')
})
})
})

const passwordPolicy: PasswordPolicy = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
ref="passwordInput"
v-model="password"
class="grow-2 border-0 focus:border-0 focus:outline-0"
:aria-describedby="getAriaDescribedBy()"
:aria-invalid="getAriaInvalid()"
:type="showPassword ? 'text' : 'password'"
:disabled="disabled"
@focus="hasFocus = true"
Expand Down Expand Up @@ -69,6 +71,12 @@
:class="[{ 'oc-text-input-danger': !testedRule.verified }]"
v-text="getPasswordPolicyRuleMessage(testedRule)"
></span>
<span
class="sr-only"
v-text="
testedRule.verified ? $gettext('Requirement met') : $gettext('Requirement not met')
"
/>
<oc-contextual-helper
v-if="testedRule.helperMessage"
:text="testedRule.helperMessage"
Expand All @@ -80,7 +88,7 @@
</template>

<script setup lang="ts">
import { computed, ref, unref, useTemplateRef, watch } from 'vue'
import { computed, HTMLAttributes, ref, unref, useAttrs, useTemplateRef, watch } from 'vue'
import OcIcon from '../OcIcon/OcIcon.vue'
import OcButton from '../OcButton/OcButton.vue'
import { useGettext } from 'vue3-gettext'
Expand Down Expand Up @@ -126,6 +134,29 @@ const testedPasswordPolicy = computed(() => {
return passwordPolicy?.missing(unref(password))
})

// attrs are not reactive, so these are plain functions evaluated on every render
const attrs = useAttrs()

function getAriaDescribedBy() {
const ids = [attrs['aria-describedby'] as string]
// the teleport target only holds the policy rules
if (unref(showPasswordPolicyInformation) && teleportId) {
ids.push(teleportId)
}
return ids.filter(Boolean).join(' ') || undefined
}

function getAriaInvalid(): HTMLAttributes['aria-invalid'] {
const policyFailed =
unref(showPasswordPolicyInformation) &&
!!unref(password) &&
unref(testedPasswordPolicy).rules.some((rule) => !rule.verified)
if (policyFailed) {
return 'true'
}
return attrs['aria-invalid'] as HTMLAttributes['aria-invalid']
}

const getPasswordPolicyRuleMessage = (rule: PasswordPolicyRule) => {
const paramObj: Record<string, string> = {}

Expand Down
Loading
Loading