accessibilite(pages-authentification): evolutions des pages de connexion/creation de compte pour respecter le DSFR et supporter une meilleure accessibilite

Update app/components/dsfr/input_component/input_component.html.haml

Co-authored-by: Colin Darie <colin@darie.eu>
This commit is contained in:
Martin 2022-12-20 17:51:36 +01:00 committed by mfo
parent be5b8c2683
commit a4d6692bc6
49 changed files with 314 additions and 439 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 19 KiB

View file

@ -21,10 +21,6 @@
padding-top: $default-spacer;
}
h1 {
margin-bottom: $default-spacer;
}
.form label {
margin-bottom: $default-spacer / 2;
}

View file

@ -1,53 +0,0 @@
@import "colors";
@import "constants";
.france-connect-login {
h2 {
color: $black;
}
}
a.france-connect-login-button {
display: inline-block;
height: 60px;
width: 230px;
margin: auto;
margin-bottom: 8px;
background-image: image-url("franceconnect-btn.svg"), image-url("franceconnect-btn-hover.svg");
background-repeat: no-repeat;
background-size: cover;
cursor: pointer;
font-size: 0;
&:hover {
background-image: image-url("franceconnect-btn-hover.svg");
}
}
.france-connect-login-separator {
display: flex;
flex-basis: 100%;
align-items: center;
color: $black;
text-transform: uppercase;
padding-bottom: $default-spacer;
padding-top: $default-spacer;
&::before,
&::after {
content: "";
flex-grow: 1;
background: $dark-grey;
height: 1px;
font-size: 0;
line-height: 0;
}
&::before {
margin-right: $default-spacer;
}
&::after {
margin-left: $default-spacer;
}
}

View file

@ -1,24 +0,0 @@
@import "colors";
@import "constants";
.suspect-email {
background-color: $orange-bg;
text-align: center;
margin-top: -$default-padding * 2;
margin-bottom: $default-padding * 2;
padding: ($default-padding - 2) $default-padding $default-padding $default-padding;
border-radius: 0 0 5px 5px;
}
.email-suggestion-address {
font-weight: bold;
}
.email-suggestion-title {
margin-bottom: $default-spacer;
}
.email-suggestion-answer button {
margin: 0 $default-spacer / 2;
min-width: 66px;
}

View file

@ -1,4 +1,11 @@
class Dsfr::InputComponent < ApplicationComponent
delegate :object, to: :@form
delegate :errors, to: :object
# use it to indicate detailed about the inputs, ex: https://www.systeme-de-design.gouv.fr/elements-d-interface/modeles-et-blocs-fonctionnels/demande-de-mot-de-passe
# it uses aria-describedby on input and link it to yielded content
renders_one :describedby
def initialize(form:, attribute:, input_type:, opts: {}, required: true)
@form = form
@attribute = attribute
@ -7,60 +14,93 @@ class Dsfr::InputComponent < ApplicationComponent
@required = required
end
# add invalid class on input when input is invalid
# and and valid on input only if another input is invalid
def input_group_opts
opts = {
class: class_names('fr-input-group': true,
'fr-password': password?,
"fr-input-group--error": errors_on_attribute?,
"fr-input-group--valid": !errors_on_attribute? && errors_on_another_attribute?)
}
if email?
opts[:data] = { controller: 'email-input' }
end
opts
end
def label_opts
{ class: class_names('fr-label': true, 'fr-password__label': password?) }
end
def input_opts
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class])
.merge('fr-input': true,
.merge('fr-password__input': password?,
'fr-input': true,
'fr-mb-0': true,
'fr-input--error': errors_on_attribute?))
if errors_on_attribute?
if errors_on_attribute? || describedby
@opts = @opts.deep_merge(aria: {
describedby: error_message_id,
invalid: true
invalid: errors_on_attribute?
})
end
if @required
@opts[:required] = true
end
if email?
@opts = @opts.deep_merge(data: {
action: "blur->email-input#checkEmail",
'email-input-target': 'input'
})
end
@opts
end
# add invalid class on input when input is invalid
# and and valid on input only if another input is invalid
def input_group_class_names
class_names('fr-input-group': true,
"fr-input-group--error": errors_on_attribute?,
"fr-input-group--valid": !errors_on_attribute? && errors_on_another_attribute?)
end
# tried to inline it within the template, but failed miserably with a double render
def label
label = @form.object.class.human_attribute_name(@attribute)
if @required
label += tag.span(" *", class: 'mandatory')
end
label
end
# errors helpers
def errors_on_attribute?
@form.object.errors.has_key?(attribute_or_rich_body)
errors.has_key?(attribute_or_rich_body)
end
def error_message_id
dom_id(@form.object, @attribute)
dom_id(object, @attribute)
end
def error_messages
@form.object.errors.full_messages_for(attribute_or_rich_body)
errors.full_messages_for(attribute_or_rich_body)
end
# i18n lookups
def label
object.class.human_attribute_name(@attribute)
end
def hint
I18n.t("activerecord.attributes.#{object.class.name.underscore}.hints.#{@attribute}")
end
# kind of input helpers
def password?
@input_type == :password_field
end
def email?
@input_type == :email_field
end
private
def errors_on_another_attribute?
!@form.object.errors.empty?
def hint?
I18n.exists?("activerecord.attributes.#{object.class.name.underscore}.hints.#{@attribute}")
end
def errors_on_another_attribute?
!errors.empty?
end
# lookup for edge case from `form.rich_text_area`
# rich text uses _rich_#{attribute}, but it is saved on #{attribute}, as well as error messages
def attribute_or_rich_body
case @input_type
when :rich_text_area

