Skip to content

Commit ea7461c

Browse files
chore: Extract a generic "filter" Stimulus controller
1 parent d20557b commit ea7461c

4 files changed

Lines changed: 73 additions & 41 deletions

File tree

src/assets/javascripts/application.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import CollectionsSelectorController from './controllers/collections_selector_co
1010
import ColorSchemeController from './controllers/color_scheme_controller.js';
1111
import CopyToClipboardController from './controllers/copy_to_clipboard_controller.js';
1212
import CsrfController from './controllers/csrf_controller.js';
13+
import FilterController from './controllers/filter_controller.js';
1314
import FormFileController from './controllers/form_file_controller.js';
1415
import GroupSelectorController from './controllers/group_selector_controller.js';
1516
import InputPasswordController from './controllers/input_password_controller.js';
@@ -38,6 +39,7 @@ application.register('collections-selector', CollectionsSelectorController);
3839
application.register('color-scheme', ColorSchemeController);
3940
application.register('copy-to-clipboard', CopyToClipboardController);
4041
application.register('csrf', CsrfController);
42+
application.register('filter', FilterController);
4143
application.register('form-file', FormFileController);
4244
application.register('group-selector', GroupSelectorController);
4345
application.register('input-password', InputPasswordController);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
3+
// Filter a list of items on the client side.
4+
//
5+
// The items are matched against a search field (on their `data-name`) and
6+
// against a set of flags: each `flag` target is a checkbox declaring a
7+
// `data-flag` name, and the items declare the flags they carry in their
8+
// `data-flags` attribute (separated by spaces). An item matches only if it
9+
// carries all the checked flags.
10+
//
11+
// All the targets but `item` are optional, so a list can be filtered by a
12+
// search field only, by flags only, or by both.
13+
export default class extends Controller {
14+
static targets = ['search', 'flag', 'item', 'empty'];
15+
16+
connect () {
17+
this.filter();
18+
}
19+
20+
filter () {
21+
const query = this.hasSearchTarget ? this.normalize(this.searchTarget.value) : '';
22+
const flags = this.flagTargets
23+
.filter((flag) => flag.checked)
24+
.map((flag) => flag.dataset.flag);
25+
26+
const matchingItems = this.itemTargets.filter((item) => {
27+
// Note the items are only hidden: an item that is filtered out may
28+
// still contain a checked input which must be submitted with the
29+
// form.
30+
const matching = this.match(item, query, flags);
31+
item.hidden = !matching;
32+
return matching;
33+
});
34+
35+
if (this.hasEmptyTarget) {
36+
this.emptyTarget.hidden = matchingItems.length > 0;
37+
}
38+
39+
this.dispatch('filtered');
40+
}
41+
42+
match (item, query, flags) {
43+
if (!this.normalize(item.dataset.name).includes(query)) {
44+
return false;
45+
}
46+
47+
const itemFlags = (item.dataset.flags ?? '').split(' ');
48+
return flags.every((flag) => itemFlags.includes(flag));
49+
}
50+
51+
// Return a comparable version of the value, so searching for "eco" matches
52+
// a attribute titled "Écologie".
53+
//
54+
// The NFD normalization splits the accented characters into a base letter
55+
// followed by a combining mark ("é" becomes "e" + U+0301). The Diacritic
56+
// Unicode property matches these marks, which are then removed.
57+
//
58+
// The characters that have no canonical decomposition are left alone, so
59+
// "ø" or "ß" still have to be typed as is.
60+
normalize (value) {
61+
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
62+
}
63+
}

src/assets/javascripts/controllers/streams_selector_controller.js

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,15 @@ import { Controller } from '@hotwired/stimulus';
22

33
export default class extends Controller {
44
static get targets () {
5-
return ['search', 'option', 'checkbox', 'empty', 'counter'];
5+
return ['checkbox', 'counter'];
66
}
77

88
connect () {
99
this.refreshCounter();
1010
}
1111

12-
// Count the selected streams, including the ones hidden by the search: they
13-
// are still submitted with the form.
1412
refreshCounter () {
1513
const count = this.checkboxTargets.filter((checkbox) => checkbox.checked).length;
1614
this.counterTarget.textContent = count;
1715
}
18-
19-
filter () {
20-
const query = this.normalize(this.searchTarget.value);
21-
22-
const matchingOptions = this.optionTargets.filter((option) => {
23-
// Note the options are only hidden: a checked stream that is
24-
// filtered out must still be submitted with the form.
25-
const matching = this.normalize(option.dataset.name).includes(query);
26-
option.hidden = !matching;
27-
return matching;
28-
});
29-
30-
this.emptyTarget.hidden = matchingOptions.length > 0;
31-
32-
// The height of the options changed, so the scroller must recalculate
33-
// its shadows.
34-
this.dispatch('filtered');
35-
}
36-
37-
// Return a comparable version of the value, so searching for "eco" matches
38-
// a stream named "Écologie".
39-
//
40-
// The NFD normalization splits the accented characters into a base letter
41-
// followed by a combining mark ("é" becomes "e" + U+0301). The Diacritic
42-
// Unicode property matches these marks, which are then removed.
43-
//
44-
// The characters that have no canonical decomposition are left alone, so
45-
// "ø" or "ß" still have to be typed as is.
46-
normalize (value) {
47-
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').trim().toLowerCase();
48-
}
4916
}

src/views/collections/followers/edit.html.twig

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@
8080
{% if form.streams %}
8181
<div
8282
class="streams-selector flow flow--smaller"
83-
data-controller="streams-selector scroller"
83+
data-controller="streams-selector filter scroller"
8484
data-scroller-vertical-value="true"
8585
data-action="
8686
resize@window->scroller#refresh
87-
streams-selector:filtered->scroller#refresh
87+
filter:filtered->scroller#refresh
8888
"
8989
>
9090
{% if form.streams | length > 10 %}
@@ -94,10 +94,10 @@
9494
placeholder="{{ t('Filter the streams…') }}"
9595
aria-label="{{ t('Filter the streams') }}"
9696
autocomplete="off"
97-
data-streams-selector-target="search"
97+
data-filter-target="search"
9898
data-action="
99-
input->streams-selector#filter
100-
keydown.enter->streams-selector#filter:prevent
99+
input->filter#filter
100+
keydown.enter->filter#filter:prevent
101101
"
102102
>
103103
{% endif %}
@@ -126,7 +126,7 @@
126126
{% for stream in form.streams %}
127127
<div
128128
class="streams-selector__option"
129-
data-streams-selector-target="option"
129+
data-filter-target="item"
130130
data-name="{{ stream.name }}"
131131
>
132132
<input
@@ -174,7 +174,7 @@
174174
</button>
175175
</div>
176176

177-
<p class="text--center text--secondary" data-streams-selector-target="empty" hidden>
177+
<p class="text--center text--secondary" data-filter-target="empty" hidden>
178178
{{ t('No stream matches your search.') }}
179179
</p>
180180

0 commit comments

Comments
 (0)