Merge branch 'main' of github.com:betagouv/demarches-simplifiees.fr into instructeur-invitation-include-typo-suggestion-ldu

This commit is contained in:
Lisa Durand 2024-07-10 14:58:55 +02:00
commit 42633c0012
No known key found for this signature in database
GPG key ID: 0DF91F2CA1E8B816
138 changed files with 2056 additions and 3526 deletions

View file

@ -28,3 +28,7 @@ body {
.container {
@extend %container;
}
react-fragment {
display: block;
}

View file

@ -10,10 +10,6 @@
}
}
.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] {
margin-bottom: 0;
}
.map-style-control {
position: absolute;
bottom: 4px;

View file

@ -32,29 +32,87 @@ trix-editor.fr-input {
}
.fr-ds-combobox {
.fr-menu {
width: 100%;
.fr-menu__list {
width: 100%;
max-height: 300px;
}
}
.fr-autocomplete {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
}
}
.fr-ds-combobox__multiple {
.fr-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
}
.fr-ds-combobox__menu {
&[data-placement=top] {
--origin: translateY(8px);
}
&[data-placement=bottom] {
--origin: translateY(-8px);
}
&[data-placement=right] {
--origin: translateX(-8px);
}
&[data-placement=left] {
--origin: translateX(8px);
}
&[data-entering] {
animation: popover-slide 200ms;
}
&.fr-menu {
width: var(--trigger-width);
top: unset;
.fr-menu__list {
display: block;
width: unset;
max-height: 300px;
overflow: auto;
}
.fr-menu__item {
&[data-selected] {
font-weight: bold;
}
&[data-focused] {
font-weight: bold;
}
}
}
}
@keyframes popover-slide {
from {
transform: var(--origin);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 62em) {
.fr-ds-combobox .fr-menu .fr-menu__list {
z-index: calc(var(--ground) + 1000);
background-color: var(--background-default-grey);
--idle: transparent;
--hover: var(--background-overlap-grey-hover);
--active: var(--background-overlap-grey-active);
filter: drop-shadow(var(--overlap-shadow));
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
.fr-ds-combobox__menu {
&.fr-menu .fr-menu__list {
z-index: calc(var(--ground) + 1000);
background-color: var(--background-default-grey);
--idle: transparent;
--hover: var(--background-overlap-grey-hover);
--active: var(--background-overlap-grey-active);
filter: drop-shadow(var(--overlap-shadow));
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
}
}
}

View file

@ -356,41 +356,6 @@
margin-bottom: 0;
}
[data-reach-combobox-input] {
&:not([class^='width-']) {
width: 100%;
min-width: 50%;
max-width: 100%;
}
&:focus {
border-color: $blue-france-500;
}
}
[data-reach-combobox-token-list] {
padding: $default-spacer;
display: flex;
flex-wrap: wrap;
align-items: center;
list-style: none;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
border-radius: 4px;
padding: $default-spacer;
margin-right: $default-spacer;
cursor: pointer;
display: flex;
align-items: center;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
.editable-champ {
&:not(.editable-champ-carte) .algolia-autocomplete {
margin-bottom: 2 * $default-padding;
@ -524,91 +489,8 @@
}
}
[data-react-component-value^="ComboMultiple"] {
.fr-ds-combobox__multiple {
margin-bottom: $default-fields-spacer;
[data-reach-combobox-input] {
flex-grow: 1;
background-image: image-url("icons/chevron-down");
background-size: 14px;
background-repeat: no-repeat;
background-position: right 10px center;
border-radius: 4px;
border: solid 1px $border-grey;
padding: $default-padding;
margin: $default-spacer;
margin-top: 0;
width: 100%;
}
ul {
list-style: none;
li {
margin-right: $default-spacer;
display: inline-block;
}
}
}
[data-reach-combobox-token-label] {
border: 1px solid #CCCCCC;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
}
[data-reach-combobox-option] {
font-size: 16px;
list-style-type: none;
}
[data-reach-combobox-option][aria-selected="true"] {
background: $light-blue !important;
color: $white;
}
[data-reach-combobox-separator] {
font-size: 16px;
color: $dark-grey;
background: $light-grey;
padding: $default-spacer;
}
[data-reach-combobox-no-results] {
font-size: 16px;
color: $dark-grey;
background: $light-grey;
padding: $default-spacer;
}
[data-reach-combobox-token] button {
cursor: pointer;
background-color: transparent;
background-image: none;
border: none;
line-height: 1;
padding: 0;
margin-right: 4px;
display: flex;
align-items: center !important;
}
[data-reach-combobox-input] button:focus {
outline-color: $light-blue;
}
[data-fr-theme="dark"] [data-reach-combobox-popover] {
border: none;
background: var(--background-action-low-blue-france);
}
[data-fr-theme="dark"] [data-reach-combobox-option]:hover {
background: var(--background-action-low-blue-france-hover);
}
[data-reach-combobox-popover] {
z-index: 20;
}
.fconnect-form {
@ -634,10 +516,6 @@ textarea::placeholder {
.fr-menu__item {
list-style-type: none;
margin-bottom: $default-spacer;
&[aria-selected] {
font-weight: bold;
}
}
}

View file

@ -1,36 +1,5 @@
@import "constants";
[data-reach-combobox-token-label] {
border: 1px solid #CCCCCC;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
}
.form [data-reach-combobox-token-list] {
padding: 8px;
display: flex;
align-items: center;
list-style: none;
}
.form [data-reach-combobox-input]:not([class^='width-']) {
width: 100%;
min-width: 50%;
max-width: 100%;
}
.form [data-reach-combobox-token] button {
border: solid 1px #CCCCCC;
background-color: transparent;
border-radius: 4px;
padding: 8px;
margin-right: 8px;
cursor: pointer;
display: flex;
align-items: center;
}
.hidden {
display: none;
}
@ -70,4 +39,79 @@
margin-bottom: 4px;
}
}
.fr-ds-combobox {
.fr-autocomplete {
background-repeat: no-repeat;
background-position: right;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
}
}
.fr-ds-combobox__multiple {
.fr-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.3rem;
}
.fr-tag {
font-size: small;
padding: 0.5rem;
display: flex;
align-items: center;
border: solid 1px #dcdcdc;
button {
margin-left: 0.3rem;
}
}
}
.fr-ds-combobox__menu {
&[data-placement=top] {
--origin: translateY(8px);
}
&[data-placement=bottom] {
--origin: translateY(-8px);
}
&[data-placement=right] {
--origin: translateX(-8px);
}
&[data-placement=left] {
--origin: translateX(8px);
}
&[data-entering] {
animation: popover-slide 200ms;
}
&.fr-menu {
width: var(--trigger-width);
top: unset;
background-color: white;
border: solid 1px #dcdcdc;
.fr-menu__list {
display: block;
width: unset;
max-height: 300px;
overflow: auto;
}
.fr-menu__item {
&[data-selected] {
font-weight: bold;
}
&[data-focused] {
font-weight: bold;
}
}
}
}
}

View file

@ -9,41 +9,7 @@
margin-left: 16px;
}
[data-react-component-value^="ComboMultiple"] {
.fr-ds-combobox__multiple {
margin-bottom: 0;
[data-reach-combobox-token-list] {
padding: 0.5 * $default-padding;
display: flex;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
margin-top: 0.5 * $default-padding;
margin-bottom: 0.5 * $default-padding;
margin-right: 0.5 * $default-padding;
border-radius: 4px;
padding: 0.5 * $default-padding;
cursor: pointer;
list-style: none;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
[data-reach-combobox-input] {
outline: none;
border: none;
flex-grow: 1;
margin: 0.25rem;
}
[data-reach-combobox-input]:focus {
outline: solid;
outline-color: $light-blue;
}
}
}

View file

@ -45,45 +45,8 @@
display: inline-block;
}
[data-react-component-value^="ComboMultiple"] {
.fr-ds-combobox__multiple {
margin-bottom: $default-fields-spacer;
[data-reach-combobox-token-list] {
padding: 0.25 * $default-padding;
display: inline-block;
width: 100%;
}
[data-reach-combobox-token] button {
border: solid 1px $border-grey;
margin: 0.25 * $default-padding;
border-radius: 2px;
padding: 0.25 * $default-padding;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
}
[data-reach-combobox-token] button:focus {
background-color: $black;
color: $white;
}
[data-reach-combobox-input] {
outline: none;
flex-grow: 1;
margin: $default-spacer;
padding: $default-spacer;
border-radius: 4px;
border: solid 1px $border-grey;
margin-top: 0;
}
[data-reach-combobox-input]:focus {
border-color: $blue-france-500;
}
}
// fix/dsfr

View file

@ -10,7 +10,7 @@ class Attachment::EditComponent < ApplicationComponent
EXTENSIONS_ORDER = ['jpeg', 'png', 'pdf', 'zip'].freeze
def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], **kwargs)
def initialize(champ: nil, auto_attach_url: nil, attached_file:, direct_upload: true, index: 0, as_multiple: false, view_as: :link, user_can_destroy: true, user_can_replace: false, attachments: [], max: nil, **kwargs)
@champ = champ
@attached_file = attached_file
@direct_upload = direct_upload
@ -24,6 +24,7 @@ class Attachment::EditComponent < ApplicationComponent
@attachments = attachments.presence || (kwargs.key?(:attachment) ? [kwargs.delete(:attachment)] : [])
@attachments << attached_file.attachment if attached_file.respond_to?(:attachment) && @attachments.empty?
@attachments.compact!
@max = max
# Utilisation du premier attachement comme référence pour la rétrocompatibilité
@attachment = @attachments.first
@ -54,7 +55,7 @@ class Attachment::EditComponent < ApplicationComponent
end
def destroy_attachment_path
attachment_path(champ_id: champ&.public_id)
attachment_path(dossier_id: champ&.dossier_id, stable_id: champ&.stable_id, row_id: champ&.row_id)
end
def attachment_input_class
@ -63,6 +64,7 @@ class Attachment::EditComponent < ApplicationComponent
def file_field_options
track_issue_with_missing_validators if missing_validators?
options = {
class: class_names("fr-upload attachment-input": true, "#{attachment_input_class}": true, "hidden": persisted?),
direct_upload: @direct_upload,
@ -76,6 +78,7 @@ class Attachment::EditComponent < ApplicationComponent
options.merge!(has_content_type_validator? ? { accept: accept_content_type } : {})
options[:multiple] = true if as_multiple?
options[:disabled] = true if @max && @index >= @max
options
end

View file

@ -30,10 +30,6 @@ class Attachment::MultipleComponent < ApplicationComponent
@attachments.each_with_index(&block)
end
def can_attach_next?
@attachments.count < @max
end
def empty_component_id
champ.present? ? "attachment-multiple-empty-#{champ.public_id}" : "attachment-multiple-empty-generic"
end

View file

@ -7,8 +7,8 @@
%li{ id: dom_id(attachment) }
= render Attachment::EditComponent.new(champ:, attached_file:, attachment:, index:, view_as:, user_can_destroy:, form_object_name:)
%div{ id: empty_component_id, class: class_names("hidden": !can_attach_next?), data: { turbo_force: :server } }
= render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:)
%div{ id: empty_component_id, data: { turbo_force: :server } }
= render Attachment::EditComponent.new(champ:, as_multiple: champ.nil?, attached_file:, attachment: nil, index: attachments_count, user_can_destroy:, form_object_name:, max: @max)
// single poll and refresh message for all attachments
= render Attachment::PendingPollComponent.new(attachments: attachments, poll_url:, context: poll_context)

View file

@ -8,10 +8,6 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
attr_reader :procedure, :procedure_presentation, :statut, :field_id
def filterable_fields_for_select
procedure_presentation.filterable_fields_options
end
def field_type
return :text if field_id.nil?
procedure_presentation.field_type(field_id)
@ -20,4 +16,16 @@ class Dossiers::InstructeurFilterComponent < ApplicationComponent
def options_for_select_of_field
procedure_presentation.field_enum(field_id)
end
def filter_react_props
{
selected_key: @field_id || '',
items: procedure_presentation.filterable_fields_options,
name: :field,
id: 'search-filter',
'aria-describedby': 'instructeur-filter-combo-label',
form: 'filter-component',
data: { no_autosubmit: 'input blur', no_autosubmit_on_empty: 'true', autosubmit_target: 'input' }
}
end
end

View file

@ -1,11 +1,8 @@
= form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do
.fr-select-group
= label_tag :field, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter'
= render Dsfr::ComboboxComponent.new form: nil,
options: filterable_fields_for_select,
selected: field_id,
input_html_options: { name: :field, id: 'search-filter', class: 'fr-select', describedby: 'instructeur-filter-combo-label', allows_custom_value: false, form_id: 'filter-component' },
hidden_html_options: { data: { no_autosubmit: ['input', 'blur'].join(' '), no_autosubmit_on_empty: "true", autosubmit_target: 'input' } }
%react-fragment
= render ReactComponent.new "ComboBox/SingleComboBox", **filter_react_props
%input.hidden{ type: 'submit', formaction: update_filter_instructeur_procedure_path(procedure), data: { autosubmit_target: 'submitter' } }

View file

@ -2,6 +2,17 @@
class Dsfr::AlertComponent < ApplicationComponent
renders_one :body
attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level
def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3')
@state = state
@title = title
@size = size
@block = block
@extra_class_names = extra_class_names
@heading_level = heading_level
end
def prefix_for_state
case state
when :error then "Erreur : "
@ -19,19 +30,4 @@ class Dsfr::AlertComponent < ApplicationComponent
extra_class_names => true
)
end
private
def initialize(state:, title: '', size: '', extra_class_names: nil, heading_level: 'h3')
@state = state
@title = title
@size = size
@block = block
@extra_class_names = extra_class_names
@heading_level = heading_level
end
attr_reader :state, :title, :size, :block, :extra_class_names, :heading_level
private
end

View file

@ -1,10 +0,0 @@
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"

View file

@ -1,10 +0,0 @@
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é"

View file

@ -1,14 +0,0 @@
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value, limit: limit } }
.fr-ds-combobox-input
%input{ value: selected_option_label_input_value, **html_input_options }
- if form
= form.hidden_field name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options
- else
%input{ type: 'hidden', name: name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options }
.fr-menu
%ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, url:, hints: hints_json }.compact }
.sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
%template
%li.fr-menu__item{ role: 'option' }
%slot{ name: 'label' }
= content

View file

@ -73,22 +73,26 @@ module Dsfr
}
end
def input_opts(other_opts = {})
def react_input_opts(other_opts = {})
input_opts(other_opts, true)
end
def input_opts(other_opts = {}, react = false)
@opts = @opts.deep_merge!(other_opts)
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class])
@opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class])
.merge({
'fr-password__input': password?,
'fr-input': true,
'fr-input': !react,
'fr-mb-0': true
}.merge(input_error_class_names)))
if errors_on_attribute?
@opts.deep_merge!(aria: { describedby: describedby_id })
@opts.deep_merge!('aria-describedby': describedby_id)
elsif hintable?
@opts.deep_merge!(aria: { describedby: hint_id })
@opts.deep_merge!('aria-describedby': hint_id)
end
if @required
@opts[:required] = true
@opts[react ? :is_required : :required] = true
end
if email?

