Enhance accessibility with correct keyboard navigation and focus style

This commit is contained in:
Bubka
2022-09-21 21:38:53 +02:00
parent fb7c0a9c6a
commit 4f3fa4ba75
17 changed files with 120 additions and 79 deletions

View File

@@ -15,7 +15,7 @@
<div v-else class="content has-text-centered">
<router-link id="lnkSettings" :to="{ name: 'settings.options' }" class="has-text-grey">{{ $t('settings.settings') }}</router-link>
<span v-if="!this.$root.appConfig.proxyAuth || (this.$root.appConfig.proxyAuth && this.$root.appConfig.proxyLogoutUrl)">
- <a id="lnkSignOut" class="has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</a>
- <button id="lnkSignOut" class="button is-text is-like-text has-text-grey" @click="logout">{{ $t('auth.sign_out') }}</button>
</span>
</div>
</footer>

View File

@@ -1,7 +1,7 @@
<template>
<div class="field">
<input :id="fieldName" type="checkbox" :name="fieldName" class="is-checkradio is-info" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" v-bind="$attrs">
<label :for="fieldName" class="label" v-html="label"></label>
<label tabindex="0" :for="fieldName" class="label" :class="labelClass" v-html="label" v-on:keypress.space.prevent="setCheckbox"></label>
<p class="help" v-html="help" v-if="help"></p>
</div>
</template>
@@ -38,6 +38,15 @@
type: String,
default: ''
},
},
methods: {
setCheckbox(event) {
if (this.$attrs.disabled == false) {
this.form[this.fieldName] = !this.form[this.fieldName]
this.$emit(this.fieldName, this.form[this.fieldName])
}
}
}
}
</script>

View File

@@ -13,10 +13,10 @@
v-on:change="$emit('field-changed', form[fieldName])"
v-on:keyup="checkCapsLock"
/>
<span v-if="currentType == 'password'" class="icon is-small is-right is-clickable" @click="currentType = 'text'" :title="$t('auth.forms.reveal_password')">
<span v-if="currentType == 'password'" role="button" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('text')" @click="setFieldType('text')" :title="$t('auth.forms.reveal_password')">
<font-awesome-icon :icon="['fas', 'eye-slash']" />
</span>
<span v-else class="icon is-small is-right is-clickable" @click="currentType = 'password'" :title="$t('auth.forms.hide_password')">
<span v-else role="button" tabindex="0" class="icon is-small is-right is-clickable" @keyup.enter="setFieldType('password')" @click="setFieldType('password')" :title="$t('auth.forms.hide_password')">
<font-awesome-icon :icon="['fas', 'eye']" />
</span>
</div>
@@ -121,6 +121,12 @@
checkCapsLock(event) {
this.hasCapsLockOn = event.getModifierState('CapsLock') ? true : false
},
setFieldType(event) {
if (this.currentType != event) {
this.currentType = event
}
}
},
}
</script>

View File

@@ -1,11 +1,29 @@
<template>
<div class="field" :class="{ 'pt-3' : hasOffset }">
<label class="label" v-html="label"></label>
<div class="field" :class="{ 'pt-3' : hasOffset }" role="radiogroup" :aria-labelledby="inputId('label', fieldName)">
<label :id="inputId('label', fieldName)" class="label" v-html="label"></label>
<div class="is-toggle buttons">
<label class="button is-dark" :disabled="isDisabled" v-for="(choice, index) in choices" :key="index" :class="{ 'is-link' : form[fieldName] === choice.value }">
<input type="radio" class="is-hidden" :checked="form[fieldName] === choice.value" :value="choice.value" v-model="form[fieldName]" v-on:change="$emit(fieldName, form[fieldName])" :disabled="isDisabled" />
<button
role="radio"
type="button"
class="button is-dark"
:aria-checked="form[fieldName] === choice.value"
:disabled="isDisabled"
v-for="(choice, index) in choices"
:key="index"
:class="{ 'is-link' : form[fieldName] === choice.value }"
v-on:click.stop="setRadio(choice.value)"
>
<input
:id="inputId(inputType, choice.value)"
:type="inputType"
class="is-hidden"
:checked="form[fieldName] === choice.value"
:value="choice.value"
v-model="form[fieldName]"
:disabled="isDisabled"
/>
<font-awesome-icon :icon="['fas', choice.icon]" v-if="choice.icon" class="mr-3" /> {{ choice.text }}
</label>
</button>
</div>
<field-error :form="form" :field="fieldName" />
<p class="help" v-html="help" v-if="help"></p>
@@ -18,7 +36,7 @@
data() {
return {
inputType: 'radio'
}
},
@@ -58,6 +76,13 @@
type: Boolean,
default: false
}
},
methods: {
setRadio(event) {
this.form[this.fieldName] = event
this.$emit(this.fieldName, this.form[this.fieldName])
}
}
}
</script>