View file

@ -0,0 +1,7 @@
---
en:
show_password:
aria_label: "Show password"
label: "Show"
email_suggest:
wanna_say: 'Do you mean to say ?'

View file

@ -0,0 +1,7 @@
---
fr:
show_password:
aria_label: "Afficher le mot de passe"
label: "Afficher"
email_suggest:
wanna_say: 'Voulez-vous dire ?'

View file

@ -1,5 +1,11 @@
%div{ class: input_group_class_names }
= @form.label @attribute, label.html_safe, class: "fr-label"
= content_tag(:div, input_group_opts) do
= @form.label @attribute, label_opts do
- capture do
= label
- if @required
%span.mandatory  *
- if hint?
%span.fr-hint-text= hint
= @form.send(@input_type, @attribute, input_opts)
@ -12,3 +18,21 @@
- error_messages.map do |error_message|
%li= error_message
- elsif describedby.present?
= describedby
- if password?
.fr-password__checkbox.fr-checkbox-group.fr-checkbox-group--sm
%input#show_password{ "aria-label" => t('.show_password.aria_label'), type: "checkbox" }/
%label.fr--password__checkbox.fr-label{ for: "show_password" }= t('.show_password.label')
- if email?
.suspect-email.hidden{ data: { "email-input-target": 'ariaRegion'}, aria: { live: 'off' } }
= render Dsfr::AlertComponent.new(title: t('.email_suggest.wanna_say'), state: :info, heading_level: :div) do |c|
- c.body do
%p{ data: { "email-input-target": 'suggestion'} } exemple@gmail.com &nbsp;?
%p
= button_tag type: 'button', class: 'fr-btn fr-btn--sm fr-mr-3w', data: { action: 'click->email-input#accept'} do
= t('utils.yes')
= button_tag type: 'button', class: 'fr-btn fr-btn--sm', data: { action: 'click->email-input#discard'} do
= t('utils.no')

View file

@ -0,0 +1,33 @@
import { suggest } from 'email-butler';
import { show, hide } from '@utils';
import { ApplicationController } from './application_controller';
export class EmailInputController extends ApplicationController {
static targets = ['ariaRegion', 'suggestion', 'input'];
declare readonly ariaRegionTarget: HTMLElement;
declare readonly suggestionTarget: HTMLElement;
declare readonly inputTarget: HTMLInputElement;
checkEmail() {
const suggestion = suggest(this.inputTarget.value);
if (suggestion && suggestion.full) {
this.suggestionTarget.innerHTML = suggestion.full;
show(this.ariaRegionTarget);
this.ariaRegionTarget.setAttribute('aria-live', 'assertive');
}
}
accept() {
this.ariaRegionTarget.setAttribute('aria-live', 'off');
hide(this.ariaRegionTarget);
this.inputTarget.value = this.suggestionTarget.innerHTML;
this.suggestionTarget.innerHTML = '';
}
discard() {
this.ariaRegionTarget.setAttribute('aria-live', 'off');
hide(this.ariaRegionTarget);
this.suggestionTarget.innerHTML = '';
}
}

View file