View file

@ -2,4 +2,15 @@ class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponen
def dsfr_input_classname
'fr-select'
end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:value),
selected_key: @champ.value,
items: @champ.selected_items,
loader: data_sources_data_source_adresse_path,
minimum_input_length: 2,
allows_custom_value: true)
end
end

View file

@ -1,3 +1,3 @@
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_adresse_path, selected: @champ.value, allows_custom_value: true, input_html_options: { name: :value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do
= @form.hidden_field :external_id, data: { value_slot: 'value' }
= @form.hidden_field :feature, data: { value_slot: 'data' }
%react-fragment
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: @form.field_name(:feature)

View file

@ -1,12 +1,15 @@
class EditableChamp::AnnuaireEducationComponent < EditableChamp::ComboSearchComponent
class EditableChamp::AnnuaireEducationComponent < EditableChamp::EditableChampBaseComponent
def dsfr_input_classname
'fr-input'
'fr-select'
end
def react_input_opts
opts = input_opts(id: @champ.input_id, required: @champ.required?, aria: { describedby: @champ.describedby_id })
opts[:className] = "#{opts.delete(:class)} fr-mt-1w"
opts
def react_props
react_input_opts(id: @champ.input_id,
class: "fr-mt-1w",
name: @form.field_name(:external_id),
selected_key: @champ.external_id,
items: @champ.selected_items,
loader: data_sources_data_source_education_path,
minimum_input_length: 3)
end
end

View file

@ -1,7 +1,3 @@
- render_parent
= @form.hidden_field :value
= @form.hidden_field :external_id
= react_component("ComboAnnuaireEducationSearch",
**react_input_opts,
**react_combo_props)
%react-fragment
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props do
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :label, name: @form.field_name(:value)

View file

@ -4,10 +4,14 @@ class EditableChamp::CarteComponent < EditableChamp::EditableChampBaseComponent
:fieldset
end
def initialize(**args)
super(**args)
@autocomplete_component = EditableChamp::ComboSearchComponent.new(**args)
def react_props
{
feature_collection: @champ.to_feature_collection,
champ_id: @champ.input_id,
url: update_path,
adresse_source: data_sources_data_source_adresse_path,
options: @champ.render_options
}
end
def update_path

View file

@ -1,14 +1,6 @@
.fr-fieldset__element
= render @autocomplete_component
= react_component("MapEditor",
{ featureCollection: @champ.to_feature_collection,
champId: @champ.input_id,
url: update_path,
options: @champ.render_options,
autocompleteAnnounceTemplateId: @autocomplete_component.announce_template_id,
autocompleteScreenReaderInstructions: t("combo_search_component.screen_reader_instructions") },
{class: 'width-100'})
%react-fragment.width-100
= render ReactComponent.new "MapEditor", **react_props
.geo-areas{ id: dom_id(@champ, :geo_areas) }
= render Dossiers::GeoAreasComponent.new(champ: @champ, editing: true)

View file

@ -1,17 +0,0 @@
class EditableChamp::ComboSearchComponent < EditableChamp::EditableChampBaseComponent
include ApplicationHelper
def announce_template_id
@announce_template_id ||= dom_id(@champ, "aria-announce-template")
end
# NOTE: because this template is called by `render_parent` from a child template,
# as of ViewComponent 2.x translations virtual paths are not properly propagated
# and we can't use the usual component namespacing. Instead we use global translations.
def react_combo_props
{
screenReaderInstructions: t("combo_search_component.screen_reader_instructions"),
announceTemplateId: announce_template_id
}
end
end

View file

@ -1,4 +0,0 @@
%template{ id: announce_template_id }
%slot{ "name": "0" }= t("combo_search_component.result_slot_html", count: 0)
%slot{ "name": "1" }= t("combo_search_component.result_slot_html", count: 1)
%slot{ "name": "many" }= t("combo_search_component.result_slot_html", count: 2)

View file

@ -4,4 +4,15 @@ class EditableChamp::CommunesComponent < EditableChamp::EditableChampBaseCompone
def dsfr_input_classname
'fr-select'
end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:code),
selected_key: @champ.selected,
items: @champ.selected_items,
loader: data_sources_data_source_commune_path(with_combined_code: true),
limit: 20,
minimum_input_length: 2)
end
end

View file

@ -1,2 +1,2 @@
= render Dsfr::ComboboxComponent.new form: @form, url: data_sources_data_source_commune_path, selected: [@champ.to_s, @champ.selected], limit: 20, input_html_options: { name: :external_id, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id } do
= @form.hidden_field :code_postal, data: { value_slot: 'data:string' }
%react-fragment
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props

View file

@ -23,4 +23,13 @@ class EditableChamp::DropDownListComponent < EditableChamp::EditableChampBaseCom
max_length = 100
@champ.enabled_non_empty_options.any? { _1.size > max_length }
end
def react_props
react_input_opts(id: @champ.input_id,
class: 'fr-mt-1w',
name: @form.field_name(:value),
selected_key: @champ.selected,
items: @champ.enabled_non_empty_options(other: true).map { _1.is_a?(Array) ? _1 : [_1, _1] },
empty_filter_key: @champ.drop_down_other? ? Champs::DropDownListChamp::OTHER : nil)
end
end

View file

@ -18,7 +18,8 @@
%label.fr-label{ for: dom_id(@champ, "radio_option_other") }
= t('shared.champs.drop_down_list.other')
- elsif @champ.render_as_combobox?
= render Dsfr::ComboboxComponent.new form: @form, options: @champ.enabled_non_empty_options(other: true), selected: @champ.selected, input_html_options: { name: :value, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id }
%react-fragment
= render ReactComponent.new "ComboBox/SingleComboBox", **react_props
- else
= @form.select :value,
@champ.enabled_non_empty_options(other: true),

View file

@ -14,6 +14,20 @@ class Procedure::ChorusFormComponent < ApplicationComponent
}
end
def selected_key(attribute_name)
items(attribute_name).first&.dig(:value)
end
def items(attribute_name)
label = format_displayed_value(attribute_name)
data = format_hidden_value(attribute_name)
if label.present?
[{ label:, value: label, data: }]
else
[]
end
end
def format_displayed_value(attribute_name)
case attribute_name
when :centre_de_cout
@ -30,13 +44,23 @@ class Procedure::ChorusFormComponent < ApplicationComponent
def format_hidden_value(attribute_name)
case attribute_name
when :centre_de_cout
@chorus_configuration.centre_de_cout.to_json
@chorus_configuration.centre_de_cout
when :domaine_fonctionnel
@chorus_configuration.domaine_fonctionnel.to_json
@chorus_configuration.domaine_fonctionnel
when :referentiel_de_programmation
@chorus_configuration.referentiel_de_programmation.to_json
@chorus_configuration.referentiel_de_programmation
else
raise 'unknown attribute_name'
end
end
def react_props(name, chorus_configuration_attribute, datasource_endpoint)
{
name:,
selected_key: selected_key(chorus_configuration_attribute),
items: items(chorus_configuration_attribute),
loader: datasource_endpoint,
id: chorus_configuration_attribute
}
end
end

View file

@ -1,12 +1,10 @@
= form_for([procedure, @chorus_configuration],url: admin_procedure_chorus_path(procedure), method: :put) do |f|
= form_for([procedure, @chorus_configuration], url: admin_procedure_chorus_path(procedure), method: :put) do |f|
- map_attribute_to_autocomplete_endpoint.map do |chorus_configuration_attribute, datasource_endpoint|
- label_id = "#{chorus_configuration_attribute}-label"
.fr-select-group
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id
= render Dsfr::ComboboxComponent.new form: f,
url: datasource_endpoint,
selected: format_displayed_value(chorus_configuration_attribute),
input_html_options: { id: chorus_configuration_attribute, class: 'fr-select', describedby: label_id, name: :chorus_configuration_attribute } do
= f.hidden_field chorus_configuration_attribute, data: { value_slot: 'data' }, value: format_hidden_value(chorus_configuration_attribute)
= f.label chorus_configuration_attribute, class: 'fr-label', id: label_id, for: chorus_configuration_attribute
%react-fragment
= render ReactComponent.new "ComboBox/RemoteComboBox", **react_props(f.field_name(:chorus_configuration_attribute), chorus_configuration_attribute, datasource_endpoint) do
= render ReactComponent.new "ComboBox/ComboBoxValueSlot", field: :data, name: f.field_name(chorus_configuration_attribute)
= f.submit "Enregister", class: 'fr-btn'

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class ProcedureDraftWarningComponent < ApplicationComponent
attr_reader :revision
attr_reader :current_administrateur
attr_reader :extra_class_names
def initialize(revision:, current_administrateur:, extra_class_names: nil)
@revision = revision
@current_administrateur = current_administrateur
@extra_class_names = extra_class_names
end
def render?
revision.draft?
end
def admin?
current_administrateur.present? && revision.procedure.administrateurs.include?(current_administrateur)
end
end

View file

@ -0,0 +1,13 @@
---
en:
title: Procedure in testing
intro_procedure_brouillon_html: This procedure is currently in <b>testing</b>
intro_revision_draft_html: This page allows you to <b>test</b> a new version of the procedure
body_general_html: |
and this page is reserved for the administration in charge of its deployment.
If you start or submit a file, it may be <b>deleted at any time</b> without notice, even if it is accepted later.
body_user: |
If this link was shared with you, please contact the service in charge of this procedure
to obtain the public link for the procedure in order to submit your application.
body_admin_procedure_brouillon: Do not share this link with your users. When you publish the procedure, you will access the public link for the procedure to be shared.
body_admin_revision_draft: Do not share this link with your users, but rather the public link for the procedure displayed in your administrator dashboard.

View file

@ -0,0 +1,13 @@
---
fr:
title: Démarche en test
intro_procedure_brouillon_html: Cette démarche est actuellement en <b>test</b>
intro_revision_draft_html: Cette page permet de <b>tester</b> une nouvelle version de la démarche
body_general_html: |
et cette page est réservée à ladministration en charge de son déploiement.
Si vous commencez ou déposez un dossier, il pourra être <b>supprimé à tout moment</b> et sans préavis, même après avoir été accepté.
body_user: |
Si ce lien vous a été communiqué, contactez le service en charge de cette démarche
pour obtenir le lien public de la démarche afin de déposer votre dossier.
body_admin_procedure_brouillon: Ne communiquez pas ce lien à vos usagers. Lorsque vous publierez la démarche, vous accéderez au lien public de la démarche à communiquer.
body_admin_revision_draft: Ne communiquez pas ce lien à vos usagers, mais le lien public de la démarche affiché dans votre tableau de bord administrateur.

View file

@ -0,0 +1,10 @@
= render Dsfr::AlertComponent.new(state: :warning, extra_class_names:, title: t(".title")) do |c|
- c.with_body do
%p
= revision.procedure.brouillon? ? t(".intro_procedure_brouillon_html") : t(".intro_revision_draft_html")
= t(".body_general_html")
- if admin?
%p= revision.procedure.brouillon? ? t(".body_admin_procedure_brouillon") : t(".body_admin_revision_draft")
- else
%p= t(".body_user")

View file

@ -0,0 +1,14 @@
class ReactComponent < ApplicationComponent
erb_template <<-ERB
<% if content? %>
<react-component name=<%= @name %> props="<%= @props.to_json %>"><%= content %></react-component>
<% else %>
<react-component name=<%= @name %> props="<%= @props.to_json %>"></react-component>
<% end %>
ERB
def initialize(name, **props)
@name = name
@props = props
end
end

View file

@ -527,11 +527,10 @@ module Administrateurs
:accuse_lecture,
:api_entreprise_token,
:duree_conservation_dossiers_dans_ds,
{ zone_ids: [] },
:lien_dpo,
:opendata,
:procedure_expires_when_termine_enabled,
:tags
{ zone_ids: [], tags: [] }
]
editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple?
@ -544,9 +543,6 @@ module Administrateurs
if permited_params[:auto_archive_on].present?
permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day
end
if permited_params[:tags].present?
permited_params[:tags] = JSON.parse(permited_params[:tags])
end
permited_params
end

View file

@ -12,8 +12,8 @@ class AgentConnect::AgentController < ApplicationController
def login
uri, state, nonce = AgentConnectService.authorization_uri
cookies.encrypted[STATE_COOKIE_NAME] = state
cookies.encrypted[NONCE_COOKIE_NAME] = nonce
cookies.encrypted[STATE_COOKIE_NAME] = { value: state, secure: Rails.env.production?, httponly: true }
cookies.encrypted[NONCE_COOKIE_NAME] = { value: nonce, secure: Rails.env.production?, httponly: true }
redirect_to uri, allow_other_host: true
end

View file

@ -117,7 +117,7 @@ class ApplicationController < ActionController::Base
def set_locale(locale)
if locale && locale.to_sym.in?(I18n.available_locales)
cookies[:locale] = locale
cookies[:locale] = { value: locale, secure: Rails.env.production?, httponly: true }
if user_signed_in?
current_user.update(locale: locale)
end

View file

@ -24,7 +24,8 @@ module ApplicationController::LongLivedAuthenticityToken
cookies.signed[COOKIE_NAME] = {
value: csrf_token,
expires: 1.year.from_now,
httponly: true
httponly: true,
secure: Rails.env.production?
}
session[:_csrf_token] = csrf_token

View file

@ -21,11 +21,18 @@ class AttachmentsController < ApplicationController
@attachment.purge_later
flash.notice = 'La pièce jointe a bien été supprimée.'
@champ_id = params[:champ_id]
@champ = find_champ if params[:dossier_id]
respond_to do |format|
format.turbo_stream
format.html { redirect_back(fallback_location: root_url) }
end
end
private
def find_champ
dossier = policy_scope(Dossier).includes(:champs).find(params[:dossier_id])
dossier.champs.find_by(stable_id: params[:stable_id], row_id: params[:row_id])
end
end

View file

@ -4,7 +4,7 @@ module CreateAvisConcern
private
def create_avis_from_params(dossier, instructeur_or_expert, confidentiel = false)
if create_avis_params[:emails].empty?
if create_avis_params[:emails].blank?
avis = Avis.new(create_avis_params)
errors = avis.errors
errors.add(:emails, :blank)
@ -19,8 +19,8 @@ module CreateAvisConcern
# the :emails parameter is a 1-element array.
# Hence the call to first
# https://github.com/rails/rails/issues/17225
expert_emails = create_avis_params[:emails].presence || [].to_json
expert_emails = JSON.parse(expert_emails).map(&:strip).map(&:downcase)
expert_emails = create_avis_params[:emails].presence || []
expert_emails = expert_emails.map(&:strip).map(&:downcase)
allowed_dossiers = [dossier]
if create_avis_params[:invite_linked_dossiers].present?
@ -84,6 +84,6 @@ module CreateAvisConcern
end
def create_avis_params
params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :emails, :question_label)
params.require(:avis).permit(:introduction_file, :introduction, :confidentiel, :invite_linked_dossiers, :question_label, emails: [])
end
end

View file