View File

@@ -14,9 +14,9 @@
</div>
<div v-if="this.showcloseButton" class="fullscreen-footer">
<!-- Close button -->
<label class="button is-dark is-rounded" @click.stop="closeModal">
<button class="button is-dark is-rounded" @click.stop="closeModal">
{{ $t('commons.close') }}
</label>
</button>
</div>
</div>
</template>

View File

@@ -5,7 +5,7 @@
</figure>
<p class="is-size-4 has-text-grey-light has-ellipsis">{{ internal_service }}</p>
<p class="is-size-6 has-text-grey has-ellipsis">{{ internal_account }}</p>
<p class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => internal_password.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
<p tabindex="0" class="is-size-1 has-text-white is-clickable" :title="$t('commons.copy_to_clipboard')" v-clipboard="() => internal_password.replace(/ /g, '')" v-clipboard:success="clipboardSuccessHandler">{{ displayedOtp }}</p>
<ul class="dots" v-show="isTimeBased(internal_otp_type)">
<li v-for="n in 10" :key="n"></li>
</ul>

View File

@@ -5,7 +5,7 @@
<div class="tabs is-centered is-fullwidth">
<ul>
<li v-for="tab in tabs" :key="tab.view" :class="{ 'is-active': tab.view === activeTab }">
<a :id="tab.id" @click="selectTab(tab.view)">{{ tab.name }}</a>
<a :id="tab.id" tabindex="0" @click="selectTab(tab.view)">{{ tab.name }}</a>
</li>
</ul>
</div>

View File