@ -24,10 +24,6 @@ import {
motivationCancel,
showImportJustificatif
} from '../new_design/state-button';
import {
acceptEmailSuggestion,
discardEmailSuggestionBox
} from '../new_design/user-sign_up';
import { showFusion, showNewAccount } from '../new_design/fc-fusion';
const application = Application.start();
@ -41,9 +37,7 @@ const DS = {
showImportJustificatif,
showFusion,
showNewAccount,
replaceSemicolonByComma,
acceptEmailSuggestion,
discardEmailSuggestionBox
replaceSemicolonByComma
};
// Start Rails helpers

View file

@ -31,3 +31,4 @@
@import '@gouvfr/dsfr/dist/component/translate/translate.css';
@import '@gouvfr/dsfr/dist/component/pagination/pagination.css';
@import '@gouvfr/dsfr/dist/component/skiplink/skiplink.css';
@import '@gouvfr/dsfr/dist/component/password/password.css';

View file

@ -1,35 +0,0 @@
import { delegate, show, hide } from '@utils';
import { suggest } from 'email-butler';
const userNewEmailSelector = '#new_user input#user_email';
const passwordFieldSelector = '#new_user input#user_password';
const suggestionsSelector = '.suspect-email';
const emailSuggestionSelector = '.suspect-email .email-suggestion-address';
delegate('focusout', userNewEmailSelector, () => {
// When the user leaves the email input during account creation, we check if this account looks correct.
// If not (e.g if its "bidou@gmail.coo" or "john@yahoo.rf"), we attempt to suggest a fix for the invalid email.
const userEmailInput = document.querySelector(userNewEmailSelector);
const suggestedEmailSpan = document.querySelector(emailSuggestionSelector);
const suggestion = suggest(userEmailInput.value);
if (suggestion && suggestion.full && suggestedEmailSpan) {
suggestedEmailSpan.innerHTML = suggestion.full;
show(document.querySelector(suggestionsSelector));
} else {
hide(document.querySelector(suggestionsSelector));
}
});
export function acceptEmailSuggestion() {
const userEmailInput = document.querySelector(userNewEmailSelector);
const suggestedEmailSpan = document.querySelector(emailSuggestionSelector);
userEmailInput.value = suggestedEmailSpan.innerHTML;
hide(document.querySelector(suggestionsSelector));
document.querySelector(passwordFieldSelector).focus();
}
export function discardEmailSuggestionBox() {
hide(document.querySelector(suggestionsSelector));
}

View file

@ -26,8 +26,7 @@
= link_to t('.whats_agentconnect'), 'https://agentconnect.gouv.fr/', target: '_blank', rel: "noopener"
.france-connect-login-separator
= t('views.shared.france_connect_login.separator')
%p.fr-hr-or= t('views.shared.france_connect_login.separator')
#session-new.auth-form.sign-in-form
= form_for User.new, url: user_session_path, html: { class: "form" } do |f|

View file

@ -1,6 +1,6 @@
- if FranceConnectService.enabled?
.france-connect-login
%h2.fr-h6.mb-0
%h2.fr-h6
= t('views.shared.france_connect_login.title')
%p
= t('views.shared.france_connect_login.description')
@ -13,7 +13,6 @@
%p
= link_to t('views.shared.france_connect_login.help_link'), "https://franceconnect.gouv.fr/", title: new_tab_suffix(t('views.shared.france_connect_login.help_link')), **external_link_attributes
.france-connect-login-separator
= t('views.shared.france_connect_login.separator')
%p.fr-hr-or= t('views.shared.france_connect_login.separator')
- else
<!-- FranceConnect is not configured -->

View file