@ -61,11 +61,18 @@ class DataSources::CommuneController < ApplicationController
else
[item]
end.map do |item|
{
label: "#{item[:name]} (#{item[:postal_code]})",
value: item[:code],
data: item[:postal_code]
}
if params[:with_combined_code].present?
{
label: "#{item[:name]} (#{item[:postal_code]})",
value: "#{item[:code]}-#{item[:postal_code]}"
}
else
{
label: "#{item[:name]} (#{item[:postal_code]})",
value: item[:code],
data: item[:postal_code]
}
end
end
end
end

View file

@ -0,0 +1,38 @@
class DataSources::EducationController < ApplicationController
def search
if params[:q].present? && params[:q].length >= 3
response = fetch_results
if response.success?
results = JSON.parse(response.body, symbolize_names: true)
return render json: format_results(results)
end
end
render json: []
rescue JSON::ParserError => e
Sentry.set_extras(body: response.body, code: response.code)
Sentry.capture_exception(e)
render json: []
end
private
def fetch_results
Typhoeus.get("#{API_EDUCATION_URL}/search", params: { q: params[:q], rows: 5, dataset: 'fr-en-annuaire-education' }, timeout: 3)
end
def format_results(results)
results[:records].map do |record|
fields = record.fetch(:fields)
value = fields.fetch(:identifiant_de_l_etablissement)
{
label: "#{fields.fetch(:nom_etablissement)}, #{fields.fetch(:nom_commune)} (#{value})",
value:,
data: record
}
end
end
end

View file

@ -6,8 +6,8 @@ module Gestionnaires
end
def create
emails = [params.require(:administrateur)[:email]].to_json
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
emails = [params.require(:administrateur)[:email]].compact
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
administrateurs_to_add, valid_emails, invalid_emails = Administrateur.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - administrateurs_to_add.map(&:email)

View file

@ -6,8 +6,8 @@ module Gestionnaires
end
def create
emails = [params.require(:gestionnaire)[:email]].to_json
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
emails = [params.require(:gestionnaire)[:email]].compact
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)

View file

@ -86,9 +86,9 @@ module Instructeurs
end
def send_to_instructeurs
recipients = params['recipients'].presence || [].to_json
recipients = params['recipients'].presence || []
# instructeurs are scoped by groupe_instructeur to avoid enumeration
recipients = dossier.groupe_instructeur.instructeurs.where(id: JSON.parse(recipients))
recipients = dossier.groupe_instructeur.instructeurs.where(id: recipients)
if recipients.present?
recipients.each do |recipient|
@ -401,6 +401,7 @@ module Instructeurs
:value,
:value_other,
:external_id,
:code,
:primary_value,
:secondary_value,
:numero_allocataire,

View file

@ -73,7 +73,6 @@ module Instructeurs
@current_filters = current_filters
@displayable_fields_for_select, @displayable_fields_selected = procedure_presentation.displayable_fields_for_select
@filterable_fields_for_select = procedure_presentation.filterable_fields_options
@counts = current_instructeur
.dossiers_count_summary(groupe_instructeur_ids)
.symbolize_keys
@ -135,8 +134,8 @@ module Instructeurs
end
def update_displayed_fields
values = params['values'].presence || [].to_json
procedure_presentation.update_displayed_fields(JSON.parse(values))
values = params['values'].presence || []
procedure_presentation.update_displayed_fields(values)
redirect_back(fallback_location: instructeur_procedure_url(procedure))
end
@ -248,7 +247,9 @@ module Instructeurs
@export_templates = current_instructeur.export_templates_for(@procedure).includes(:groupe_instructeur)
cookies.encrypted[cookies_export_key] = {
value: DateTime.current,
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT
expires: Export::MAX_DUREE_GENERATION + Export::MAX_DUREE_CONSERVATION_EXPORT,
httponly: true,
secure: Rails.env.production?
}
respond_to do |format|

View file

@ -2,8 +2,8 @@ module Manager
class GroupeGestionnairesController < Manager::ApplicationController
def add_gestionnaire
groupe_gestionnaire = GroupeGestionnaire.find(params[:id])
emails = [params['emails'].presence || ''].to_json
emails = JSON.parse(emails).map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
emails = [params['emails']].compact
emails = emails.map { EmailSanitizableConcern::EmailSanitizer.sanitize(_1) }
gestionnaires_to_add, valid_emails, invalid_emails = Gestionnaire.find_all_by_identifier_with_emails(emails:)
not_found_emails = valid_emails - gestionnaires_to_add.map(&:email)

View file

@ -111,8 +111,7 @@ module Manager
end
def add_tags
tags_h = { tags: JSON.parse(tags_params[:tags]) }
if procedure.update(tags_h)
if procedure.update(tags: tags_params[:tags])
flash.notice = "Le modèle est mis à jour."
else
flash.alert = procedure.errors.full_messages.join(', ')
@ -181,7 +180,7 @@ module Manager
end
def tags_params
params.require(:procedure).permit(:tags)
params.require(:procedure).permit(tags: [])
end
def template_params

View file

@ -494,6 +494,7 @@ module Users
:value,
:value_other,
:external_id,
:code,
:primary_value,
:secondary_value,
:numero_allocataire,

View file

@ -59,10 +59,6 @@ module ApplicationHelper
'alert'
end
def react_component(name, props = {}, html = {})
tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json }))
end
def current_email
current_user&.email ||
current_instructeur&.email ||

View file

@ -1,32 +0,0 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import type { FeatureCollection, Geometry } from 'geojson';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
type RawResult = FeatureCollection<Geometry, { label: string }>;
type AdresseResult = RawResult['features'][0];
type ComboAdresseSearchProps = Omit<
ComboSearchProps<AdresseResult>,
'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope'
>;
export default function ComboAdresseSearch({
allowInputValues = true,
...props
}: ComboAdresseSearchProps) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch<AdresseResult>
{...props}
allowInputValues={allowInputValues}
scope="adresse"
minimumInputLength={2}
transformResult={({ properties: { label } }) => [label, label, label]}
transformResults={(_, result) => (result as RawResult).features}
debounceDelay={300}
/>
</QueryClientProvider>
);
}

View file

@ -1,40 +0,0 @@
import React from 'react';
import { QueryClientProvider } from 'react-query';
import ComboSearch, { ComboSearchProps } from './ComboSearch';
import { queryClient } from './shared/queryClient';
type AnnuaireEducationResult = {
fields: {
identifiant_de_l_etablissement: string;
nom_etablissement: string;
nom_commune: string;
};
};
function transformResults(_: unknown, result: unknown) {
const results = result as { records: AnnuaireEducationResult[] };
return results.records as AnnuaireEducationResult[];
}
export default function ComboAnnuaireEducationSearch(
props: ComboSearchProps<AnnuaireEducationResult>
) {
return (
<QueryClientProvider client={queryClient}>
<ComboSearch
{...props}
scope="annuaire-education"
minimumInputLength={3}
transformResults={transformResults}
transformResult={({
fields: {
identifiant_de_l_etablissement: id,
nom_etablissement,
nom_commune
}
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
/>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,347 @@
import type { ListBoxItemProps } from 'react-aria-components';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
Popover,
Input,
Label,
Text,
Button,
TagGroup,
TagList,
Tag
} from 'react-aria-components';
import { useMemo, useRef, createContext, useContext } from 'react';
import type { RefObject } from 'react';
import { findOrCreateContainerElement } from '@coldwired/react';
import * as s from 'superstruct';
import {
useLabelledBy,
useDispatchChangeEvent,
useMultiList,
useSingleList,
useRemoteList,
useOnFormReset,
createLoader,
type ComboBoxProps
} from './react-aria/hooks';
import {
type Item,
SingleComboBoxProps,
MultiComboBoxProps,
RemoteComboBoxProps
} from './react-aria/props';
const getPortal = () => findOrCreateContainerElement('rac-portal');
export function ComboBox({
children,
label,
description,
className,
inputRef,
...props
}: ComboBoxProps & { inputRef?: RefObject<HTMLInputElement> }) {
return (
<AriaComboBox
{...props}
className={`fr-ds-combobox ${className ?? ''}`}
shouldFocusWrap={true}
>
{label ? <Label className="fr-label">{label}</Label> : null}
{description ? (
<Text slot="description" className="fr-hint-text">
{description}
</Text>
) : null}
<div className="fr-ds-combobox__input" style={{ position: 'relative' }}>
<Input className="fr-select fr-autocomplete" ref={inputRef} />
<Button
style={{
width: '40px',
height: '100%',
position: 'absolute',
opacity: 0,
right: 0,
top: 0
}}
>
{' '}
</Button>
</div>
<Popover
className="fr-ds-combobox__menu fr-menu"
UNSTABLE_portalContainer={getPortal()!}
>
<ListBox className="fr-menu__list">{children}</ListBox>
</Popover>
</AriaComboBox>
);
}
export function ComboBoxItem(props: ListBoxItemProps<Item>) {
return <ListBoxItem {...props} className="fr-menu__item" />;
}
export function SingleComboBox({
children,
...maybeProps
}: SingleComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
emptyFilterKey,
name,
formValue,
form,
data,
...props
} = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const { selectedItem, onReset, ...comboBoxProps } = useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange: dispatch
});
return (
<>
<ComboBox aria-labelledby={labelledby} {...comboBoxProps} {...props}>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={formValue == 'text' ? 'label' : 'value'}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function MultiComboBox(maybeProps: MultiComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKeys: defaultSelectedKeys,
name,
form,
formValue,
allowsCustomValue,
valueSeparator,
...props
} = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const inputRef = useRef<HTMLInputElement>(null);
const {
selectedItems,
hiddenInputValues,
onRemove,
onReset,
...comboBoxProps
} = useMultiList({
defaultItems,
defaultSelectedKeys,
onChange: dispatch,
formValue,
allowsCustomValue,
valueSeparator,
focusInput: () => {
inputRef.current?.focus();
}
});
const formResetRef = useOnFormReset(onReset);
return (
<div className="fr-ds-combobox__multiple">
{selectedItems.length > 0 ? (
<TagGroup onRemove={onRemove} aria-label={props['aria-label']}>
<TagList items={selectedItems} className="fr-tag-list">
{selectedItems.map((item) => (
<Tag
key={item.value}
id={item.value}
textValue={`Retirer ${item.label}`}
className="fr-tag fr-tag--sm"
>
{item.label}
<Button slot="remove" className="fr-tag--dismiss"></Button>
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<ComboBox
aria-labelledby={labelledby}
allowsCustomValue={allowsCustomValue}
inputRef={inputRef}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{name ? (
<span ref={ref}>
{hiddenInputValues.map((value, i) => (
<input
type="hidden"
value={value}
name={name}
form={form}
ref={i == 0 ? formResetRef : undefined}
key={value}
/>
))}
</span>
) : null}
</div>
);
}
export function RemoteComboBox({
loader,
onChange,
children,
...maybeProps
}: RemoteComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
allowsCustomValue,
minimumInputLength,
limit,
formValue,
name,
form,
data,
...props
} = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const load = useMemo(
() =>
typeof loader == 'string'
? createLoader(loader, { minimumInputLength, limit })
: loader,
[loader, minimumInputLength, limit]
);
const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({
allowsCustomValue,
defaultItems,
defaultSelectedKey,
load,
onChange: (item) => {
onChange?.(item);
dispatch();
}
});
return (
<>
<ComboBox
allowsEmptyCollection={comboBoxProps.inputValue.length > 0}
allowsCustomValue={allowsCustomValue}
aria-labelledby={labelledby}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={
formValue == 'text' || allowsCustomValue ? 'label' : 'value'
}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function ComboBoxValueSlot({
field,
name,
form,
onReset,
data
}: {
field: 'label' | 'value' | 'data';
name: string;
form?: string;
onReset?: () => void;
data?: Record<string, string>;
}) {
const selectedItem = useContext(SelectedItemContext);
const value = getSelectedValue(selectedItem, field);
const dataProps = Object.fromEntries(
Object.entries(data ?? {}).map(([key, value]) => [
`data-${key.replace(/_/g, '-')}`,
value
])
);
const ref = useOnFormReset(onReset);
return (
<input
ref={onReset ? ref : undefined}
type="hidden"
name={name}
value={value}
form={form}
{...dataProps}
/>
);
}
const SelectedItemContext = createContext<Item | null>(null);
const SelectedItemProvider = SelectedItemContext.Provider;
function getSelectedValue(
selectedItem: Item | null,
field: 'label' | 'value' | 'data'
): string {
if (selectedItem == null) {
return '';
} else if (field == 'data') {
if (typeof selectedItem.data == 'string') {
return selectedItem.data;
} else if (!selectedItem.data) {
return '';
}
return JSON.stringify(selectedItem.data);
}
return selectedItem[field];
}

View file

@ -1,374 +0,0 @@
import React, {
useMemo,
useState,
useRef,
useContext,
createContext,
useId,
ReactNode,
ChangeEventHandler,
KeyboardEventHandler
} from 'react';
import {
Combobox,
ComboboxInput,
ComboboxList,
ComboboxOption,
ComboboxPopover
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import { matchSorter } from 'match-sorter';
import { XIcon } from '@heroicons/react/outline';
import isHotkey from 'is-hotkey';
import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField } from './shared/hooks';
const Context = createContext<{
onRemove: (value: string) => void;
} | null>(null);
type Option = [label: string, value: string];
function isOptions(options: string[] | Option[]): options is Option[] {
return Array.isArray(options[0]);
}
const optionLabelByValue = (
values: string[],
options: Option[],
value: string
): string => {
const maybeOption: Option | undefined = values.includes(value)
? [value, value]
: options.find(([, optionValue]) => optionValue == value);
return maybeOption ? maybeOption[0] : '';
};
export type ComboMultipleProps = {
options: string[] | Option[];
id: string;
labelledby: string;
describedby: string;
label: string;
group: string;
name?: string;
selected: string[];
acceptNewValues?: boolean;
};
export default function ComboMultiple({
options,
id,
labelledby,
describedby,
label,
group,
name = 'value',
selected,
acceptNewValues = false
}: ComboMultipleProps) {
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
invariant(group, 'ComboMultiple: `group` is required');
const inputRef = useRef<HTMLInputElement>(null);
const [term, setTerm] = useState('');
const [selections, setSelections] = useState(selected);
const [newValues, setNewValues] = useState<string[]>([]);
const internalId = useId();
const inputId = id ?? internalId;
const removedLabelledby = `${inputId}-remove`;
const selectedLabelledby = `${inputId}-selected`;
const optionsWithLabels = useMemo<Option[]>(
() =>
isOptions(options)
? options
: options.filter((o) => o).map((o) => [o, o]),
[options]
);
const extraOptions = useMemo(
() =>
acceptNewValues &&
term &&
term.length > 2 &&
!optionLabelByValue(newValues, optionsWithLabels, term)
? [[term, term]]
: [],
[acceptNewValues, term, optionsWithLabels, newValues]
);
const extraListOptions = useMemo(
() =>
acceptNewValues && term && term.length > 2 && term.includes(';')
? term.split(';').map((val) => [val.trim(), val.trim()])
: [],
[acceptNewValues, term]
);
const results = useMemo(
() =>
[
...extraOptions,
...(term
? matchSorter(
optionsWithLabels.filter(([label]) => !label.startsWith('--')),
term
)
: optionsWithLabels)
].filter(([, value]) => !selections.includes(value)),
[term, selections, extraOptions, optionsWithLabels]
);
const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setTerm(event.target.value);
};
const saveSelection = (fn: (selections: string[]) => string[]) => {
setSelections((selections) => {
selections = fn(selections);
setHiddenFieldValue(JSON.stringify(selections));
return selections;
});
};
const onSelect = (value: string) => {
const maybeValue = [...extraOptions, ...optionsWithLabels].find(
([, val]) => val == value
);
const maybeValueFromListOptions = extraListOptions.find(
([, val]) => val == value
);
const selectedValue =
term.includes(';') && acceptNewValues
? maybeValueFromListOptions && maybeValueFromListOptions[1]
: maybeValue && maybeValue[1];
if (selectedValue) {
if (
(acceptNewValues &&
extraOptions[0] &&
extraOptions[0][0] == selectedValue) ||
(acceptNewValues && extraListOptions[0])
) {
setNewValues((newValues) => {
const set = new Set(newValues);
set.add(selectedValue);
return [...set];
});
}
saveSelection((selections) => {
const set = new Set(selections);
set.add(selectedValue);
return [...set];
});
}
setTerm('');
awaitFormSubmit.done();
hidePopover();
};
const onRemove = (optionValue: string) => {
if (optionValue) {
saveSelection((selections) =>
selections.filter((value) => value != optionValue)
);
setNewValues((newValues) =>
newValues.filter((value) => value != optionValue)
);
}
inputRef.current?.focus();
};
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (
isHotkey('enter', event) ||
isHotkey(' ', event) ||
isHotkey(',', event) ||
isHotkey(';', event)
) {
if (term.includes(';')) {
for (const val of term.split(';')) {
event.preventDefault();
onSelect(val.trim());
}
} else if (
term &&
[...extraOptions, ...optionsWithLabels]
.map(([label]) => label)
.includes(term)
) {
event.preventDefault();
onSelect(term);
}
}
};
const hidePopover = () => {
document
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
?.setAttribute('hidden', 'true');
};
const showPopover = () => {
document
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
?.removeAttribute('hidden');
};
const onBlur = () => {
const shouldSelect =
term &&
[...extraOptions, ...optionsWithLabels]
.map(([label]) => label)
.includes(term);
awaitFormSubmit(() => {
if (term.includes(';')) {
for (const val of term.split(';')) {
onSelect(val.trim());
}
} else if (shouldSelect) {
onSelect(term);
} else {
hidePopover();
}
});
};
return (
<Combobox openOnFocus={true} onSelect={onSelect}>
<ComboboxTokenLabel onRemove={onRemove}>
<span id={removedLabelledby} className="hidden">
désélectionner
</span>
<ul
id={selectedLabelledby}
aria-live="polite"
aria-atomic={true}
data-reach-combobox-token-list
>
{selections.map((selection) => (
<ComboboxToken
key={selection}
value={selection}
describedby={removedLabelledby}
>
{optionLabelByValue(newValues, optionsWithLabels, selection)}
</ComboboxToken>
))}
</ul>
<ComboboxInput
ref={inputRef}
value={term}
onChange={handleChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
onClick={showPopover}
autocomplete={false}
id={inputId}
aria-label={label}
aria-labelledby={[labelledby, selectedLabelledby]
.filter(Boolean)
.join(' ')}
aria-describedby={describedby}
/>
</ComboboxTokenLabel>
{results && (results.length > 0 || !acceptNewValues) && (
<ComboboxPopover
className="shadow-popup"
data-reach-combobox-popover-id={inputId}
>
<ComboboxList>
{results.length === 0 && (
<li data-reach-combobox-no-results>
Aucun résultat{' '}
<button
onClick={() => {
setTerm('');
inputRef.current?.focus();
}}
className="button"
>
Effacer
</button>
</li>
)}
{results.map(([label, value], index) => {
if (label.startsWith('--')) {
return <ComboboxSeparator key={index} value={label} />;
}
return (
<ComboboxOption key={index} value={value}>
{label}
</ComboboxOption>
);
})}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
);
}
function ComboboxTokenLabel({
onRemove,
children
}: {
onRemove: (value: string) => void;
children: ReactNode;
}) {
return (
<Context.Provider value={{ onRemove }}>
<div data-reach-combobox-token-label>{children}</div>
</Context.Provider>
);
}
function ComboboxSeparator({ value }: { value: string }) {
return (
<li aria-disabled="true" role="option" data-reach-combobox-separator>
{value.slice(2, -2)}
</li>
);
}
function ComboboxToken({
value,
describedby,
children,
...props
}: {
value: string;
describedby: string;
children: ReactNode;
}) {
const context = useContext(Context);
invariant(context, 'invalid context');
const { onRemove } = context;
return (
<li data-reach-combobox-token {...props}>
<button
type="button"
onClick={() => {
onRemove(value);
}}
onKeyDown={(event) => {
if (event.key === 'Backspace') {
onRemove(value);
}
}}
aria-describedby={describedby}
>
<XIcon className="icon-size mr-1" aria-hidden="true" />
{children}
</button>
</li>
);
}

View file

@ -1,222 +0,0 @@
import React, {
useState,
useEffect,
useRef,
useId,
ChangeEventHandler
} from 'react';
import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query';
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
type TransformResults<Result> = (term: string, results: unknown) => Result[];
type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result = unknown> = {
onChange?: (value: string | null, result?: Result) => void;
value?: string;
scope: string;
scopeExtra?: string;
minimumInputLength: number;
transformResults?: TransformResults<Result>;
transformResult: TransformResult<Result>;
allowInputValues?: boolean;
id?: string;
describedby?: string;
className?: string;
placeholder?: string;
debounceDelay?: number;
screenReaderInstructions: string;
announceTemplateId: string;
};
type QueryKey = readonly [
scope: string,
term: string,
extra: string | undefined
];
function ComboSearch<Result>({
onChange,
value: controlledValue,
scope,
scopeExtra,
minimumInputLength,
transformResult,
allowInputValues = false,
transformResults = (_, results) => results as Result[],
id,
describedby,
screenReaderInstructions,
announceTemplateId,
debounceDelay = 0,
...props
}: ComboSearchProps<Result>) {
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
const group = !onChange && id ? groupId(id) : undefined;
const [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
const [, setExternalId] = useHiddenField(group, 'external_id');
const initialValue = externalValue ? externalValue : controlledValue;
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, debounceDelay);
const [value, setValue] = useState(initialValue);
const resultsMap = useRef<
Record<string, { key: string; value: string; result: Result }>
>({});
const getLabel = (result: Result) => {
const [, value, label] = transformResult(result);
return label ?? value;
};
const setExternalValueAndId = (label: string) => {
const { key, value, result } = resultsMap.current[label];
if (onChange) {
onChange(value, result);
} else {
setExternalId(key);
setExternalValue(value);
}
};
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setValue(value);
if (!value) {
if (onChange) {
onChange(null);
} else {
setExternalId('');
setExternalValue('');
}
} else if (value.length >= minimumInputLength) {
setSearchTerm(value.trim());
if (allowInputValues) {
setExternalId('');
setExternalValue(value);
}
}
};
const handleOnSelect = (value: string) => {
setExternalValueAndId(value);
setValue(value);
setSearchTerm('');
awaitFormSubmit.done();
};
const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
[scope, debouncedSearchTerm, scopeExtra],
{
enabled: !!debouncedSearchTerm,
refetchOnMount: false
}
);
const results =
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
const onBlur = () => {
if (!allowInputValues && isSuccess && results[0]) {
const label = getLabel(results[0]);
awaitFormSubmit(() => {
handleOnSelect(label);
});
}
};
const [announceLive, setAnnounceLive] = useState('');
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
const announceTemplate = document.querySelector<HTMLTemplateElement>(
`#${announceTemplateId}`
);
invariant(announceTemplate, `Missing #${announceTemplateId}`);
const announceFragment = useRef(
announceTemplate.content.cloneNode(true) as DocumentFragment
).current;
useEffect(() => {
if (isSuccess) {
const slot = announceFragment.querySelector<HTMLSlotElement>(
'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]'
);
if (!slot) {
return;
}
const countSlot =
slot.querySelector<HTMLSlotElement>('slot[name="count"]');
if (countSlot) {
countSlot.replaceWith(String(results.length));
}
setAnnounceLive(slot.textContent ?? '');
}
announceTimeout.current = setTimeout(() => {
setAnnounceLive('');
}, 3000);
return () => clearTimeout(announceTimeout.current);
}, [announceFragment, results.length, isSuccess]);
const initInstrId = useId();
const resultsId = useId();
return (
<Combobox onSelect={handleOnSelect}>
<ComboboxInput
{...props}
onChange={handleOnChange}
onBlur={onBlur}
value={value ?? ''}
autocomplete={false}
id={id}
aria-describedby={describedby ?? initInstrId}
aria-owns={resultsId}
/>
{isSuccess && (
<ComboboxPopover id={resultsId} className="shadow-popup">
{results.length > 0 ? (
<ComboboxList>
{results.map((result, index) => {
const label = getLabel(result);
const [key, value] = transformResult(result);
resultsMap.current[label] = { key, value, result };
return <ComboboxOption key={`${key}-${index}`} value={label} />;
})}
</ComboboxList>
) : (
<span style={{ display: 'block', margin: 8 }}>
Aucun résultat trouvé
</span>
)}
</ComboboxPopover>
)}
{!describedby && (
<span id={initInstrId} className="hidden">
{screenReaderInstructions}
</span>
)}
<div aria-live="assertive" className="sr-only">
{announceLive}
</div>
</Combobox>
);
}
export default ComboSearch;

View file

@ -0,0 +1,12 @@
import { I18nProvider } from 'react-aria-components';
import { StrictMode, type ReactNode } from 'react';
export function Layout({ children }: { children: ReactNode }) {
const locale = document.documentElement.lang;
console.debug(`locale: ${locale}`);
return (
<I18nProvider locale={locale}>
<StrictMode>{children}</StrictMode>
</I18nProvider>
);
}

View file

@ -1,33 +1,33 @@
import React from 'react';
import { fire } from '@utils';
import type { FeatureCollection } from 'geojson';
import ComboAdresseSearch from '../../ComboAdresseSearch';
import { ComboSearchProps } from '~/components/ComboSearch';
import { RemoteComboBox } from '../../ComboBox';
export function AddressInput(
comboProps: Pick<
ComboSearchProps,
'screenReaderInstructions' | 'announceTemplateId'
> & { featureCollection: FeatureCollection; champId: string }
) {
export function AddressInput({
source,
featureCollection,
champId
}: {
source: string;
featureCollection: FeatureCollection;
champId: string;
}) {
return (
<div
style={{
marginBottom: '10px'
}}
>
<ComboAdresseSearch
className="fr-input fr-mt-1w"
allowInputValues={false}
id={comboProps.champId}
onChange={(_, feature) => {
fire(document, 'map:zoom', {
featureCollection: comboProps.featureCollection,
feature
});
<div style={{ marginBottom: '10px' }}>
<RemoteComboBox
minimumInputLength={2}
id={champId}
loader={source}
label="Rechercher une Adresse"
description="Saisissez au moins 2 caractères"
onChange={(item) => {
if (item && item.data) {
fire(document, 'map:zoom', {
featureCollection,
feature: item.data
});
}
}}
{...comboProps}
/>
</div>
);

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
import { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
import type { FeatureCollection } from 'geojson';
import invariant from 'tiny-invariant';

View file

@ -1,4 +1,4 @@
import React, { useState, useId } from 'react';
import { useState, useId } from 'react';
import { fire } from '@utils';
import type { Feature, FeatureCollection } from 'geojson';
import CoordinateInput from 'react-coordinate-input';

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { CursorClickIcon } from '@heroicons/react/outline';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
@ -12,21 +12,18 @@ import { AddressInput } from './components/AddressInput';
import { PointInput } from './components/PointInput';
import { ImportFileInput } from './components/ImportFileInput';
import { FlashMessage } from '../shared/FlashMessage';
import { ComboSearchProps } from '../ComboSearch';
export default function MapEditor({
featureCollection: initialFeatureCollection,
url,
adresseSource,
options,
autocompleteAnnounceTemplateId,
autocompleteScreenReaderInstructions,
champId
}: {
featureCollection: FeatureCollection;
url: string;
adresseSource: string;
options: { layers: string[] };
autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
champId: string;
}) {
const [cadastreEnabled, setCadastreEnabled] = useState(false);
@ -41,15 +38,10 @@ export default function MapEditor({
{error && <FlashMessage message={error} level="alert" fixed={true} />}
<ImportFileInput featureCollection={featureCollection} {...actions} />
<label className="fr-label" htmlFor={champId}>
Rechercher une Adresse
<span className="fr-hint-text">Saisissez au moins 2 caractères</span>
</label>
<AddressInput
source={adresseSource}
champId={champId}
featureCollection={featureCollection}
screenReaderInstructions={autocompleteScreenReaderInstructions}
announceTemplateId={autocompleteAnnounceTemplateId}
/>
<MapLibre layers={options.layers}>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
import type { Feature, FeatureCollection, Point } from 'geojson';

View file

@ -1,4 +1,3 @@
import React from 'react';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { FeatureCollection } from 'geojson';

View file

@ -0,0 +1,486 @@
import type {
ComboBoxProps as AriaComboBoxProps,
TagGroupProps
} from 'react-aria-components';
import { useAsyncList, type AsyncListOptions } from 'react-stately';
import { useMemo, useRef, useState, useEffect } from 'react';
import type { Key } from 'react';
import { matchSorter } from 'match-sorter';
import { useDebounceCallback } from 'usehooks-ts';
import { useEvent } from 'react-use-event-hook';
import isEqual from 'react-fast-compare';
import * as s from 'superstruct';
import { Item } from './props';
export type Loader = AsyncListOptions<Item, string>['load'];
export interface ComboBoxProps
extends Omit<AriaComboBoxProps<Item>, 'children'> {
children: React.ReactNode | ((item: Item) => React.ReactNode);
label?: string;
description?: string;
}
const inputMap = new WeakMap<HTMLInputElement, string>();
export function useDispatchChangeEvent() {
const ref = useRef<HTMLSpanElement>(null);
return {
ref,
dispatch: () => {
requestAnimationFrame(() => {
const input = ref.current?.querySelector('input');
if (input) {
const value = input.value;
const prevValue = inputMap.get(input) || '';
if (value != prevValue) {
inputMap.set(input, value);
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
}
};
}
export function useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange
}: {
defaultItems?: Item[];
defaultSelectedKey?: string | null;
emptyFilterKey?: string | null;
onChange?: (item: Item | null) => void;
}) {
const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
const items = useMemo(
() => (defaultItems ? distinctBy(defaultItems, 'value') : []),
[defaultItems]
);
const selectedItem = useMemo(
() => items.find((item) => item.value == selectedKey) ?? null,
[items, selectedKey]
);
const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? '');
// show fallback item when input value is not matching any items
const fallbackItem = useMemo(
() => items.find((item) => item.value == emptyFilterKey),
[items, emptyFilterKey]
);
const filteredItems = useMemo(() => {
if (inputValue == '') {
return items;
}
const filteredItems = matchSorter(items, inputValue, { keys: ['label'] });
if (filteredItems.length == 0 && fallbackItem) {
return [fallbackItem];
} else {
return filteredItems;
}
}, [items, inputValue, fallbackItem]);
const initialSelectedKeyRef = useRef(defaultSelectedKey);
const setSelection = useEvent((key?: string | null) => {
const inputValue = key
? items.find((item) => item.value == key)?.label
: '';
setSelectedKey(key);
setInputValue(inputValue ?? '');
});
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
setSelection(key ? String(key) : null);
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: items.find((item) => item.value == key) ?? null;
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
setInputValue(value);
if (value == '') {
onSelectionChange(null);
}
}
);
const onReset = useEvent(() => {
setSelectedKey(null);
setInputValue('');
});
// reset default selected key when props change
useEffect(() => {
if (initialSelectedKeyRef.current != defaultSelectedKey) {
initialSelectedKeyRef.current = defaultSelectedKey;
setSelection(defaultSelectedKey);
}
}, [defaultSelectedKey, setSelection]);
return {
selectedItem,
selectedKey,
onSelectionChange,
inputValue,
onInputChange,
items: filteredItems,
onReset
};
}
export function useMultiList({
defaultItems,
defaultSelectedKeys,
allowsCustomValue,
valueSeparator,
onChange,
focusInput,
formValue
}: {
defaultItems?: Item[];
defaultSelectedKeys?: string[];
allowsCustomValue?: boolean;
valueSeparator?: string;
onChange?: () => void;
focusInput?: () => void;
formValue?: 'text' | 'key';
}) {
const valueSeparatorRegExp = useMemo(
() => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/),
[valueSeparator]
);
const [selectedKeys, setSelectedKeys] = useState(
() => new Set(defaultSelectedKeys ?? [])
);
const [inputValue, setInputValue] = useState('');
const items = useMemo(
() => (defaultItems ? distinctBy(defaultItems, 'value') : []),
[defaultItems]
);
const itemsIndex = useMemo(() => {
const index = new Map<string, Item>();
for (const item of items) {
index.set(item.value, item);
}
return index;
}, [items]);
const filteredItems = useMemo(
() =>
inputValue.length == 0
? items
: matchSorter(items, inputValue, { keys: ['label'] }),
[items, inputValue]
);
const selectedItems = useMemo(() => {
const selectedItems: Item[] = [];
for (const key of selectedKeys) {
const item = itemsIndex.get(key);
if (item) {
selectedItems.push(item);
} else if (allowsCustomValue) {
selectedItems.push({ label: key, value: key });
}
}
return selectedItems;
}, [itemsIndex, selectedKeys, allowsCustomValue]);
const hiddenInputValues = useMemo(() => {
const values = selectedItems.map((item) =>
formValue == 'text' || allowsCustomValue ? item.label : item.value
);
if (!allowsCustomValue || inputValue == '') {
return values;
}
return [
...new Set([
...values,
...inputValue.split(valueSeparatorRegExp).filter(Boolean)
])
];
}, [
selectedItems,
inputValue,
valueSeparatorRegExp,
allowsCustomValue,
formValue
]);
const isSelectionSetRef = useRef(false);
const initialSelectedKeysRef = useRef(defaultSelectedKeys);
// reset default selected keys when props change
useEffect(() => {
if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) {
initialSelectedKeysRef.current = defaultSelectedKeys;
setSelectedKeys(new Set(defaultSelectedKeys));
}
}, [defaultSelectedKeys]);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
if (key) {
isSelectionSetRef.current = true;
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
selectedKeys.add(String(key));
return selectedKeys;
});
setInputValue('');
onChange?.();
}
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
const isSelectionSet = isSelectionSetRef.current;
isSelectionSetRef.current = false;
if (isSelectionSet) {
setInputValue('');
return;
}
if (allowsCustomValue) {
const values = value.split(valueSeparatorRegExp);
// if input contains a separator, add all values
if (values.length > 1) {
const addedKeys = values.filter(Boolean);
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of addedKeys) {
selectedKeys.add(key);
}
return selectedKeys;
});
setInputValue('');
} else {
setInputValue(value);
}
onChange?.();
} else {
setInputValue(value);
}
}
);
const onRemove = useEvent<NonNullable<TagGroupProps['onRemove']>>(
(removedKeys) => {
setSelectedKeys((keys) => {
const selectedKeys = new Set(keys.values());
for (const key of removedKeys) {
selectedKeys.delete(String(key));
}
// focus input when all items are removed
if (selectedKeys.size == 0) {
focusInput?.();
}
return selectedKeys;
});
onChange?.();
}
);
const onReset = useEvent(() => {
setSelectedKeys(new Set());
setInputValue('');
});
return {
onRemove,
onSelectionChange,
onInputChange,
selectedItems,
items: filteredItems,
hiddenInputValues,
inputValue,
onReset
};
}
export function useRemoteList({
load,
defaultItems,
defaultSelectedKey,
onChange,
debounce,
allowsCustomValue
}: {
load: Loader;
defaultItems?: Item[];
defaultSelectedKey?: Key | null;
onChange?: (item: Item | null) => void;
debounce?: number;
allowsCustomValue?: boolean;
}) {
const [defaultSelectedItem, setSelectedItem] = useState<Item | null>(() => {
if (defaultItems) {
return (
defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
);
}
return null;
});
const [inputValue, setInputValue] = useState(
defaultSelectedItem?.label ?? ''
);
const selectedItem = useMemo<Item | null>(() => {
if (defaultSelectedItem) {
return defaultSelectedItem;
}
if (allowsCustomValue && inputValue != '') {
return { label: inputValue, value: inputValue };
}
return null;
}, [defaultSelectedItem, inputValue, allowsCustomValue]);
const list = useAsyncList<Item>({ getKey, load });
const setFilterText = useEvent((filterText: string) => {
list.setFilterText(filterText);
});
const debouncedSetFilterText = useDebounceCallback(
setFilterText,
debounce ?? 300
);
const onSelectionChange = useEvent<
NonNullable<ComboBoxProps['onSelectionChange']>
>((key) => {
const item =
typeof key != 'string'
? null
: selectedItem?.value == key
? selectedItem
: list.getItem(key);
setSelectedItem(item);
if (item) {
setInputValue(item.label);
} else if (!allowsCustomValue) {
setInputValue('');
}
onChange?.(item);
});
const onInputChange = useEvent<NonNullable<ComboBoxProps['onInputChange']>>(
(value) => {
debouncedSetFilterText(value);
setInputValue(value);
if (value == '') {
onSelectionChange(null);
} else if (allowsCustomValue && selectedItem?.label != value) {
onChange?.(selectedItem);
}
}
);
const onReset = useEvent(() => {
setSelectedItem(null);
setInputValue('');
});
// add to items list current selected item if it's not in the list
const items =
selectedItem && !list.getItem(selectedItem.value)
? [selectedItem, ...list.items]
: list.items;
return {
selectedItem,
selectedKey: selectedItem?.value ?? null,
onSelectionChange,
inputValue,
onInputChange,
items,
onReset
};
}
function getKey(item: Item) {
return item.value;
}
export const createLoader: (
source: string,
options?: {
minimumInputLength?: number;
limit?: number;
param?: string;
}
) => Loader =
(source, options) =>
async ({ signal, filterText }) => {
const url = new URL(source, location.href);
const minimumInputLength = options?.minimumInputLength ?? 2;
const param = options?.param ?? 'q';
const limit = options?.limit ?? 10;
if (!filterText || filterText.length < minimumInputLength) {
return { items: [] };
}
url.searchParams.set(param, filterText);
try {
const response = await fetch(url.toString(), {
headers: { accept: 'application/json' },
signal
});
if (response.ok) {
const json = await response.json();
const [err, result] = s.validate(json, s.array(Item), { coerce: true });
if (!err) {
const items = matchSorter(result, filterText, {
keys: ['label']
});
return {
items: limit ? items.slice(0, limit) : items
};
}
}
return { items: [] };
} catch {
return { items: [] };
}
};
export function useLabelledBy(id?: string, ariaLabelledby?: string) {
return useMemo(
() => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)),
[id, ariaLabelledby]
);
}
function findLabelledbyId(id?: string) {
if (!id) {
return;
}
const label = document.querySelector(`[for="${id}"]`);
if (!label?.id) {
return;
}
return label.id;
}
export function useOnFormReset(onReset?: () => void) {
const ref = useRef<HTMLInputElement>(null);
const onResetListener = useEvent<EventListener>((event) => {
if (event.target == ref.current?.form) {
onReset?.();
}
});
useEffect(() => {
if (onReset) {
addEventListener('reset', onResetListener);
return () => {
removeEventListener('reset', onResetListener);
};
}
}, [onReset, onResetListener]);
return ref;
}
function distinctBy<T>(array: T[], key: keyof T): T[] {
const keys = array.map((item) => item[key]);
return array.filter((item, index) => keys.indexOf(item[key]) == index);
}

View file

@ -0,0 +1,82 @@
import type { ReactNode } from 'react';
import * as s from 'superstruct';
import type { Loader } from './hooks';
export const Item = s.object({
label: s.string(),
value: s.string(),
data: s.any()
});
export type Item = s.Infer<typeof Item>;
const ArrayOfTuples = s.coerce(
s.array(Item),
s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])),
(items) =>
items.map<Item>(([label, value]) => ({
label,
value: String(value)
}))
);
const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) =>
items.map<Item>((label) => ({ label, value: label }))
);
const ComboBoxPropsSchema = s.partial(
s.object({
id: s.string(),
className: s.string(),
name: s.string(),
label: s.string(),
description: s.string(),
isRequired: s.boolean(),
'aria-label': s.string(),
'aria-labelledby': s.string(),
'aria-describedby': s.string(),
items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]),
formValue: s.enums(['text', 'key']),
form: s.string(),
data: s.record(s.string(), s.string())
})
);
export const SingleComboBoxProps = s.assign(
ComboBoxPropsSchema,
s.partial(
s.object({
selectedKey: s.nullable(s.string()),
emptyFilterKey: s.nullable(s.string())
})
)
);
export const MultiComboBoxProps = s.assign(
ComboBoxPropsSchema,
s.partial(
s.object({
selectedKeys: s.array(s.string()),
allowsCustomValue: s.boolean(),
valueSeparator: s.string()
})
)
);
export const RemoteComboBoxProps = s.assign(
ComboBoxPropsSchema,
s.partial(
s.object({
selectedKey: s.nullable(s.string()),
minimumInputLength: s.number(),
limit: s.number(),
allowsCustomValue: s.boolean()
})
)
);
export type SingleComboBoxProps = s.Infer<typeof SingleComboBoxProps> & {
children?: ReactNode;
};
export type MultiComboBoxProps = s.Infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = s.Infer<typeof RemoteComboBoxProps> & {
children?: ReactNode;
loader: Loader | string;
onChange?: (item: Item | null) => void;
};

