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:
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 |
|
@ -21,10 +21,6 @@
|
|||
padding-top: $default-spacer;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: $default-spacer;
|
||||
}
|
||||
|
||||
.form label {
|
||||
margin-bottom: $default-spacer / 2;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
en:
|
||||
show_password:
|
||||
aria_label: "Show password"
|
||||
label: "Show"
|
||||
email_suggest:
|
||||
wanna_say: 'Do you mean to say ?'
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
fr:
|
||||
show_password:
|
||||
aria_label: "Afficher le mot de passe"
|
||||
label: "Afficher"
|
||||
email_suggest:
|
||||
wanna_say: 'Voulez-vous dire ?'
|
|
@ -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 ?
|
||||
%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')
|
||||
|
|
33
app/javascript/controllers/email_input_controller.ts
Normal file
33
app/javascript/controllers/email_input_controller.ts
Normal 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 = '';
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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));
|
||||
}
|
|
@ -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|
|
||||
|
|
|
@ -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 -->
|
||||
|
|
|
@ -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
|
||||
?
|
||||
.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"
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue