diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index c3e3d4e66..f40b1d781 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -621,3 +621,18 @@ textarea::placeholder { background-color: $white; z-index: 2; } + +.fr-menu__list { + padding: $default-spacer; + overflow-y: auto; + max-height: 300px; + + .fr-menu__item { + list-style-type: none; + margin-bottom: $default-spacer; + + &[aria-selected] { + font-weight: bold; + } + } +} diff --git a/app/components/dsfr/combobox_component.rb b/app/components/dsfr/combobox_component.rb new file mode 100644 index 000000000..d80dd70ce --- /dev/null +++ b/app/components/dsfr/combobox_component.rb @@ -0,0 +1,65 @@ +class Dsfr::ComboboxComponent < ApplicationComponent + def initialize(form: nil, options:, selected: nil, allows_custom_value: false, **html_options) + @form, @options, @selected, @allows_custom_value, @html_options = form, options, selected, allows_custom_value, html_options + end + + attr_reader :form, :options, :selected, :allows_custom_value + + private + + def name + @html_options[:name] + end + + def form_id + @html_options[:form_id] + end + + def html_input_options + { + type: 'text', + autocomplete: 'off', + spellcheck: 'false', + id: input_id, + class: input_class, + value: input_value, + 'aria-expanded': 'false', + 'aria-describedby': @html_options[:describedby] + }.compact + end + + def input_id + @html_options[:id] + end + + def input_class + "#{@html_options[:class].presence || ''} fr-select" + end + + def input_value + selected.present? ? options_with_values.find { _1.last == selected }&.first : nil + end + + def list_id + input_id.present? ? "#{input_id}-list" : nil + end + + def options_with_values + options.map { _1.is_a?(Array) ? _1 : [_1, _1] } + end + + def options_json + options_with_values.map { |(label, value)| { label:, value: } }.to_json + end + + def hints_json + { + empty: t(".sr.results", count: 0), + one: t(".sr.results", count: 1), + many: t(".sr.results", count: 2), + oneWithLabel: t(".sr.results_with_label", count: 1), + manyWithLabel: t(".sr.results_with_label", count: 2), + selected: t(".sr.selected", count: 2) + }.to_json + end +end diff --git a/app/components/dsfr/combobox_component/combobox_component.en.yml b/app/components/dsfr/combobox_component/combobox_component.en.yml new file mode 100644 index 000000000..e24b49f92 --- /dev/null +++ b/app/components/dsfr/combobox_component/combobox_component.en.yml @@ -0,0 +1,10 @@ +en: + sr: + results: + zero: No result + one: 1 result + other: "{count} results" + results_with_label: + one: "1 result. {label} is the top result – press Enter to activate" + other: "{count} results. {label} is the top result – press Enter to activate" + selected: "{label} selected" diff --git a/app/components/dsfr/combobox_component/combobox_component.fr.yml b/app/components/dsfr/combobox_component/combobox_component.fr.yml new file mode 100644 index 000000000..dc76ad006 --- /dev/null +++ b/app/components/dsfr/combobox_component/combobox_component.fr.yml @@ -0,0 +1,10 @@ +fr: + sr: + results: + zero: Aucun résultat + one: 1 résultat + other: "{count} résultats" + results_with_label: + one: "1 résultat. {label} est le premier résultat – appuyez sur Entrée pour sélectionner" + other: "{count} résultats. {label} est le premier résultat – appuyez sur Entrée pour sélectionner" + selected: "{label} sélectionné" diff --git a/app/components/dsfr/combobox_component/combobox_component.html.haml b/app/components/dsfr/combobox_component/combobox_component.html.haml new file mode 100644 index 000000000..d29bda22a --- /dev/null +++ b/app/components/dsfr/combobox_component/combobox_component.html.haml @@ -0,0 +1,13 @@ +.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } } + .fr-ds-combobox-input + %input{ **html_input_options } + - if form + = form.hidden_field name, value: selected, form: form_id + - else + %input{ type: 'hidden', name:, value: selected, form: form_id } + .fr-menu + %ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, selected:, hints: hints_json } } + .sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } } + %template + %li.fr-menu__item{ role: 'option' } + %slot{ name: 'label' } diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml index da52c5d1f..039d7b7f8 100644 --- a/app/views/layouts/component_preview.html.haml +++ b/app/views/layouts/component_preview.html.haml @@ -14,6 +14,7 @@ = render partial: "layouts/favicons" = vite_client_tag + = vite_react_refresh_tag = vite_javascript_tag 'application' = preload_link_tag(asset_url("Marianne-Regular.woff2")) @@ -22,7 +23,7 @@ = vite_stylesheet_tag 'main', media: 'all' = stylesheet_link_tag 'application', media: 'all' - %body{ class: browser.platform.ios? ? 'ios' : nil } + %body{ class: browser.platform.ios? ? 'ios' : nil, data: { controller: 'turbo' } } .page-wrapper %main.m-6 = content_for?(:content) ? yield(:content) : yield diff --git a/spec/components/previews/dsfr/combobox_component_preview.rb b/spec/components/previews/dsfr/combobox_component_preview.rb new file mode 100644 index 000000000..29ea63c3f --- /dev/null +++ b/spec/components/previews/dsfr/combobox_component_preview.rb @@ -0,0 +1,112 @@ +class Dsfr::ComboboxComponentPreview < ViewComponent::Preview + OPTIONS = [ + 'Cheddar', + 'Brie', + 'Mozzarella', + 'Gouda', + 'Swiss', + 'Parmesan', + 'Feta', + 'Blue cheese', + 'Camembert', + 'Monterey Jack', + 'Roquefort', + 'Provolone', + 'Colby', + 'Havarti', + 'Ricotta', + 'Pepper Jack', + 'Muenster', + 'Fontina', + 'Limburger', + 'Asiago', + 'Cottage cheese', + 'Emmental', + 'Mascarpone', + 'Taleggio', + 'Gruyere', + 'Edam', + 'Pecorino Romano', + 'Manchego', + 'Halloumi', + 'Jarlsberg', + 'Munster', + 'Stilton', + 'Gorgonzola', + 'Queso blanco', + 'Queso fresco', + 'Queso de bola', + 'Queso de cabra', + 'Queso panela', + 'Queso Oaxaca', + 'Queso Chihuahua', + 'Queso manchego', + 'Queso de bola', + 'Queso de bola de cabra', + 'Queso de bola de vaca', + 'Queso de bola de oveja', + 'Queso de bola de mezcla', + 'Queso de bola de leche cruda', + 'Queso de bola de leche pasteurizada', + 'Queso de bola de leche de cabra', + 'Queso de bola de leche de vaca', + 'Queso de bola de leche de oveja', + 'Queso de bola de leche de mezcla', + 'Burrata', + 'Scamorza', + 'Caciocavallo', + 'Provolone piccante', + 'Pecorino sardo', + 'Pecorino toscano', + 'Pecorino siciliano', + 'Pecorino calabrese', + 'Pecorino moliterno', + 'Pecorino di fossa', + 'Pecorino di filiano', + 'Pecorino di pienza', + 'Pecorino di grotta', + 'Pecorino di capra', + 'Pecorino di mucca', + 'Pecorino di pecora', + 'Pecorino di bufala', + 'Cacio di bosco', + 'Cacio di roma', + 'Cacio di fossa', + 'Cacio di tricarico', + 'Cacio di cavallo', + 'Cacio di capra', + 'Cacio di mucca', + 'Cacio di pecora', + 'Cacio di bufala', + 'Taleggio di capra', + 'Taleggio di mucca', + 'Taleggio di pecora', + 'Taleggio di bufala', + 'Bel Paese', + 'Crescenza', + 'Stracchino', + 'Robiola', + 'Toma', + 'Bra', + 'Castelmagno', + 'Raschera', + 'Montasio', + 'Piave', + 'Bitto', + 'Quartirolo Lombardo', + 'Formaggella del Luinese', + 'Formaggella della Val Vigezzo', + 'Formaggella della Valle Grana', + 'Formaggella della Val Bognanco', + 'Formaggella della Val d’Intelvi', + 'Formaggella della Val Gerola' + ] + + def simple_select_with_options + render Dsfr::ComboboxComponent.new(name: :value, options: OPTIONS, selected: OPTIONS.sample, id: 'simple-select', class: 'width-33') + end + + def simple_select_with_options_and_allows_custom_value + render Dsfr::ComboboxComponent.new(name: :value, options: OPTIONS, selected: OPTIONS.sample, id: 'simple-select', class: 'width-33', allows_custom_value: true) + end +end