@ -2,26 +2,26 @@
.auth-form
= devise_error_messages!
= form_for resource, url: user_registration_path, html: { class: "form" } do |f|
= form_for resource, url: user_registration_path, html: { class: "fr-py-5w" } do |f|
%h1.fr-h2= t('views.registrations.new.title', name: APPLICATION_NAME)
= render partial: 'shared/france_connect_login', locals: { url: france_connect_particulier_path }
= f.label :email, t('views.registrations.new.email_label'), id: :user_email_label
= f.text_field :email, type: :email, autocomplete: 'email', autofocus: true, placeholder: t('views.registrations.new.email_placeholder'), 'aria-describedby': :user_email_label
%fieldset.fr-mb-0.fr-fieldset{ aria: { labelledby: 'create-account-legend' } }
%legend.fr-fieldset__legend#create-account-legend
%h2.fr-h6= I18n.t('views.registrations.new.subtitle')
.suspect-email.hidden
.email-suggestion-title
= t('views.registrations.new.wanna_say')
%span.email-suggestion-address blabla@gmail.com
&nbsp;?
.email-suggestion-answer
= button_tag type: 'button', class: 'button small', onclick: "DS.acceptEmailSuggestion()" do
= t('utils.yes')
= button_tag type: 'button', class: 'button small', onclick: "DS.discardEmailSuggestionBox()" do
= t('utils.no')
.fr-fieldset__element
%p.fr-text--sm= t('utils.asterisk_html')
= f.label :password, t('views.registrations.new.password_label', min_length: PASSWORD_MIN_LENGTH), id: :user_password_label
= f.password_field :password, autocomplete: 'new-password', value: @user.password, placeholder: t('views.registrations.new.password_placeholder', min_length: PASSWORD_MIN_LENGTH), 'aria-describedby': :user_password_label
.fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email', autofocus: true })
= f.submit t('views.shared.account.create'), class: "button large primary expand"
.fr-fieldset__element
= render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'new-password', min_length: PASSWORD_MIN_LENGTH }) do |c|
- c.describedby do
#password-input-messages.fr-messages-group{ "aria-live" => "off" }
%p#password-input-message.fr-message= t('views.registrations.new.password_message')
%p#password-input-message-info.fr-message.fr-message--info= t('views.registrations.new.password_placeholder', min_length: PASSWORD_MIN_LENGTH)
= f.submit t('views.shared.account.create'), class: "fr-btn fr-btn--lg"

View file

@ -1,32 +1,40 @@
= content_for(:page_id, 'auth')
= content_for(:title, t('metas.signin.title'))
#session-new.auth-form.sign-in-form
= form_for resource, url: user_session_path, html: { class: "form" } do |f|
%h1.huge-title= t('views.users.sessions.new.sign_in')
#session-new.auth-form.sign-in-form
= form_for resource, url: user_session_path, html: { class: "fr-py-5w" } do |f|
%h1.fr-h2= t('views.users.sessions.new.sign_in', application_name: APPLICATION_NAME)
= render partial: 'shared/france_connect_login', locals: { url: france_connect_particulier_path }
= f.label :email, t('views.users.sessions.new.email')
= f.text_field :email, type: :email, autocomplete: 'email', autofocus: true
%fieldset.fr-mb-0.fr-fieldset{ aria: { labelledby: 'new-account-legend' } }
%legend.fr-fieldset__legend#new-account-legend
%h2.fr-h6= I18n.t('views.users.sessions.new.subtitle')
= f.label :password, t('views.users.sessions.new.password', min_length: PASSWORD_MIN_LENGTH)
= f.password_field :password, autocomplete: 'current-password'
.fr-fieldset__element
%p.fr-text--sm= t('utils.asterisk_html')
.auth-options
.flex-no-shrink
= f.check_box :remember_me
= f.label :remember_me, t('views.users.sessions.new.remember_me'), class: 'remember-me'
.fr-fieldset__element= render Dsfr::InputComponent.new(form: f, attribute: :email, input_type: :email_field, opts: { autocomplete: 'email', autofocus: true })
.fr-fieldset__element
= render Dsfr::InputComponent.new(form: f, attribute: :password, input_type: :password_field, opts: { autocomplete: 'current-password' })
%p= link_to t('views.users.sessions.new.reset_password'), new_user_password_path, class: "link"
.fr-fieldset__element
.auth-options
.flex-no-shrink
= f.check_box :remember_me
= f.label :remember_me, t('views.users.sessions.new.remember_me'), class: 'remember-me'
.text-right
= link_to t('views.users.sessions.new.reset_password'), new_user_password_path, class: "link"
= f.submit t('views.users.sessions.new.connection'), class: "fr-btn fr-btn--lg"
- if AgentConnectService.enabled?
.france-connect-login-separator
= t('views.shared.france_connect_login.separator')
%p.fr-hr-or= t('views.shared.france_connect_login.separator')
.center
%h2.important-header.mb-1= t('views.users.sessions.new.state_civil_servant')
= link_to t('views.users.sessions.new.connect_with_agent_connect'), agent_connect_path, class: "fr-btn fr-btn--secondary"