@@ -177,6 +177,12 @@ Vue.mixin({
case 'password':
prefix = 'pwd'
break
case 'radio':
prefix = 'rdo'
break
case 'label':
prefix = 'lbl'
break
default:
prefix = 'txt'
break

View File

@@ -50,9 +50,9 @@
{{ $t('commons.environment') }}
</h2>
<div class="box has-background-black-bis is-family-monospace is-size-7">
<span class="is-pulled-right is-clickable" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
<button class="button copy-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listInfos.innerText" v-clipboard:success="clipboardSuccessHandler">
<font-awesome-icon :icon="['fas', 'copy']" />
</span>
</button>
<ul ref="listInfos">
<li v-for="(value, key) in infos" :value="value" :key="key"><b>{{key}}</b>: {{value}}</li>
</ul>
@@ -62,9 +62,9 @@
{{ $t('settings.user_options') }}
</h2>
<div class="box has-background-black-bis is-family-monospace is-size-7">
<span class="is-pulled-right is-clickable" v-clipboard="() => this.$refs.listUserOptions.innerText" v-clipboard:success="clipboardSuccessHandler">
<button class="button copy-text is-pulled-right is-small is-text" v-clipboard="() => this.$refs.listUserOptions.innerText" v-clipboard:success="clipboardSuccessHandler">
<font-awesome-icon :icon="['fas', 'copy']" />
</span>
</button>
<ul ref="listUserOptions">
<li v-for="(value, option) in options" :value="value" :key="option"><b>{{option}}</b>: {{value}}</li>
</ul>

View File

@@ -19,7 +19,7 @@
<vue-footer :showButtons="true">
<!-- Close Group switch button -->
<p class="control">
<a class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</a>
<button class="button is-dark is-rounded" @click="closeGroupSwitch()">{{ $t('commons.close') }}</button>
</p>
</vue-footer>
</div>
@@ -81,7 +81,7 @@
</div>
</div>
</transition>
<div class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click.stop="showAccount(account)">
<div tabindex="0" class="tfa-cell tfa-content is-size-3 is-size-4-mobile" @click="showAccount(account)" @keyup.enter="showAccount(account)" role="button">
<div class="tfa-text has-ellipsis">
<img :src="'/storage/icons/' + account.icon" v-if="account.icon && $root.appSettings.showAccountsIcons" :alt="$t('twofaccounts.icon_for_account_x_at_service_y', {account: account.account, service: account.service})">
{{ displayService(account.service) }}<font-awesome-icon class="has-text-danger is-size-5 ml-2" v-if="$root.appSettings.useEncryption && account.account === $t('errors.indecipherable')" :icon="['fas', 'exclamation-circle']" />
@@ -148,7 +148,7 @@
{{ selectedAccounts.length }}&nbsp;{{ $t('commons.selected') }}
<button @click="clearSelected" :style="{visibility: selectedAccounts.length > 0 ? 'visible' : 'hidden'}" class="delete" :title="$t('commons.clear_selection')"></button>
</span>
<span @click="selectAll" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.select_all')">
<span role="button" @click="selectAll" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.select_all')">
{{ $t('commons.all') }}
<font-awesome-icon class="ml-1" :icon="['fas', 'check-square']" />
</span>
@@ -156,10 +156,10 @@
</div>
<div class="control">
<div class="tags has-addons are-medium">
<span @click="sortAsc" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.sort_ascending')">
<span role="button" @click="sortAsc" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.sort_ascending')">
<font-awesome-icon :icon="['fas', 'sort-alpha-down']" />
</span>
<span @click="sortDesc" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.sort_descending')">
<span role="button" @click="sortDesc" class="tag is-dark is-clickable has-background-black-ter has-text-grey" :title="$t('commons.sort_descending')">
<font-awesome-icon :icon="['fas', 'sort-alpha-up']" />
</span>
</div>
@@ -167,7 +167,7 @@
</div>
<div class="field is-grouped is-justify-content-center pt-1">
<div class="control">
<div class="tags are-medium has-addons is-clickable" v-if="selectedAccounts.length > 0" @click="showGroupSelector = true">
<div role="button" class="tags are-medium has-addons is-clickable" v-if="selectedAccounts.length > 0" @click="showGroupSelector = true">
<span class="tag is-dark">
{{ $t('groups.change_group') }}
</span>
@@ -177,7 +177,7 @@
</div>
</div>
<div class="control">
<div class="tags are-medium has-addons is-clickable" v-if="selectedAccounts.length > 0" @click="destroyAccounts">
<div role="button" class="tags are-medium has-addons is-clickable" v-if="selectedAccounts.length > 0" @click="destroyAccounts">
<span class="tag is-dark">
{{ $t('commons.delete') }}
</span>
@@ -193,7 +193,7 @@
<!-- search -->
<div class="field">
<div class="control has-icons-right">
<input type="text" :title="$t('commons.search')" class="input is-rounded is-search" v-model="search">
<input id="txtSearch" type="search" tabindex="1" :aria-label="$t('commons.search')" :title="$t('commons.search')" class="input is-rounded is-search" v-model="search">
<span class="icon is-small is-right">
<font-awesome-icon :icon="['fas', 'search']" v-if="!search" />
<a class="delete" v-if="search" @click="search = '' "></a>
@@ -201,14 +201,18 @@
</div>
</div>
<!-- group switch toggle -->
<div class="is-clickable has-text-centered">
<div class="columns" @click="toggleGroupSwitch">
<div class="has-text-centered">
<div class="columns">
<div class="column" v-if="!showGroupSwitch">
{{ activeGroupName }} ({{ filteredAccounts.length }})
<button :title="$t('groups.show_group_selector')" tabindex="1" class="button is-text is-like-text" @click.stop="toggleGroupSwitch">
{{ activeGroupName }} ({{ filteredAccounts.length }})&nbsp;
<font-awesome-icon :icon="['fas', 'caret-down']" />
</button>
</div>
<div class="column" v-else>
<button :title="$t('groups.hide_group_selector')" tabindex="1" class="button is-text is-like-text" @click.stop="toggleGroupSwitch">
{{ $t('groups.select_accounts_to_show') }}
</button>
</div>
</div>
</div>

View File

@@ -10,7 +10,9 @@
</div>
<div class="nav-links">
<p>{{ $t('auth.webauthn.lost_your_device') }}&nbsp;<router-link id="lnkRecoverAccount" :to="{ name: 'webauthn.lost' }" class="is-link">{{ $t('auth.webauthn.recover_your_account') }}</router-link></p>
<p v-if="!this.$root.appSettings.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp;<a id="lnkSignWithLegacy" class="is-link" @click="showWebauthn = false">{{ $t('auth.login_and_password') }}</a></p>
<p v-if="!this.$root.appSettings.useWebauthnOnly">{{ $t('auth.sign_in_using') }}&nbsp;
<a id="lnkSignWithLegacy" role="button" class="is-link" @keyup.enter="showWebauthn = false" @click="showWebauthn = false" tabindex="0">{{ $t('auth.login_and_password') }}</a>
</p>
</div>
</form-wrapper>
<!-- login/password legacy form -->
@@ -28,7 +30,9 @@
</div>
<div v-else>
<p>{{ $t('auth.forms.forgot_your_password') }}&nbsp;<router-link id="lnkResetPwd" :to="{ name: 'password.request' }" class="is-link">{{ $t('auth.forms.request_password_reset') }}</router-link></p>
<p >{{ $t('auth.sign_in_using') }}&nbsp;<a id="lnkSignWithWebauthn" class="is-link" @click="showWebauthn = true">{{ $t('auth.webauthn.security_device') }}</a></p>
<p >{{ $t('auth.sign_in_using') }}&nbsp;
<a id="lnkSignWithWebauthn" role="button" class="is-link" @keyup.enter="showWebauthn = true" @click="showWebauthn = true" tabindex="0">{{ $t('auth.webauthn.security_device') }}</a>
</p>
</div>
</div>
</form-wrapper>

View File

@@ -37,7 +37,7 @@
<vue-footer :showButtons="true">
<!-- Cancel button -->
<p class="control">
<a class="button is-dark is-rounded" @click.stop="exitSettings">
<a role="button" tabindex="0" class="button is-dark is-rounded" @click.stop="exitSettings">
{{ $t('commons.close') }}
</a>
</p>

View File

@@ -9,7 +9,7 @@
{{ $t('settings.token_legend')}}
</div>
<div class="mt-3">
<a class="is-link" @click="createToken()">
<a role="button" tabindex="0" class="is-link" @click="createToken()">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('settings.generate_new_token')}}
</a>
</div>