View file

@ -1,4 +1,3 @@
import React from 'react';
import { createPortal } from 'react-dom';
import invariant from 'tiny-invariant';

View file

@ -1,90 +0,0 @@
import { useRef, useCallback, useMemo, useState } from 'react';
import { fire } from '@utils';
export function useDeferredSubmit(input?: HTMLInputElement): {
(callback: () => void): void;
done: () => void;
} {
const calledRef = useRef(false);
const awaitFormSubmit = useCallback(
(callback: () => void) => {
const form = input?.form;
if (!form) {
return;
}
const interceptFormSubmit = (event: Event) => {
event.preventDefault();
runCallback();
if (
!Array.from(form.elements).some(
(e) =>
e.hasAttribute('data-direct-upload-url') &&
'value' in e &&
e.value != ''
)
) {
form.submit();
}
// else: form will be submitted by diret upload once file have been uploaded
};
calledRef.current = false;
form.addEventListener('submit', interceptFormSubmit);
const runCallback = () => {
form.removeEventListener('submit', interceptFormSubmit);
clearTimeout(timer);
if (!calledRef.current) {
callback();
}
};
const timer = setTimeout(runCallback, 400);
},
[input]
);
const done = () => {
calledRef.current = true;
};
return Object.assign(awaitFormSubmit, { done });
}
export function groupId(id: string) {
return `#${id.replace(/-input$/, '')}`;
}
export function useHiddenField(
group?: string,
name = 'value'
): [
value: string | undefined,
setValue: (value: string) => void,
input: HTMLInputElement | undefined
] {
const hiddenField = useMemo(
() => selectInputInGroup(group, name),
[group, name]
);
const [value, setValue] = useState(() => hiddenField?.value);
return [
value,
(value) => {
if (hiddenField) {
hiddenField.setAttribute('value', value);
setValue(value);
fire(hiddenField, 'change');
}
},
hiddenField ?? undefined
];
}
function selectInputInGroup(
group: string | undefined,
name: string
): HTMLInputElement | undefined | null {
if (group) {
return document.querySelector<HTMLInputElement>(
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
);
}
}

View file

@ -1,4 +1,4 @@
import React, {
import {
useState,
useContext,
useRef,

View file

@ -1,4 +1,4 @@
import React, { useState, useId } from 'react';
import { useState, useId } from 'react';
import { Popover, RadioGroup } from '@headlessui/react';
import { usePopper } from 'react-popper';
import { MapIcon } from '@heroicons/react/outline';

View file

@ -1,62 +0,0 @@
import { QueryClient, QueryFunction } from 'react-query';
import { httpRequest, getConfig } from '@utils';
const API_EDUCATION_QUERY_LIMIT = 5;
const API_ADRESSE_QUERY_LIMIT = 5;
const {
autocomplete: { api_adresse_url, api_education_url }
} = getConfig();
type QueryKey = readonly [
scope: string,
term: string,
extra: string | undefined
];
function buildURL(scope: string, term: string) {
term = term.replace(/\(|\)/g, '');
const params = new URLSearchParams();
let path = '';
if (scope == 'adresse') {
path = `${api_adresse_url}/search`;
params.set('q', term);
params.set('limit', `${API_ADRESSE_QUERY_LIMIT}`);
} else if (scope == 'annuaire-education') {
path = `${api_education_url}/search`;
params.set('q', term);
params.set('rows', `${API_EDUCATION_QUERY_LIMIT}`);
params.set('dataset', 'fr-en-annuaire-education');
}
return `${path}?${params}`;
}
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
queryKey: [scope, term],
signal
}) => {
// BAN will error with queries less then 3 chars long
if (scope == 'adresse' && term.length < 3) {
return {
type: 'FeatureCollection',
version: 'draft',
features: [],
query: term
};
}
const url = buildURL(scope, term);
return httpRequest(url, { csrf: false, signal }).json();
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// we don't really care about global queryFn type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryFn: defaultQueryFn as any
}
}
});

View file

