react-bootstrap-front 3.10.2 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG +9 -0
  2. package/dist/react-bootstrap-front.js +14 -14
  3. package/doc/editor/feuille-de-route.md +36 -0
  4. package/package.json +42 -8
  5. package/src/features/advanced/DropdownWrapper.js +12 -2
  6. package/src/features/basic/Dropdown.js +112 -31
  7. package/src/features/basic/computePlacement.js +66 -0
  8. package/src/features/editor/HtmlEditor.js +681 -0
  9. package/src/features/editor/HtmlEditor.scss +189 -0
  10. package/src/features/editor/ImageDialog.js +195 -0
  11. package/src/features/editor/LinkDialog.js +148 -0
  12. package/src/features/editor/MergeMenu.js +120 -0
  13. package/src/features/editor/SourceView.js +61 -0
  14. package/src/features/editor/Toolbar.js +355 -0
  15. package/src/features/editor/Toolbar.scss +130 -0
  16. package/src/features/editor/ToolbarButton.js +59 -0
  17. package/src/features/editor/ToolbarDropdown.js +46 -0
  18. package/src/features/editor/ToolbarGroup.js +27 -0
  19. package/src/features/editor/buildExtensions.js +77 -0
  20. package/src/features/editor/defaultIcons.js +45 -0
  21. package/src/features/editor/extensions/CleanPaste.js +44 -0
  22. package/src/features/editor/extensions/FontSize.js +60 -0
  23. package/src/features/editor/extensions/LegacyImageAttrs.js +43 -0
  24. package/src/features/editor/extensions/MergeVariable.js +104 -0
  25. package/src/features/editor/extensions/index.js +4 -0
  26. package/src/features/editor/html/cleanHtml.constants.js +279 -0
  27. package/src/features/editor/html/cleanHtml.js +691 -0
  28. package/src/features/editor/html/index.js +4 -0
  29. package/src/features/editor/html/normalizeLegacyHtml.js +196 -0
  30. package/src/features/editor/html/serialize.js +130 -0
  31. package/src/features/editor/html/sourceHelpers.js +86 -0
  32. package/src/features/editor/index.js +20 -0
  33. package/src/features/editor/style.scss +2 -0
  34. package/src/features/editor/toolbarPresets.js +74 -0
  35. package/src/features/page/DefaultHeader.jsx +1 -1
  36. package/src/index.js +8 -0
  37. package/src/styles/index.scss +1 -0
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Styles de base de ProseMirror.
3
+ *
4
+ * Recopiés depuis prosemirror-view/style/prosemirror.css et
5
+ * prosemirror-gapcursor/style/gapcursor.css (licence MIT, stables depuis des
6
+ * années) plutôt qu'importés par `~prosemirror-view/...` : avec `npm link`, le
7
+ * `~` résoudrait dans les node_modules de la librairie et non dans ceux de
8
+ * l'application, ce qui couplerait la résolution SCSS des deux dépôts.
9
+ */
10
+ .ProseMirror {
11
+ position: relative;
12
+ word-wrap: break-word;
13
+ white-space: pre-wrap;
14
+ white-space: break-spaces;
15
+ -webkit-font-variant-ligatures: none;
16
+ font-variant-ligatures: none;
17
+ font-feature-settings: 'liga' 0;
18
+ outline: none;
19
+ }
20
+
21
+ .ProseMirror pre {
22
+ white-space: pre-wrap;
23
+ }
24
+
25
+ .ProseMirror li {
26
+ position: relative;
27
+ }
28
+
29
+ .ProseMirror-hideselection *::selection {
30
+ background: transparent;
31
+ }
32
+ .ProseMirror-hideselection *::-moz-selection {
33
+ background: transparent;
34
+ }
35
+ .ProseMirror-hideselection {
36
+ caret-color: transparent;
37
+ }
38
+
39
+ .ProseMirror-selectednode {
40
+ outline: 2px solid #8cf;
41
+ }
42
+
43
+ li.ProseMirror-selectednode {
44
+ outline: none;
45
+ }
46
+
47
+ li.ProseMirror-selectednode:after {
48
+ content: '';
49
+ position: absolute;
50
+ left: -32px;
51
+ right: -2px;
52
+ top: -2px;
53
+ bottom: -2px;
54
+ border: 2px solid #8cf;
55
+ pointer-events: none;
56
+ }
57
+
58
+ img.ProseMirror-separator {
59
+ display: inline !important;
60
+ border: none !important;
61
+ margin: 0 !important;
62
+ }
63
+
64
+ .ProseMirror-gapcursor {
65
+ display: none;
66
+ pointer-events: none;
67
+ position: absolute;
68
+ }
69
+
70
+ .ProseMirror-gapcursor:after {
71
+ content: '';
72
+ display: block;
73
+ position: absolute;
74
+ top: -2px;
75
+ width: 20px;
76
+ border-top: 1px solid black;
77
+ animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
78
+ }
79
+
80
+ @keyframes ProseMirror-cursor-blink {
81
+ to {
82
+ visibility: hidden;
83
+ }
84
+ }
85
+
86
+ .ProseMirror-focused .ProseMirror-gapcursor {
87
+ display: block;
88
+ }
89
+
90
+ /**
91
+ * Le champ lui-même.
92
+ */
93
+ .rbf-editor {
94
+ .rbf-editor-main {
95
+ padding: 0;
96
+ display: flex;
97
+ flex-direction: column;
98
+ min-width: 0;
99
+ /* Bootstrap pose flex:1 sur .form-control dans un input-group, mais le
100
+ contenu ne doit pas déborder de l'input-group. */
101
+ flex: 1 1 auto;
102
+ width: 1%;
103
+ }
104
+
105
+ /* La hauteur est bornée : sans cela les six champs d'un même formulaire
106
+ font exploser la page. Le contenu défile à l'intérieur. */
107
+ .rbf-editor-scroll {
108
+ overflow-y: auto;
109
+ overflow-x: hidden;
110
+ }
111
+
112
+ .rbf-editor-content {
113
+ padding: 0.375rem 0.75rem;
114
+ min-height: 100%;
115
+ }
116
+
117
+ /* Marges resserrées : dans un champ de formulaire, l'espacement par défaut
118
+ des paragraphes donne une impression de contenu troué. */
119
+ .rbf-editor-content {
120
+ p,
121
+ ul,
122
+ ol,
123
+ blockquote,
124
+ h1,
125
+ h2,
126
+ h3 {
127
+ margin-top: 0;
128
+ margin-bottom: 0.5rem;
129
+ }
130
+
131
+ > *:last-child {
132
+ margin-bottom: 0;
133
+ }
134
+
135
+ img {
136
+ max-width: 100%;
137
+ height: auto;
138
+ }
139
+
140
+ blockquote {
141
+ padding-left: 0.75rem;
142
+ border-left: 3px solid theme-color('secondary-light');
143
+ }
144
+
145
+ hr {
146
+ margin: 0.5rem 0;
147
+ }
148
+
149
+ a {
150
+ text-decoration: underline;
151
+ }
152
+ }
153
+
154
+ /* Le texte indicatif de l'extension Placeholder. */
155
+ .rbf-editor-content p.is-editor-empty:first-child::before {
156
+ content: attr(data-placeholder);
157
+ float: left;
158
+ height: 0;
159
+ pointer-events: none;
160
+ color: theme-color('secondary-inactive');
161
+ }
162
+
163
+ .rbf-editor-append .btn {
164
+ max-height: 40px;
165
+ }
166
+
167
+ &.rbf-editor-disabled .rbf-editor-main {
168
+ background-color: $input-disabled-bg;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Variables de fusion. La variante « inconnue » est le vrai apport : une
174
+ * variable mal orthographiée se voit à la frappe, alors qu'elle ne se
175
+ * découvrait jusqu'ici qu'à l'envoi du courriel.
176
+ */
177
+ .rbf-mergevar {
178
+ background: #e8f5e9;
179
+ color: #105a1c;
180
+ border-radius: 3px;
181
+ padding: 0 2px;
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .rbf-mergevar-unknown {
186
+ background: #fdecea;
187
+ color: #b71c1c;
188
+ text-decoration: underline wavy;
189
+ }
@@ -0,0 +1,195 @@
1
+ import React, { Component } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import classnames from 'classnames';
4
+
5
+ /**
6
+ * Insertion d'une image.
7
+ *
8
+ * Deux voies :
9
+ * - une adresse, toujours disponible et de loin la plus sûre pour un
10
+ * courriel : les images doivent être hébergées, pas embarquées ;
11
+ * - un fichier, seulement si l'application fournit `onUpload`. Sans endpoint
12
+ * d'upload, l'onglet propose un repli en data-URL plafonné, avec
13
+ * l'avertissement qui va avec.
14
+ */
15
+ export default class ImageDialog extends Component {
16
+ static propTypes = {
17
+ onApply: PropTypes.func.isRequired,
18
+ onCancel: PropTypes.func.isRequired,
19
+ /** (File) => Promise<url>. Absente, l'onglet fichier passe en data-URL. */
20
+ onUpload: PropTypes.func,
21
+ /** Autorise le repli data-URL quand onUpload est absente. */
22
+ allowDataUrl: PropTypes.bool,
23
+ maxImageSize: PropTypes.number,
24
+ t: PropTypes.func.isRequired,
25
+ };
26
+
27
+ static defaultProps = {
28
+ onUpload: null,
29
+ allowDataUrl: true,
30
+ maxImageSize: 524288,
31
+ };
32
+
33
+ constructor(props) {
34
+ super(props);
35
+ this.state = {
36
+ tab: 'url',
37
+ src: '',
38
+ alt: '',
39
+ width: '',
40
+ pending: false,
41
+ error: null,
42
+ };
43
+ this.srcRef = React.createRef();
44
+ this.onApply = this.onApply.bind(this);
45
+ this.onFile = this.onFile.bind(this);
46
+ }
47
+
48
+ componentDidMount() {
49
+ if (this.srcRef.current) {
50
+ this.srcRef.current.focus();
51
+ }
52
+ }
53
+
54
+ onApply() {
55
+ const src = this.state.src.trim();
56
+ if (src === '') {
57
+ this.setState({ error: this.props.t({ id: 'ui.editor.image.url', defaultMessage: 'Enter an address' }) });
58
+ return;
59
+ }
60
+ this.props.onApply({ src, alt: this.state.alt, width: this.state.width || null });
61
+ }
62
+
63
+ onFile(ev) {
64
+ const file = ev.target.files && ev.target.files[0];
65
+ if (!file) {
66
+ return;
67
+ }
68
+ const { t, onUpload, maxImageSize, allowDataUrl } = this.props;
69
+ this.setState({ pending: true, error: null });
70
+
71
+ if (onUpload) {
72
+ onUpload(file).then(
73
+ url => this.setState({ pending: false, src: url, tab: 'url' }),
74
+ () => this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) }),
75
+ );
76
+ return;
77
+ }
78
+
79
+ if (!allowDataUrl) {
80
+ this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) });
81
+ return;
82
+ }
83
+ if (file.size > maxImageSize) {
84
+ this.setState({
85
+ pending: false,
86
+ error: t({
87
+ id: 'ui.editor.image.tooLarge',
88
+ defaultMessage: 'Image too large to be embedded, host it and paste its address',
89
+ }),
90
+ });
91
+ return;
92
+ }
93
+ const reader = new FileReader();
94
+ reader.onload = () => this.setState({ pending: false, src: reader.result, tab: 'url' });
95
+ reader.onerror = () =>
96
+ this.setState({ pending: false, error: t({ id: 'ui.editor.image.uploadError', defaultMessage: 'Upload failed' }) });
97
+ reader.readAsDataURL(file);
98
+ }
99
+
100
+ renderTabs() {
101
+ const { t } = this.props;
102
+ return (
103
+ <ul className="nav nav-tabs nav-tabs-sm mb-2">
104
+ <li className="nav-item">
105
+ <button
106
+ type="button"
107
+ className={classnames('nav-link btn btn-link', this.state.tab === 'url' && 'active')}
108
+ onClick={() => this.setState({ tab: 'url' })}
109
+ >
110
+ {t({ id: 'ui.editor.image.url', defaultMessage: 'Address' })}
111
+ </button>
112
+ </li>
113
+ <li className="nav-item">
114
+ <button
115
+ type="button"
116
+ className={classnames('nav-link btn btn-link', this.state.tab === 'file' && 'active')}
117
+ onClick={() => this.setState({ tab: 'file' })}
118
+ >
119
+ {t({ id: 'ui.editor.image.upload', defaultMessage: 'File' })}
120
+ </button>
121
+ </li>
122
+ </ul>
123
+ );
124
+ }
125
+
126
+ render() {
127
+ const { t, onUpload, allowDataUrl } = this.props;
128
+ const showFileTab = !!onUpload || allowDataUrl;
129
+ return (
130
+ <div className="rbf-editor-dialog">
131
+ {showFileTab && this.renderTabs()}
132
+ {this.state.tab === 'file' ? (
133
+ <div>
134
+ <input type="file" accept="image/*" className="form-control-file" onChange={this.onFile} />
135
+ {!onUpload && (
136
+ <small className="form-text text-muted">
137
+ {t({
138
+ id: 'ui.editor.image.dataUrlWarning',
139
+ defaultMessage: 'The image will be embedded in the content and may be blocked by mail clients.',
140
+ })}
141
+ </small>
142
+ )}
143
+ {this.state.pending && (
144
+ <small className="form-text text-muted">
145
+ {t({ id: 'ui.editor.image.uploading', defaultMessage: 'Sending…' })}
146
+ </small>
147
+ )}
148
+ </div>
149
+ ) : (
150
+ <div className="form-row align-items-end">
151
+ <div className="col">
152
+ <label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.url', defaultMessage: 'Address' })}</label>
153
+ <input
154
+ ref={this.srcRef}
155
+ type="text"
156
+ className="form-control form-control-sm"
157
+ value={this.state.src}
158
+ placeholder="https://"
159
+ onChange={ev => this.setState({ src: ev.target.value, error: null })}
160
+ />
161
+ </div>
162
+ <div className="col">
163
+ <label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.alt', defaultMessage: 'Alt text' })}</label>
164
+ <input
165
+ type="text"
166
+ className="form-control form-control-sm"
167
+ value={this.state.alt}
168
+ onChange={ev => this.setState({ alt: ev.target.value })}
169
+ />
170
+ </div>
171
+ <div className="col-auto" style={{ maxWidth: '8rem' }}>
172
+ <label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.image.width', defaultMessage: 'Width' })}</label>
173
+ <input
174
+ type="text"
175
+ className="form-control form-control-sm"
176
+ value={this.state.width}
177
+ placeholder="600"
178
+ onChange={ev => this.setState({ width: ev.target.value })}
179
+ />
180
+ </div>
181
+ <div className="col-auto">
182
+ <button type="button" className="btn btn-sm btn-primary" onClick={this.onApply}>
183
+ {t({ id: 'ui.editor.image.apply', defaultMessage: 'Insert' })}
184
+ </button>
185
+ <button type="button" className="btn btn-sm btn-link" onClick={this.props.onCancel}>
186
+ {t({ id: 'ui.editor.dialog.cancel', defaultMessage: 'Cancel' })}
187
+ </button>
188
+ </div>
189
+ </div>
190
+ )}
191
+ {this.state.error && <div className="text-danger small mt-1">{this.state.error}</div>}
192
+ </div>
193
+ );
194
+ }
195
+ }
@@ -0,0 +1,148 @@
1
+ import React, { Component } from 'react';
2
+ import PropTypes from 'prop-types';
3
+
4
+ const SAFE_SCHEME = /^(https?:|mailto:|tel:|#|\/)/i;
5
+
6
+ /**
7
+ * Saisie d'un lien.
8
+ *
9
+ * Panneau en ligne plutôt que modale : l'éditeur reste visible, et surtout on
10
+ * n'ouvre pas une modale à l'intérieur d'une modale — la plupart des
11
+ * formulaires appelants sont déjà dans un `ResponsiveModalOrForm`.
12
+ *
13
+ * La cible se choisit ici, lien par lien, et n'est jamais forcée globalement :
14
+ * un `target="_blank"` posé par défaut réécrirait tous les liens de la base au
15
+ * premier enregistrement.
16
+ */
17
+ export default class LinkDialog extends Component {
18
+ static propTypes = {
19
+ url: PropTypes.string,
20
+ text: PropTypes.string,
21
+ target: PropTypes.string,
22
+ canRemove: PropTypes.bool,
23
+ /** Vrai si du texte est sélectionné : le libellé n'est alors pas demandé. */
24
+ hasSelection: PropTypes.bool,
25
+ onApply: PropTypes.func.isRequired,
26
+ onRemove: PropTypes.func.isRequired,
27
+ onCancel: PropTypes.func.isRequired,
28
+ t: PropTypes.func.isRequired,
29
+ };
30
+
31
+ static defaultProps = {
32
+ url: '',
33
+ text: '',
34
+ target: '',
35
+ canRemove: false,
36
+ hasSelection: false,
37
+ };
38
+
39
+ constructor(props) {
40
+ super(props);
41
+ this.state = {
42
+ url: props.url,
43
+ text: props.text,
44
+ blank: props.target === '_blank',
45
+ error: null,
46
+ };
47
+ this.urlRef = React.createRef();
48
+ this.onApply = this.onApply.bind(this);
49
+ this.onKeyDown = this.onKeyDown.bind(this);
50
+ }
51
+
52
+ componentDidMount() {
53
+ if (this.urlRef.current) {
54
+ this.urlRef.current.focus();
55
+ }
56
+ }
57
+
58
+ onKeyDown(ev) {
59
+ if (ev.key === 'Enter') {
60
+ ev.preventDefault();
61
+ this.onApply();
62
+ } else if (ev.key === 'Escape') {
63
+ ev.preventDefault();
64
+ this.props.onCancel();
65
+ }
66
+ }
67
+
68
+ onApply() {
69
+ const url = this.state.url.trim();
70
+ if (url === '') {
71
+ this.setState({ error: this.props.t({ id: 'ui.editor.link.invalidUrl', defaultMessage: 'Enter an address' }) });
72
+ return;
73
+ }
74
+ if (!SAFE_SCHEME.test(url)) {
75
+ /* Une adresse sans schéma est presque toujours un oubli de https://,
76
+ et accepter n'importe quel schéma ouvrirait la porte à javascript:. */
77
+ if (url.indexOf(':') !== -1) {
78
+ this.setState({
79
+ error: this.props.t({ id: 'ui.editor.link.invalidUrl', defaultMessage: 'Unsupported address' }),
80
+ });
81
+ return;
82
+ }
83
+ this.props.onApply({ url: 'https://' + url, text: this.state.text, target: this.state.blank ? '_blank' : null });
84
+ return;
85
+ }
86
+ this.props.onApply({ url, text: this.state.text, target: this.state.blank ? '_blank' : null });
87
+ }
88
+
89
+ render() {
90
+ const { t, canRemove, hasSelection } = this.props;
91
+ return (
92
+ <div className="rbf-editor-dialog" onKeyDown={this.onKeyDown}>
93
+ <div className="form-row align-items-end">
94
+ <div className="col">
95
+ <label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.link.url', defaultMessage: 'Address' })}</label>
96
+ <input
97
+ ref={this.urlRef}
98
+ type="text"
99
+ className="form-control form-control-sm"
100
+ value={this.state.url}
101
+ placeholder="https://"
102
+ onChange={ev => this.setState({ url: ev.target.value, error: null })}
103
+ />
104
+ </div>
105
+ {!hasSelection && (
106
+ <div className="col">
107
+ <label className="rbf-editor-dialog-label">{t({ id: 'ui.editor.link.label', defaultMessage: 'Label' })}</label>
108
+ <input
109
+ type="text"
110
+ className="form-control form-control-sm"
111
+ value={this.state.text}
112
+ onChange={ev => this.setState({ text: ev.target.value })}
113
+ />
114
+ </div>
115
+ )}
116
+ <div className="col-auto">
117
+ <div className="custom-control custom-checkbox">
118
+ <input
119
+ type="checkbox"
120
+ className="custom-control-input"
121
+ id="rbf-editor-link-blank"
122
+ checked={this.state.blank}
123
+ onChange={ev => this.setState({ blank: ev.target.checked })}
124
+ />
125
+ <label className="custom-control-label" htmlFor="rbf-editor-link-blank">
126
+ {t({ id: 'ui.editor.link.target', defaultMessage: 'New window' })}
127
+ </label>
128
+ </div>
129
+ </div>
130
+ <div className="col-auto">
131
+ <button type="button" className="btn btn-sm btn-primary" onClick={this.onApply}>
132
+ {t({ id: 'ui.editor.link.apply', defaultMessage: 'Apply' })}
133
+ </button>
134
+ {canRemove && (
135
+ <button type="button" className="btn btn-sm btn-outline-danger ml-1" onClick={this.props.onRemove}>
136
+ {t({ id: 'ui.editor.link.remove', defaultMessage: 'Remove' })}
137
+ </button>
138
+ )}
139
+ <button type="button" className="btn btn-sm btn-link" onClick={this.props.onCancel}>
140
+ {t({ id: 'ui.editor.dialog.cancel', defaultMessage: 'Cancel' })}
141
+ </button>
142
+ </div>
143
+ </div>
144
+ {this.state.error && <div className="text-danger small mt-1">{this.state.error}</div>}
145
+ </div>
146
+ );
147
+ }
148
+ }
@@ -0,0 +1,120 @@
1
+ import React, { Component } from 'react';
2
+ import PropTypes from 'prop-types';
3
+
4
+ /**
5
+ * Normalise la structure des variables de fusion.
6
+ *
7
+ * Accepte la forme groupée `[{ group, items: [{ code, label, help }] }]` et
8
+ * l'ancienne forme plate `[{ text_code, text_content }]` de `presetTexts`,
9
+ * repliée en un groupe unique. La prop historique reste donc fonctionnelle.
10
+ */
11
+ export function normalizeMergeVars(mergeVars, fallbackGroupLabel) {
12
+ if (!Array.isArray(mergeVars) || mergeVars.length === 0) {
13
+ return [];
14
+ }
15
+ const flat = [];
16
+ const groups = [];
17
+ for (let i = 0; i < mergeVars.length; i++) {
18
+ const entry = mergeVars[i];
19
+ if (!entry) {
20
+ continue;
21
+ }
22
+ if (Array.isArray(entry.items)) {
23
+ groups.push({
24
+ key: entry.key || entry.group || String(i),
25
+ group: entry.group || '',
26
+ items: entry.items.filter(item => item && item.code),
27
+ });
28
+ } else if (entry.code || entry.text_code) {
29
+ flat.push({
30
+ code: entry.code || entry.text_code,
31
+ label: entry.label || entry.text_content || entry.code || entry.text_code,
32
+ help: entry.help || '',
33
+ });
34
+ }
35
+ }
36
+ if (flat.length > 0) {
37
+ groups.push({ key: '__flat__', group: fallbackGroupLabel || '', items: flat });
38
+ }
39
+ return groups;
40
+ }
41
+
42
+ function matches(item, needle) {
43
+ if (needle === '') {
44
+ return true;
45
+ }
46
+ const haystack = ((item.label || '') + ' ' + (item.code || '')).toLowerCase();
47
+ return haystack.indexOf(needle) !== -1;
48
+ }
49
+
50
+ /**
51
+ * Menu d'insertion des variables de fusion.
52
+ * Le champ de filtre devient indispensable dès une trentaine de variables.
53
+ */
54
+ export default class MergeMenu extends Component {
55
+ static propTypes = {
56
+ groups: PropTypes.array.isRequired,
57
+ onSelect: PropTypes.func.isRequired,
58
+ t: PropTypes.func.isRequired,
59
+ };
60
+
61
+ constructor(props) {
62
+ super(props);
63
+ this.state = { filter: '' };
64
+ this.onFilter = this.onFilter.bind(this);
65
+ }
66
+
67
+ onFilter(ev) {
68
+ this.setState({ filter: ev.target.value.toLowerCase() });
69
+ }
70
+
71
+ render() {
72
+ const { groups, onSelect, t } = this.props;
73
+ const needle = this.state.filter;
74
+ const visible = groups
75
+ .map(group => ({ ...group, items: group.items.filter(item => matches(item, needle)) }))
76
+ .filter(group => group.items.length > 0);
77
+
78
+ return (
79
+ <div className="rbf-editor-mergemenu">
80
+ <div className="rbf-editor-mergemenu-search">
81
+ <input
82
+ type="text"
83
+ className="form-control form-control-sm"
84
+ placeholder={t({ id: 'ui.editor.mergevar.search', defaultMessage: 'Search a variable' })}
85
+ value={this.state.filter}
86
+ onChange={this.onFilter}
87
+ /* Le clic dans le champ ne doit pas refermer le menu. */
88
+ onClick={ev => ev.stopPropagation()}
89
+ onMouseDown={ev => ev.stopPropagation()}
90
+ />
91
+ </div>
92
+ <div className="rbf-editor-mergemenu-list">
93
+ {visible.length === 0 && (
94
+ <div className="dropdown-item-text text-muted">
95
+ {t({ id: 'ui.editor.mergevar.empty', defaultMessage: 'No variable' })}
96
+ </div>
97
+ )}
98
+ {visible.map(group => (
99
+ <div key={group.key}>
100
+ {group.group !== '' && <h6 className="dropdown-header">{group.group}</h6>}
101
+ {group.items.map(item => (
102
+ <button
103
+ key={item.code}
104
+ type="button"
105
+ className="dropdown-item rbf-editor-mergemenu-item"
106
+ title={item.help || item.code}
107
+ onMouseDown={ev => ev.preventDefault()}
108
+ onClick={() => onSelect(item)}
109
+ >
110
+ <span className="rbf-editor-mergemenu-label">{item.label}</span>
111
+ <small className="text-muted d-block">{item.code}</small>
112
+ </button>
113
+ ))}
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+ }