View File

@@ -9,8 +9,8 @@
{{ $t('auth.webauthn.security_devices_legend')}}
</div>
<div class="mt-3">
<a class="is-link" @click="register()">
<font-awesome-icon :icon="['fas', 'plus-circle']" /> {{ $t('auth.webauthn.register_a_new_device')}}
<a role="button" tabindex="0" @click="register()">
<font-awesome-icon :icon="['fas', 'plus-circle']" />&nbsp;{{ $t('auth.webauthn.register_a_new_device')}}
</a>
</div>
<!-- credentials list -->

View File

@@ -22,7 +22,7 @@ return [
'Unable_to_decrypt_uri' => 'Unable to decrypt uri',
'not_a_supported_otp_type' => 'This OTP format is not currently supported',
'cannot_create_otp_without_secret' => 'Cannot create an OTP without a secret',
'data_of_qrcode_is_not_valid_URI' => 'The data of this QR code is not a valid OTP Auth URI:',
'data_of_qrcode_is_not_valid_URI' => 'The data of this QR code is not a valid OTP Auth URI. The QR code contains:',
'wrong_current_password' => 'Wrong current password, nothing has changed',
'error_during_encryption' => 'Encryption failed, your database remains unprotected.',
'error_during_decryption' => 'Decryption failed, your database is still protected. This is mainly caused by an integrity issue of encrypted data for one or more accounts.',

View File

@@ -15,7 +15,9 @@ return [
'groups' => 'Groups',
'create_group' => 'Create new group',
'select_accounts_to_show' => 'Select accounts to show',
'show_group_selector' => 'Show group selector',
'hide_group_selector' => 'Hide group selector',
'select_accounts_to_show' => 'Select accounts group to show',
'manage_groups' => 'Manage groups',
'active_group' => 'Active group',
'manage_groups_legend' => 'You can create groups to organize your accounts the way you want. All accounts remain visible in the pseudo group named \'All\', regardless of the group they belong to.',

View File

@@ -205,9 +205,13 @@ a:hover {
flex-grow: 1;
overflow: hidden;
}
// .tfa-grid .tfa-content {
// }
.tfa-content:focus, .tfa-content:focus-visible
{
outline: 2px solid $grey;
border: none;
outline-offset: 7px;
border-radius: 3px;
}
.tfa-list .tfa-content {
padding-right: 1rem;
@@ -524,43 +528,24 @@ a.has-text-white-bis:focus, a.has-text-white-bis:focus-visible {
box-shadow: $input-focus-box-shadow-size $input-focus-box-shadow-color;
}
// .button.is-dark:focus:not(:active), .button.is-dark.is-focused:not(:active),
// .button.is-link:focus:not(:active), .button.is-link.is-focused:not(:active) {
// box-shadow: 0 0 0 0.125em hsla(0, 0%, 96%, 0.25);
// }
// .button.copy-text:focus:not(:active), .button.copy-text.is-focused:not(:active) {
// box-shadow: none;
// color: hsl(0, 0%, 86%);
// }
// a:focus,
// .button:focus,
// .control.has-icons-right > span.icon:focus {
// outline: none !important;
// }
// .button:focus {
// box-shadow: none;
// }
// a:focus-visible,
// .control.has-icons-right > span.icon:focus {
// outline: 2px solid hsl(217, 71%, 53%) !important;
// outline-offset: 3px !important;
// }
// .button:focus-visible {
// box-shadow: none;
// outline: 2px solid hsl(217, 71%, 53%) !important;
// outline-offset: 3px !important;
// }
// @supports not selector(:focus-visible) {
// a:focus,
// button:focus,
// .control.has-icons-right > span.icon:focus {
// outline: 2px solid hsl(217, 71%, 53%);
// outline-offset: 3px;
// }
// }
.is-checkradio[type="checkbox"] + label:focus,
.is-checkradio[type="checkbox"] + label:focus-visible
{
outline: none;
border: none;
}
.is-checkradio[type="checkbox"] + label:focus::before,
.is-checkradio[type="checkbox"] + label:focus-visible::before
{
outline: none;
border: 1px solid $input-focus-border-color;
box-shadow: $input-focus-box-shadow-size $input-focus-box-shadow-color;
}
.is-checkradio[type="checkbox"] + label::before,
.is-checkradio[type="checkbox"] + label::before
{
border-color: $grey;
}
.label {