@ -1,99 +0,0 @@
import invariant from 'tiny-invariant';
import { isInputElement, isElement } from '@coldwired/utils';
import { Hint } from '../shared/combobox';
import { ComboboxUI } from '../shared/combobox-ui';
import { ApplicationController } from './application_controller';
export class ComboboxController extends ApplicationController {
#combobox?: ComboboxUI;
connect() {
const { input, selectedValueInput, valueSlots, list, item, hint } =
this.getElements();
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
string,
string
>;
this.#combobox = new ComboboxUI({
input,
selectedValueInput,
valueSlots,
list,
item,
hint,
allowsCustomValue: this.element.hasAttribute('data-allows-custom-value'),
limit: this.element.hasAttribute('data-limit')
? Number(this.element.getAttribute('data-limit'))
: undefined,
getHintText: (hint) => getHintText(hints, hint)
});
this.#combobox.init();
}
disconnect() {
this.#combobox?.destroy();
}
private getElements() {
const input =
this.element.querySelector<HTMLInputElement>('input[type="text"]');
const selectedValueInput = this.element.querySelector<HTMLInputElement>(
'input[type="hidden"]'
);
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
'input[type="hidden"][data-value-slot]'
);
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
const item = this.element.querySelector<HTMLTemplateElement>('template');
const hint =
this.element.querySelector<HTMLElement>('[aria-live]') ?? undefined;
invariant(
isInputElement(input),
'ComboboxController requires a input element'
);
invariant(
isInputElement(selectedValueInput),
'ComboboxController requires a hidden input element'
);
invariant(
isElement(list),
'ComboboxController requires a [role=listbox] element'
);
invariant(
isElement(item),
'ComboboxController requires a template element'
);
return { input, selectedValueInput, valueSlots, list, item, hint };
}
}
function getHintText(hints: Record<string, string>, hint: Hint): string {
const slot = hints[getSlotName(hint)];
switch (hint.type) {
case 'empty':
return slot;
case 'selected':
return slot.replace('{label}', hint.label ?? '');
default:
return slot
.replace('{count}', String(hint.count))
.replace('{label}', hint.label ?? '');
}
}
function getSlotName(hint: Hint): string {
switch (hint.type) {
case 'empty':
return 'empty';
case 'selected':
return 'selected';
default:
if (hint.count == 1) {
return hint.label ? 'oneWithLabel' : 'one';
}
return hint.label ? 'manyWithLabel' : 'many';
}
}

View file

@ -1,6 +1,6 @@
import { Editor, type JSONContent } from '@tiptap/core';
import { Editor } from '@tiptap/core';
import { isButtonElement, isHTMLElement } from '@coldwired/utils';
import { z } from 'zod';
import * as s from 'superstruct';
import { ApplicationController } from '../application_controller';
import { getAction } from '../../shared/tiptap/actions';
@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController {
insertTag(event: MouseEvent) {
if (this.#editor && isHTMLElement(event.target)) {
const tag = tagSchema.parse(event.target.dataset);
const tag = s.create(event.target.dataset, tagSchema);
const editor = this.#editor
.chain()
.focus()
@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController {
private get content() {
const value = this.inputTarget.value;
if (value) {
return jsonContentSchema.parse(JSON.parse(value));
return s.create(JSON.parse(value), jsonContentSchema);
}
}
private get tags(): TagSchema[] {
return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset));
return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema));
}
private get menuButtons() {
@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController {
}
}
const jsonContentSchema: z.ZodType<JSONContent> = z.object({
type: z.string().optional(),
text: z.string().optional(),
attrs: z.record(z.any()).optional(),
marks: z
.object({ type: z.string(), attrs: z.record(z.any()).optional() })
.array()
.optional(),
content: z.lazy(() => z.array(jsonContentSchema).optional())
const Attrs = s.record(s.string(), s.any());
const Marks = s.array(
s.type({
type: s.string(),
attrs: s.optional(Attrs)
})
);
type JSONContent = {
type?: string;
text?: string;
attrs?: s.Infer<typeof Attrs>;
marks?: s.Infer<typeof Marks>;
content?: JSONContent[];
};
const jsonContentSchema: s.Describe<JSONContent> = s.type({
type: s.optional(s.string()),
text: s.optional(s.string()),
attrs: s.optional(Attrs),
marks: s.optional(Marks),
content: s.lazy(() => s.optional(s.array(jsonContentSchema)))
});

View file

@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
target.isConnected &&
!this.element.contains(target) &&
!target.closest('reach-portal') &&
!target.closest('#rac-portal') &&
this.isOpen
);
}

View file

@ -1,72 +0,0 @@
import { Controller } from '@hotwired/stimulus';
import React, { lazy, Suspense, FunctionComponent } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import invariant from 'tiny-invariant';
type Props = Record<string, unknown>;
type Loader = () => Promise<{ default: FunctionComponent<Props> }>;
const componentsRegistry = new Map<string, FunctionComponent<Props>>();
const components = import.meta.glob('../components/*.tsx');
for (const [path, loader] of Object.entries(components)) {
const [filename] = path.split('/').reverse();
const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
console.debug(
`Registered lazy default export for "${componentClassName}" component`
);
componentsRegistry.set(
componentClassName,
LoadableComponent(loader as Loader)
);
}
// Initialize React components when their markup appears into the DOM.
//
// Example:
// <div data-controller="react" data-react-component-value="ComboMultiple" data-react-props-value="{}"></div>
//
export class ReactController extends Controller {
static values = {
component: String,
props: Object
};
declare readonly componentValue: string;
declare readonly propsValue: Props;
connect(): void {
this.mountComponent(this.element as HTMLElement);
}
disconnect(): void {
unmountComponentAtNode(this.element as HTMLElement);
}
private mountComponent(node: HTMLElement): void {
const componentName = this.componentValue;
const props = this.propsValue;
const Component = this.getComponent(componentName);
invariant(
Component,
`Cannot find a React component with class "${componentName}"`
);
render(<Component {...props} />, node);
}
private getComponent(componentName: string): FunctionComponent<Props> | null {
return componentsRegistry.get(componentName) ?? null;
}
}
const Spinner = () => <div className="spinner left" />;
function LoadableComponent(loader: Loader): FunctionComponent<Props> {
const LazyComponent = lazy(loader);
const Component: FunctionComponent<Props> = (props: Props) => (
<Suspense fallback={<Spinner />}>
<LazyComponent {...props} />
</Suspense>
);
return Component;
}

View file

@ -1,7 +1,9 @@
import { Actions } from '@coldwired/actions';
import { parseTurboStream } from '@coldwired/turbo-stream';
import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
import invariant from 'tiny-invariant';
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
import type { ComponentType } from 'react';
import { ApplicationController } from './application_controller';
@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
#submitting = false;
#actions?: Actions;
#root?: Root;
// `actions` instrface exposes all available actions as methods and also `applyActions` method
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
}
connect() {
this.#root = createRoot({
layoutComponentName: 'Layout/Layout',
loader,
schema: {
fragmentTagName: 'react-fragment',
componentTagName: 'react-component',
slotTagName: 'react-slot',
loadingClassName: 'loading'
}
});
const plugin = createReactPlugin(this.#root);
this.#actions = new Actions({
element: document.body,
schema: {
@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
focusDirectionAttribute: 'data-turbo-focus-direction',
hiddenClassName: 'hidden'
},
plugins: [plugin],
debug: false
});
@ -47,6 +62,10 @@ export class TurboController extends ApplicationController {
// They allow us to preserve certain HTML changes across mutations.
this.#actions.observe();
this.#actions.ready().then(() => {
document.body.classList.add('dom-ready');
});
// setup spinner events
this.onGlobal('turbo:submit-start', () => this.startSpinner());
this.onGlobal('turbo:submit-end', () => this.stopSpinner());
@ -73,6 +92,11 @@ export class TurboController extends ApplicationController {
});
}
disconnect(): void {
this.#actions?.disconnect();
this.#root?.destroy();
}
private startSpinner() {
this.#submitting = true;
this.actions.show({ targets: this.spinnerTargets });
@ -89,3 +113,24 @@ export class TurboController extends ApplicationController {
}
}
}
type Loader = (exportName: string) => Promise<ComponentType<unknown>>;
const componentsRegistry: Record<string, Loader> = {};
const components = import.meta.glob('../components/*.tsx');
const loader: Loader = (name) => {
const [moduleName, exportName] = name.split('/');
const loader = componentsRegistry[moduleName];
invariant(loader, `Cannot find a React component with name "${name}"`);
return loader(exportName ?? 'default');
};
for (const [path, loader] of Object.entries(components)) {
const [filename] = path.split('/').reverse();
const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
console.debug(`Registered lazy export for "${componentClassName}" component`);
componentsRegistry[componentClassName] = (exportName) =>
loader().then(
(m) => (m as Record<string, ComponentType<unknown>>)[exportName]
);
}

View file

@ -1,470 +0,0 @@
import invariant from 'tiny-invariant';
import { isElement, dispatch, isInputElement } from '@coldwired/utils';
import { dispatchAction } from '@coldwired/actions';
import { createPopper, Instance as Popper } from '@popperjs/core';
import {
Combobox,
Action,
type State,
type Option,
type Hint,
type Fetcher
} from './combobox';
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
export type ComboboxUIOptions = {
input: HTMLInputElement;
selectedValueInput: HTMLInputElement;
list: HTMLUListElement;
item: HTMLTemplateElement;
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
allowsCustomValue?: boolean;
limit?: number;
hint?: HTMLElement;
getHintText?: (hint: Hint) => string;
};
export class ComboboxUI implements EventListenerObject {
#combobox?: Combobox;
#popper?: Popper;
#interactingWithList = false;
#mouseOverList = false;
#isComposing = false;
#input: HTMLInputElement;
#selectedValueInput: HTMLInputElement;
#valueSlots: HTMLInputElement[];
#list: HTMLUListElement;
#item: HTMLTemplateElement;
#hint?: HTMLElement;
#getHintText = defaultGetHintText;
#allowsCustomValue: boolean;
#limit?: number;
#selectedData: Option['data'] = null;
constructor({
input,
selectedValueInput,
valueSlots,
list,
item,
hint,
getHintText,
allowsCustomValue,
limit
}: ComboboxUIOptions) {
this.#input = input;
this.#selectedValueInput = selectedValueInput;
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
this.#list = list;
this.#item = item;
this.#hint = hint;
this.#getHintText = getHintText ?? defaultGetHintText;
this.#allowsCustomValue = allowsCustomValue ?? false;
this.#limit = limit;
}
init() {
if (this.#list.dataset.url) {
const fetcher = createFetcher(this.#list.dataset.url);
this.#list.removeAttribute('data-url');
const selected: Option | null = this.#input.value
? { label: this.#input.value, value: this.#selectedValueInput.value }
: null;
this.#combobox = new Combobox({
options: fetcher,
selected,
allowsCustomValue: this.#allowsCustomValue,
limit: this.#limit,
render: (state) => this.render(state)
});
} else {
const selectedValue = this.#selectedValueInput.value;
const options = JSON.parse(
this.#list.dataset.options ?? '[]'
) as Option[];
const selected =
options.find(({ value }) => value == selectedValue) ?? null;
this.#list.removeAttribute('data-options');
this.#list.removeAttribute('data-selected');
this.#combobox = new Combobox({
options,
selected,
allowsCustomValue: this.#allowsCustomValue,
limit: this.#limit,
render: (state) => this.render(state)
});
}
this.#combobox.init();
this.#input.addEventListener('blur', this);
this.#input.addEventListener('focus', this);
this.#input.addEventListener('click', this);
this.#input.addEventListener('input', this);
this.#input.addEventListener('keydown', this);
this.#list.addEventListener('mousedown', this);
this.#list.addEventListener('mouseenter', this);
this.#list.addEventListener('mouseleave', this);
document.body.addEventListener('mouseup', this);
}
destroy() {
this.#combobox?.destroy();
this.#popper?.destroy();
this.#input.removeEventListener('blur', this);
this.#input.removeEventListener('focus', this);
this.#input.removeEventListener('click', this);
this.#input.removeEventListener('input', this);
this.#input.removeEventListener('keydown', this);
this.#list.removeEventListener('mousedown', this);
this.#list.removeEventListener('mouseenter', this);
this.#list.removeEventListener('mouseleave', this);
document.body.removeEventListener('mouseup', this);
}
handleEvent(event: Event) {
switch (event.type) {
case 'input':
this.onInputChange(event as InputEvent);
break;
case 'blur':
this.onInputBlur();
break;
case 'focus':
this.onInputFocus();
break;
case 'click':
if (event.target == this.#input) {
this.onInputClick(event as MouseEvent);
} else {
this.onListClick(event as MouseEvent);
}
break;
case 'keydown':
this.onKeydown(event as KeyboardEvent);
break;
case 'mousedown':
this.onListMouseDown();
break;
case 'mouseenter':
this.onListMouseEnter();
break;
case 'mouseleave':
this.onListMouseLeave();
break;
case 'mouseup':
this.onBodyMouseUp(event);
break;
case 'compositionstart':
case 'compositionend':
this.#isComposing = event.type == 'compositionstart';
break;
}
}
private get combobox() {
invariant(this.#combobox, 'ComboboxUI requires a Combobox instance');
return this.#combobox;
}
private render(state: State) {
console.debug('combobox render', state);
switch (state.action) {
case Action.Select:
case Action.Clear:
this.renderSelect(state);
break;
}
this.renderList(state);
this.renderOptionList(state);
this.renderValue(state);
this.renderHintForScreenReader(state.hint);
}
private renderList(state: State): void {
if (state.open) {
if (!this.#list.hidden) return;
this.#list.hidden = false;
this.#list.classList.remove('hidden');
this.#list.addEventListener('click', this);
this.#input.setAttribute('aria-expanded', 'true');
this.#input.addEventListener('compositionstart', this);
this.#input.addEventListener('compositionend', this);
this.#popper = createPopper(this.#input, this.#list, {
placement: 'bottom-start'
});
} else {
if (this.#list.hidden) return;
this.#list.hidden = true;
this.#list.classList.add('hidden');
this.#list.removeEventListener('click', this);
this.#input.setAttribute('aria-expanded', 'false');
this.#input.removeEventListener('compositionstart', this);
this.#input.removeEventListener('compositionend', this);
this.#popper?.destroy();
this.#interactingWithList = false;
}
}
private renderValue(state: State): void {
if (this.#input.value != state.inputValue) {
this.#input.value = state.inputValue;
}
this.dispatchChange(() => {
if (this.#selectedValueInput.value != state.inputValue) {
if (state.allowsCustomValue || !state.inputValue) {
this.#selectedValueInput.value = state.inputValue;
}
}
return state.selection?.data;
});
}
private renderSelect(state: State): void {
this.dispatchChange(() => {
this.#selectedValueInput.value = state.selection?.value ?? '';
this.#input.value = state.selection?.label ?? '';
return state.selection?.data;
});
}
private renderOptionList(state: State): void {
const html = state.options
.map(({ label, value }) => {
const fragment = this.#item.content.cloneNode(true) as DocumentFragment;
const item = fragment.querySelector('li');
if (item) {
item.id = optionId(value);
item.setAttribute('data-turbo-force', 'server');
if (state.focused?.value == value) {
item.setAttribute('aria-selected', 'true');
} else {
item.removeAttribute('aria-selected');
}
item.setAttribute('data-value', value);
item.querySelector('slot[name="label"]')?.replaceWith(label);
return item.outerHTML;
}
return '';
})
.join('');
dispatchAction({ targets: this.#list, action: 'update', fragment: html });
if (state.focused) {
const id = optionId(state.focused.value);
const item = this.#list.querySelector<HTMLElement>(`#${id}`);
this.#input.setAttribute('aria-activedescendant', id);
if (item) {
scrollTo(this.#list, item);
}
} else {
this.#input.removeAttribute('aria-activedescendant');
}
}
private renderHintForScreenReader(hint: Hint | null): void {
if (this.#hint) {
if (hint) {
this.#hint.textContent = this.#getHintText(hint);
} else {
this.#hint.textContent = '';
}
}
}
private dispatchChange(cb: () => Option['data']): void {
const value = this.#selectedValueInput.value;
const data = cb();
if (value != this.#selectedValueInput.value || data != this.#selectedData) {
this.#selectedData = data;
for (const input of this.#valueSlots) {
switch (input.dataset.valueSlot) {
case 'value':
input.value = this.#selectedValueInput.value;
break;
case 'label':
input.value = this.#input.value;
break;
case 'data:string':
input.value = data ? String(data) : '';
break;
case 'data':
input.value = data ? JSON.stringify(data) : '';
break;
}
}
console.debug('combobox change', this.#selectedValueInput.value);
dispatch('change', {
target: this.#selectedValueInput,
detail: data ? { data } : undefined
});
}
}
private onKeydown(event: KeyboardEvent): void {
if (event.shiftKey || event.metaKey || event.altKey) return;
if (!ctrlBindings && event.ctrlKey) return;
if (this.#isComposing) return;
if (this.combobox.keyboard(event.key)) {
event.preventDefault();
event.stopPropagation();
}
}
private onInputClick(event: MouseEvent): void {
const rect = this.#input.getBoundingClientRect();
const clickOnArrow =
event.clientX >= rect.right - 40 &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (clickOnArrow) {
this.combobox.toggle();
}
}
private onListClick(event: MouseEvent): void {
if (isElement(event.target)) {
const element = event.target.closest<HTMLElement>('[role="option"]');
if (element) {
const value = element.getAttribute('data-value')?.trim();
if (value) {
this.combobox.select(value);
}
}
}
}
private onInputFocus(): void {
this.combobox.focus();
}
private onInputBlur(): void {
if (!this.#interactingWithList) {
this.combobox.close();
}
}
private onInputChange(event: InputEvent): void {
if (isInputElement(event.target)) {
this.combobox.input(event.target.value);
}
}
private onListMouseDown(): void {
this.#interactingWithList = true;
}
private onBodyMouseUp(event: Event): void {
if (
this.#interactingWithList &&
!this.#mouseOverList &&
isElement(event.target) &&
event.target != this.#list &&
!this.#list.contains(event.target)
) {
this.combobox.close();
}
}
private onListMouseEnter(): void {
this.#mouseOverList = true;
}
private onListMouseLeave(): void {
this.#mouseOverList = false;
}
}
function scrollTo(container: HTMLElement, target: HTMLElement): void {
if (!inViewport(container, target)) {
container.scrollTop = target.offsetTop;
}
}
function inViewport(container: HTMLElement, element: HTMLElement): boolean {
const scrollTop = container.scrollTop;
const containerBottom = scrollTop + container.clientHeight;
const top = element.offsetTop;
const bottom = top + element.clientHeight;
return top >= scrollTop && bottom <= containerBottom;
}
function optionId(value: string) {
return `option-${value
.toLowerCase()
// Replace spaces and special characters with underscores
.replace(/[^a-z0-9]/g, '_')
// Remove non-alphanumeric characters at start and end
.replace(/^[^a-z]+|[^\w]$/g, '')}`;
}
function defaultGetHintText(hint: Hint): string {
switch (hint.type) {
case 'results':
if (hint.label) {
return `${hint.count} results. ${hint.label} is the top result: press Enter to activate.`;
}
return `${hint.count} results.`;
case 'empty':
return 'No results.';
case 'selected':
return `${hint.label} selected.`;
}
}
function createFetcher(source: string, param = 'q'): Fetcher {
const url = new URL(source, location.href);
const fetcher: Fetcher = (term: string, options) => {
url.searchParams.set(param, term);
return fetch(url.toString(), {
headers: { accept: 'application/json' },
signal: options?.signal
}).then<Option[]>((response) => {
if (response.ok) {
return response.json();
}
return [];
});
};
return async (term: string, options) => {
await wait(500, options?.signal);
return fetcher(term, options);
};
}
function wait(ms: number, signal?: AbortSignal) {
return new Promise((resolve, reject) => {
const abort = () => reject(new DOMException('Aborted', 'AbortError'));
if (signal?.aborted) {
abort();
} else {
signal?.addEventListener('abort', abort);
setTimeout(resolve, ms);
}
});
}

View file

@ -1,295 +0,0 @@
import { suite, test, beforeEach, expect } from 'vitest';
import { matchSorter } from 'match-sorter';
import { Combobox, Option, State } from './combobox';
suite('Combobox', () => {
const options: Option[] =
'Fraises,Myrtilles,Framboises,Mûres,Canneberges,Groseilles,Baies de sureau,Mûres blanches,Baies de genièvre,Baies daçaï'
.split(',')
.map((label) => ({ label, value: label }));
let combobox: Combobox;
let currentState: State;
suite('single select without custom value', () => {
suite('with default selection', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: options.at(0) ?? null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('open select box and select option with click', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.loading).toBe(null);
expect(currentState.selection?.label).toBe('Fraises');
combobox.open();
expect(currentState.open).toBeTruthy();
combobox.select('Mûres');
expect(currentState.selection?.label).toBe('Mûres');
expect(currentState.open).toBeFalsy();
});
test('open select box and select option with enter', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Fraises');
combobox.keyboard('ArrowDown');
expect(currentState.open).toBeTruthy();
expect(currentState.selection?.label).toBe('Fraises');
expect(currentState.focused?.label).toBe('Fraises');
combobox.keyboard('ArrowDown');
expect(currentState.selection?.label).toBe('Fraises');
expect(currentState.focused?.label).toBe('Myrtilles');
combobox.keyboard('Enter');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
combobox.keyboard('Enter');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
});
test('open select box and select option with tab', () => {
combobox.keyboard('ArrowDown');
combobox.keyboard('ArrowDown');
combobox.keyboard('Tab');
expect(currentState.selection?.label).toBe('Myrtilles');
expect(currentState.open).toBeFalsy();
expect(currentState.hint).toEqual({
type: 'selected',
label: 'Myrtilles'
});
});
test('do not open select box on focus', () => {
combobox.focus();
expect(currentState.open).toBeFalsy();
});
});
suite('empty', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('open select box on focus', () => {
combobox.focus();
expect(currentState.open).toBeTruthy();
});
suite('open', () => {
beforeEach(() => {
combobox.open();
});
test('if tab on empty input nothing is selected', () => {
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
test('if enter on empty input nothing is selected', () => {
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
});
suite('closed', () => {
test('if tab on empty input nothing is selected', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
test('if enter on empty input nothing is selected', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
});
});
test('type exact match and press enter', () => {
combobox.input('Baies');
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
expect(currentState.options.length).toEqual(3);
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Baies daçaï');
});
test('type exact match and press tab', () => {
combobox.input('Baies');
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Baies daçaï');
expect(currentState.inputValue).toEqual('Baies daçaï');
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('focus should circle', () => {
combobox.input('Baie');
expect(currentState.open).toBeTruthy();
expect(currentState.options.map(({ label }) => label)).toEqual([
'Baies daçaï',
'Baies de genièvre',
'Baies de sureau'
]);
expect(currentState.focused).toBeNull();
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de genièvre');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de sureau');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowUp');
expect(currentState.focused?.label).toBe('Baies de sureau');
});
});
});
suite('single select with custom value', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: null,
allowsCustomValue: true,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
});
suite('single select with fetcher', () => {
beforeEach(() => {
combobox = new Combobox({
options: (term: string) =>
Promise.resolve(matchSorter(options, term, { keys: ['value'] })),
selected: null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('type and get options from fetcher', async () => {
expect(currentState.open).toBeFalsy();
expect(currentState.loading).toBe(false);
const result = combobox.input('Baies');
expect(currentState.loading).toBe(true);
await result;
expect(currentState.loading).toBe(false);
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
expect(currentState.options.length).toEqual(3);
});
});
});

View file

@ -1,300 +0,0 @@
import { matchSorter } from 'match-sorter';
export enum Action {
Init = 'init',
Open = 'open',
Close = 'close',
Navigate = 'navigate',
Select = 'select',
Clear = 'clear',
Update = 'update'
}
export type Option = { value: string; label: string; data?: unknown };
export type Hint =
| {
type: 'results';
label: string | null;
count: number;
}
| { type: 'empty' }
| { type: 'selected'; label: string };
export type State = {
action: Action;
open: boolean;
inputValue: string;
focused: Option | null;
selection: Option | null;
options: Option[];
allowsCustomValue: boolean;
hint: Hint | null;
loading: boolean | null;
};
export type Fetcher = (
term: string,
options?: { signal: AbortSignal }
) => Promise<Option[]>;
export class Combobox {
#allowsCustomValue = false;
#limit?: number;
#open = false;
#inputValue = '';
#selectedOption: Option | null = null;
#focusedOption: Option | null = null;
#options: Option[] = [];
#visibleOptions: Option[] = [];
#render: (state: State) => void;
#fetcher: Fetcher | null;
#abortController?: AbortController | null;
constructor({
options,
selected,
allowsCustomValue,
limit,
render
}: {
options: Option[] | Fetcher;
selected: Option | null;
allowsCustomValue?: boolean;
limit?: number;
render: (state: State) => void;
}) {
this.#allowsCustomValue = allowsCustomValue ?? false;
this.#limit = limit;
this.#options = Array.isArray(options) ? options : [];
this.#fetcher = Array.isArray(options) ? null : options;
this.#selectedOption = selected;
if (this.#selectedOption) {
this.#inputValue = this.#selectedOption.label;
}
this.#render = render;
}
init(): void {
this.#visibleOptions = this._filterOptions();
this._render(Action.Init);
}
destroy(): void {
this.#render = () => null;
}
navigate(indexDiff: -1 | 1 = 1): void {
const focusIndex = this._focusedOptionIndex;
const lastIndex = this.#visibleOptions.length - 1;
let indexOfItem = indexDiff == 1 ? 0 : lastIndex;
if (focusIndex == lastIndex && indexDiff == 1) {
indexOfItem = 0;
} else if (focusIndex == 0 && indexDiff == -1) {
indexOfItem = lastIndex;
} else if (focusIndex == -1) {
indexOfItem = 0;
} else {
indexOfItem = focusIndex + indexDiff;
}
this.#focusedOption = this.#visibleOptions.at(indexOfItem) ?? null;
this._render(Action.Navigate);
}
select(value?: string): boolean {
const maybeValue = this._nextSelectValue(value);
if (!maybeValue) {
this.close();
return false;
}
const option = this.#visibleOptions.find(
(option) => option.value.trim() == maybeValue.trim()
);
if (!option) return false;
this.#selectedOption = option;
this.#focusedOption = null;
this.#inputValue = option.label;
this.#open = false;
this.#visibleOptions = this._filterOptions();
this._render(Action.Select);
return true;
}
async input(value: string) {
if (this.#inputValue == value) return;
this.#inputValue = value;
if (this.#fetcher) {
this.#abortController?.abort();
this.#abortController = new AbortController();
this._render(Action.Update);
this.#options = await this.#fetcher(value, {
signal: this.#abortController.signal
}).catch(() => []);
this.#abortController = null;
this._render(Action.Update);
this.#selectedOption = null;
} else {
this.#selectedOption = null;
}
this.#visibleOptions = this._filterOptions();
if (this.#visibleOptions.length > 0) {
if (!this.#open) {
this.open();
} else {
this._render(Action.Update);
}
} else if (this.#allowsCustomValue) {
this.#open = false;
this.#focusedOption = null;
this._render(Action.Close);
} else {
this._render(Action.Update);
}
}
keyboard(key: string) {
switch (key) {
case 'Enter':
case 'Tab':
return this.select();
case 'Escape':
this.close();
return true;
case 'ArrowDown':
if (this.#open) {
this.navigate(1);
} else {
this.open();
}
return true;
case 'ArrowUp':
if (this.#open) {
this.navigate(-1);
} else {
this.open();
}
return true;
}
}
clear() {
if (!this.#inputValue && !this.#selectedOption) return;
this.#inputValue = '';
this.#selectedOption = this.#focusedOption = null;
this.#visibleOptions = this.#options;
this.#visibleOptions = this._filterOptions();
this._render(Action.Clear);
}
open() {
if (this.#open || this.#visibleOptions.length == 0) return;
this.#open = true;
this.#focusedOption = this.#selectedOption;
this._render(Action.Open);
}
close() {
this.#open = false;
this.#focusedOption = null;
if (!this.#allowsCustomValue && !this.#selectedOption) {
this.#inputValue = '';
}
this.#visibleOptions = this._filterOptions();
this._render(Action.Close);
}
focus() {
if (this.#open) return;
if (this.#selectedOption) return;
this.open();
}
toggle() {
this.#open ? this.close() : this.open();
}
private _nextSelectValue(value?: string): string | false {
if (value) {
return value;
}
if (this.#focusedOption && this._focusedOptionIndex != -1) {
return this.#focusedOption.value;
}
if (this.#allowsCustomValue) {
return false;
}
if (this.#inputValue.length > 0 && !this.#selectedOption) {
return this.#visibleOptions.at(0)?.value ?? false;
}
return false;
}
private _filterOptions(): Option[] {
const emptyOrSelected =
!this.#inputValue || this.#inputValue == this.#selectedOption?.value;
const options = emptyOrSelected
? this.#options
: matchSorter(this.#options, this.#inputValue, {
keys: ['label']
});
if (this.#limit) {
return options.slice(0, this.#limit);
}
return options;
}
private get _focusedOptionIndex(): number {
if (this.#focusedOption) {
return this.#visibleOptions.indexOf(this.#focusedOption);
}
return -1;
}
private _render(action: Action): void {
this.#render(this._getState(action));
}
private _getState(action: Action): State {
const state = {
action,
open: this.#open,
options: this.#visibleOptions,
inputValue: this.#inputValue,
focused: this.#focusedOption,
selection: this.#selectedOption,
allowsCustomValue: this.#allowsCustomValue,
hint: null,
loading: this.#abortController ? true : this.#fetcher ? false : null
};
return { ...state, hint: this._getFeedback(state) };
}
private _getFeedback(state: State): Hint | null {
const count = state.options.length;
if (state.action == Action.Open || state.action == Action.Update) {
if (!state.selection) {
const defaultOption = state.options.at(0);
if (defaultOption) {
return { type: 'results', label: defaultOption.label, count };
} else if (count > 0) {
return { type: 'results', label: null, count };
}
return { type: 'empty' };
}
} else if (state.action == Action.Select && state.selection) {
return { type: 'selected', label: state.selection.label };
}
return null;
}
}

View file

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core';
import { z } from 'zod';
import * as s from 'superstruct';
type EditorAction = {
run(): void;
@ -11,7 +11,7 @@ export function getAction(
editor: Editor,
button: HTMLButtonElement
): EditorAction {
return tiptapActionSchema.parse(button.dataset)(editor);
return s.create(button.dataset, tiptapActionSchema)(editor);
}
const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
})
};
const tiptapActionSchema = z
.object({
tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
})
.transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]);
const EditorActionFn = s.define<(editor: Editor) => EditorAction>(
'EditorActionFn',
(fn) => typeof fn == 'function'
);
const tiptapActionSchema = s.coerce(
EditorActionFn,
s.type({
tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
}),
({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]
);

View file

@ -1,12 +1,17 @@
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion';
import { z } from 'zod';
import * as s from 'superstruct';
import tippy, { type Instance as TippyInstance } from 'tippy.js';
import { matchSorter } from 'match-sorter';
export const tagSchema = z
.object({ tagLabel: z.string(), tagId: z.string() })
.transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }));
export type TagSchema = z.infer<typeof tagSchema>;
export const tagSchema = s.coerce(
s.object({ label: s.string(), id: s.string() }),
s.type({
tagLabel: s.string(),
tagId: s.string()
}),
({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })
);
export type TagSchema = s.Infer<typeof tagSchema>;
class SuggestionMenu {
#selectedIndex = 0;

View file

@ -1,72 +1,87 @@
import { session } from '@hotwired/turbo';
import { z } from 'zod';
import * as s from 'superstruct';
const Gon = z
.object({
autosave: z
.object({
debounce_delay: z.number().default(0),
status_visible_duration: z.number().default(0)
})
.default({}),
autocomplete: z
.object({
api_geo_url: z.string().optional(),
api_adresse_url: z.string().optional(),
api_education_url: z.string().optional()
})
.default({}),
locale: z.string().default('fr'),
matomo: z
.object({
cookieDomain: z.string().optional(),
domain: z.string().optional(),
enabled: z.boolean().default(false),
host: z.string().optional(),
key: z.string().or(z.number()).nullish()
})
.default({}),
sentry: z
.object({
key: z.string().nullish(),
enabled: z.boolean().default(false),
environment: z.string().optional(),
user: z.object({ id: z.string() }).default({ id: '' }),
browser: z.object({ modern: z.boolean() }).default({ modern: false }),
release: z.string().nullish()
})
.default({}),
crisp: z
.object({
key: z.string().nullish(),
enabled: z.boolean().default(false),
administrateur: z
.object({
email: z.string(),
DS_SIGN_IN_COUNT: z.number(),
DS_NB_DEMARCHES_BROUILLONS: z.number(),
DS_NB_DEMARCHES_ACTIVES: z.number(),
DS_NB_DEMARCHES_ARCHIVES: z.number(),
DS_ID: z.number()
})
.default({
function nullish<T, S>(struct: s.Struct<T, S>) {
return s.optional(s.union([s.literal(null), struct]));
}
const Gon = s.defaulted(
s.type({
autosave: s.defaulted(
s.type({
debounce_delay: s.defaulted(s.number(), 0),
status_visible_duration: s.defaulted(s.number(), 0)
}),
{}
),
autocomplete: s.defaulted(
s.partial(
s.type({
api_geo_url: s.string(),
api_adresse_url: s.string(),
api_education_url: s.string()
})
),
{}
),
locale: s.defaulted(s.string(), 'fr'),
matomo: s.defaulted(
s.type({
cookieDomain: s.optional(s.string()),
domain: s.optional(s.string()),
enabled: s.defaulted(s.boolean(), false),
host: s.optional(s.string()),
key: nullish(s.union([s.string(), s.number()]))
}),
{}
),
sentry: s.defaulted(
s.type({
key: nullish(s.string()),
enabled: s.defaulted(s.boolean(), false),
environment: s.optional(s.string()),
user: s.defaulted(s.type({ id: s.string() }), { id: '' }),
browser: s.defaulted(s.type({ modern: s.boolean() }), {
modern: false
}),
release: nullish(s.string())
}),
{}
),
crisp: s.defaulted(
s.type({
key: nullish(s.string()),
enabled: s.defaulted(s.boolean(), false),
administrateur: s.defaulted(
s.type({
email: s.string(),
DS_SIGN_IN_COUNT: s.number(),
DS_NB_DEMARCHES_BROUILLONS: s.number(),
DS_NB_DEMARCHES_ACTIVES: s.number(),
DS_NB_DEMARCHES_ARCHIVES: s.number(),
DS_ID: s.number()
}),
{
email: '',
DS_SIGN_IN_COUNT: 0,
DS_NB_DEMARCHES_BROUILLONS: 0,
DS_NB_DEMARCHES_ACTIVES: 0,
DS_NB_DEMARCHES_ARCHIVES: 0,
DS_ID: 0
})
})
.default({}),
defaultQuery: z.string().optional(),
defaultVariables: z.string().optional()
})
.default({});
}
)
}),
{}
),
defaultQuery: s.optional(s.string()),
defaultVariables: s.optional(s.string())
}),
{}
);
declare const window: Window & typeof globalThis & { gon: unknown };
export function getConfig() {
return Gon.parse(window.gon);
return s.create(window.gon, Gon);
}
export function show(el: Element | null) {

View file

@ -107,11 +107,6 @@ class Champ < ApplicationRecord
[to_s]
end
def valid_value
return unless valid_champ_value?
value
end
def to_s
TypeDeChamp.champ_value(type_champ, self)
end
@ -285,7 +280,7 @@ class Champ < ApplicationRecord
return if value.nil?
return if value.present? && !value.include?("\u0000")
self.value = value.delete("\u0000")
write_attribute(:value, value.delete("\u0000"))
end
class NotImplemented < ::StandardError

View file

@ -3,10 +3,6 @@ class Champs::AddressChamp < Champs::TextChamp
data.present?
end
def feature
data.to_json if full_address?
end
def feature=(value)
if value.blank?
self.data = nil
@ -22,6 +18,14 @@ class Champs::AddressChamp < Champs::TextChamp
self.data = nil
end
def selected_items
if value.present?
[{ value:, label: value, data: full_address? ? data : nil }]
else
[]
end
end
def address
full_address? ? data : nil
end

View file

@ -7,14 +7,11 @@ class Champs::AnnuaireEducationChamp < Champs::TextChamp
APIEducation::AnnuaireEducationAdapter.new(external_id).to_params
end
def update_with_external_data!(data:)
if data&.is_a?(Hash) && data['nom_etablissement'].present? && data['nom_commune'].present? && data['identifiant_de_l_etablissement'].present?
update!(
data: data,
value: "#{data['nom_etablissement']}, #{data['nom_commune']} (#{data['identifiant_de_l_etablissement']})"
)
def selected_items
if external_id.present?
[{ value: external_id, label: value }]
else
update!(data: data)
[]
end
end
end

View file

@ -50,7 +50,7 @@ class Champs::COJOChamp < Champ
def update_external_id
if accreditation_number_changed? || accreditation_birthdate_changed?
if accreditation_number.present? && accreditation_birthdate.present? && /\A\d+\z/.match?(accreditation_number)
if accreditation_number.present? && accreditation_birthdate.present? && /\A[\d-]+\z/.match?(accreditation_number)
self.external_id = { accreditation_number:, accreditation_birthdate: }.to_json
else
self.external_id = nil

View file

@ -28,10 +28,6 @@ class Champs::CommuneChamp < Champs::TextChamp
code_postal.present?
end
def code_postal=(value)
super(value&.gsub(/[[:space:]]/, ''))
end
alias postal_code code_postal
def name
@ -43,7 +39,36 @@ class Champs::CommuneChamp < Champs::TextChamp
end
def selected
code
code? ? "#{code}-#{code_postal}" : nil
end
def selected_items
if code?
[{ label: to_s, value: selected }]
else
[]
end
end
def code=(code)
if code.blank?
self.code_departement = nil
self.code_postal = nil
self.external_id = nil
self.value = nil
elsif code.match?(/-/)
codes = code.split('-')
self.external_id = codes.first
self.code_postal = codes.second
else
self.external_id = code
end
end
private
def safe_to_s
value.present? ? value.to_s : ''
end
def communes
@ -54,12 +79,6 @@ class Champs::CommuneChamp < Champs::TextChamp
end
end
private
def safe_to_s
value.present? ? value.to_s : ''
end
def on_codes_change
return if !code?

View file

@ -22,6 +22,6 @@ class Champs::DecimalNumberChamp < Champ
def format_value
return if value.blank?
self.value = value.tr(",", ".")
self.value = value.tr(",", ".").gsub(/[[:space:]]/, "")
end
end

View file

@ -1,4 +1,6 @@
class Champs::IntegerNumberChamp < Champ
before_validation :format_value
validates :value, numericality: {
only_integer: true,
allow_nil: true,
@ -8,4 +10,10 @@ class Champs::IntegerNumberChamp < Champ
object.errors.generate_message(:value, :not_an_integer)
}
}, if: :validate_champ_value_or_prefill?
def format_value
return if value.blank?
self.value = value.gsub(/[[:space:]]/, "")
end
end

View file

@ -8,7 +8,8 @@ module TrustedDeviceConcern
cookies.encrypted[TRUSTED_DEVICE_COOKIE_NAME] = {
value: JSON.generate({ created_at: start_at }),
expires: start_at + TRUSTED_DEVICE_PERIOD,
httponly: true
httponly: true,
secure: Rails.env.production?
}
end

View file

@ -641,8 +641,7 @@ class TypeDeChamp < ApplicationRecord
# We should refresh all champs after update except for champs using react or custom refresh
# logic (RNA, SIRET, etc.)
case type_champ
when type_champs.fetch(:annuaire_education),
type_champs.fetch(:carte),
when type_champs.fetch(:carte),
type_champs.fetch(:piece_justificative),
type_champs.fetch(:titre_identite),
type_champs.fetch(:rna),

View file

@ -20,7 +20,7 @@ class TypesDeChamp::DecimalNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase
private
def champ_formatted_value(champ)
champ.valid_value&.to_f
champ.value&.to_f
end
end
end

View file

@ -20,7 +20,7 @@ class TypesDeChamp::IntegerNumberTypeDeChamp < TypesDeChamp::TypeDeChampBase
private
def champ_formatted_value(champ)
champ.valid_value&.to_i
champ.value&.to_i
end
end
end

View file

@ -66,12 +66,12 @@ class TypesDeChamp::TypeDeChampBase
when 2
champ_value(champ)
else
champ.valid_value.presence || champ_default_api_value(version)
champ.value.presence || champ_default_api_value(version)
end
end
def champ_value_for_export(champ, path = :value)
path == :value ? champ.valid_value.presence : champ_default_export_value(path)
path == :value ? champ.value.presence : champ_default_export_value(path)
end
def champ_value_for_tag(champ, path = :value)

View file

@ -65,15 +65,13 @@
.instructeur-wrapper
%p#experts-emails Entrez les adresses emails des experts que vous souhaitez ajouter à la liste prédéfinie
= hidden_field_tag :emails, nil
= react_component("ComboMultiple",
options: [],
selected: [], disabled: [],
group: '.instructeur-wrapper',
name: 'emails',
label: 'Emails',
describedby: 'experts-emails',
acceptNewValues: true)
%react-fragment
= render ReactComponent.new "ComboBox/MultiComboBox",
id: 'emails',
name: 'emails[]',
allows_custom_value: true,
'aria-label': 'Emails',
'aria-describedby': 'experts-emails'
= f.submit 'Ajouter à la liste', class: 'fr-btn'

View file

@ -10,14 +10,8 @@
- if disabled_as_super_admin
= f.select :emails, available_instructeur_emails, {}, disabled: disabled_as_super_admin, id: 'instructeur_emails'
- else
= hidden_field_tag :emails, nil
= react_component("ComboMultiple",
options: available_instructeur_emails, selected: [], disabled: [],
group: '.instructeur-wrapper',
id: 'instructeur_emails',
name: 'emails',
label: 'Emails',
acceptNewValues: true)
%react-fragment
= render ReactComponent.new 'ComboBox/MultiComboBox', items: available_instructeur_emails, id: 'instructeur_emails', name: 'emails[]', allows_custom_value: true, 'aria-label': 'Emails'
= f.submit 'Affecter', class: 'fr-btn', disabled: disabled_as_super_admin

View file

@ -122,17 +122,16 @@
.fr-fieldset__element
= f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label'
%p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs.
= hidden_field_tag 'procedure[tags]', JSON.generate(@procedure.tags)
= react_component("ComboMultiple",
id: "procedure_tags_combo",
options: Procedure.tags,
selected: @procedure.tags,
disabled: [],
label: 'Tags',
group: '.procedure_tags_combo',
name: 'tags',
describedby: 'procedure-tags',
acceptNewValues: true)
%react-fragment
= render ReactComponent.new "ComboBox/MultiComboBox",
id: "procedure_tags_combo",
items: Procedure.tags,
selected_keys: @procedure.tags,
name: 'procedure[tags][]',
value_separator: ',|;',
allows_custom_value: true,
'aria-label': 'Tags',
'aria-describedby': 'procedure-tags'
%details.procedure-form__options-details
%summary.procedure-form__options-summary

View file

@ -1,7 +1,9 @@
= turbo_stream.remove dom_id(@attachment, :persisted_row)
- if @champ_id
= turbo_stream.show "attachment-multiple-empty-#{@champ_id}"
= turbo_stream.focus_all "#attachment-multiple-empty-#{@champ_id} input"
= turbo_stream.show_all ".attachment-input-#{@attachment.id}"
- if @champ
= fields_for @champ.input_name, @champ do |form|
= turbo_stream.replace @champ.input_group_id do
= render EditableChamp::EditableChampComponent.new champ: @champ, form: form
= turbo_stream.focus_all "#attachment-multiple-empty-#{@champ.public_id} input"

View file

@ -13,7 +13,11 @@
#{Current.application_name}
%li= link_to t('views.shared.account.already_user'), commencer_sign_in_path(path: @procedure.path, prefill_token: @prefilled_dossier&.prefill_token), class: 'fr-btn fr-btn--secondary'
= render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w")
- else
= render ProcedureDraftWarningComponent.new(revision: @revision, current_administrateur:, extra_class_names: "fr-mb-2w")
- if @prefilled_dossier
= render Dsfr::CalloutComponent.new(title: t(".prefilled_draft"), heading_level: 'h2') do |c|
- c.with_body do

View file

@ -7,12 +7,7 @@
%p.tab-paragrah.mb-1
Le destinataire suivra automatiquement le dossier
= form_for dossier, url: send_to_instructeurs_instructeur_dossier_path(dossier.procedure, dossier), method: :post, html: { class: 'form recipients-form fr-mb-4w' } do |f|
= hidden_field_tag :recipients, nil
= react_component("ComboMultiple",
options: potential_recipients.map{|r| [r.email, r.id]},
selected: [], disabled: [],
group: '.recipients-form',
name: 'recipients',
label: 'Emails')
%react-fragment
= render ReactComponent.new "ComboBox/MultiComboBox", items: potential_recipients.map { [_1.email, _1.id] }, name: 'recipients[]', 'aria-label': 'Emails'
= f.submit "Envoyer", class: "fr-btn fr-mt-2w"

Some files were not shown because too many files have changed in this diff Show more