@madgex/design-system 13.4.0 → 13.5.1
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.
- package/dist/assets/icons.json +1 -1
- package/dist/css/index.css +1 -1
- package/dist/js/components/mds-image-cropper-standalone.js +4 -0
- package/dist/js/image-cropper-BlqRRHAU.js +58 -0
- package/dist/js/index-fractal.js +2 -2
- package/dist/js/index.js +1 -1
- package/package.json +6 -4
- package/src/components/_macro-index.njk +1 -0
- package/src/components/accordion/README.md +9 -6
- package/src/components/image-cropper/README.md +80 -0
- package/src/components/image-cropper/helpers.js +229 -0
- package/src/components/image-cropper/helpers.spec.js +111 -0
- package/src/components/image-cropper/image-cropper-standalone.scss +2 -0
- package/src/components/image-cropper/image-cropper-touch-area.js +270 -0
- package/src/components/image-cropper/image-cropper.config.js +6 -0
- package/src/components/image-cropper/image-cropper.js +447 -0
- package/src/components/image-cropper/image-cropper.njk +99 -0
- package/src/components/image-cropper/image-cropper.scss +93 -0
- package/src/components/image-cropper/mds-image-cropper-standalone.js +39 -0
- package/src/components/inputs/_form-elements.scss +4 -0
- package/src/components/inputs/category-picker/README.md +45 -0
- package/src/components/inputs/category-picker/_macro.njk +3 -0
- package/src/components/inputs/category-picker/_template.njk +83 -0
- package/src/components/inputs/category-picker/category-picker.config.js +176 -0
- package/src/components/inputs/category-picker/category-picker.js +302 -0
- package/src/components/inputs/category-picker/category-picker.njk +81 -0
- package/src/components/inputs/category-picker/category-picker.scss +148 -0
- package/src/components/inputs/checkbox-list/README.md +1 -0
- package/src/components/inputs/checkbox-list/_template.njk +2 -1
- package/src/js/index-fractal.js +1 -0
- package/src/js/index.js +8 -0
- package/src/scss/components/__index.scss +2 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
## Category Picker (Tags with Combobox)
|
|
2
|
+
|
|
3
|
+
A tags-with-combobox control: an input-like wrapper displays selected items as removable pills. Clicking or focusing the input reveals a dropdown containing a checkbox-list. Typing 2+ characters filters the visible options.
|
|
4
|
+
|
|
5
|
+
### Parameters — Nunjucks
|
|
6
|
+
|
|
7
|
+
- `removeButtonAria`: Base aria-label for pill remove buttons. Defaults to `'remove category'`. The pill's path text is appended.
|
|
8
|
+
- `searchPlaceholder`: Placeholder text for the search input. Defaults to `'Search or select…'`.
|
|
9
|
+
- `checkboxList`: Passes parameters through to the checkbox-list component. See checkbox-list README.
|
|
10
|
+
- `i18n.selectedTermsLabel`: Visually hidden label for the pill list. Defaults to `'Selected terms:'`.
|
|
11
|
+
- `i18n.noResults`: Message shown when search filter finds no matches. Defaults to `'No matching options'`.
|
|
12
|
+
- `i18n.optionsSelectedSingular`: Announcement when 1 option is selected. Defaults to `'1 option selected'`.
|
|
13
|
+
- `i18n.optionsSelectedPlural`: Announcement when multiple options are selected. Use `{count}` placeholder. Defaults to `'{count} options selected'`.
|
|
14
|
+
- `i18n.optionsFoundSingular`: Announcement when 1 option matches the filter. Defaults to `'1 option found'`.
|
|
15
|
+
- `i18n.optionsFoundPlural`: Announcement when multiple options match the filter. Use `{count}` placeholder. Defaults to `'{count} options found'`.
|
|
16
|
+
|
|
17
|
+
### Behaviour
|
|
18
|
+
|
|
19
|
+
- **Click / focus input** → dropdown opens with checkbox-list
|
|
20
|
+
- **Check a checkbox** → pill appears in the input, search clears, dropdown stays open
|
|
21
|
+
- **Click pill ✕ / uncheck checkbox** → pill removed
|
|
22
|
+
- **Type 2+ characters** → options filtered by label text (case-insensitive); parent groups auto-expand to show matches; if a parent label matches, all its children are shown
|
|
23
|
+
- **Backspace on empty input** → removes last pill
|
|
24
|
+
- **Escape** → closes dropdown and clears search (works from search input or within dropdown)
|
|
25
|
+
- **ArrowDown** → moves focus from input into the first visible checkbox
|
|
26
|
+
- **Tab** → closes dropdown and moves to next form field
|
|
27
|
+
- **Click outside** → closes dropdown
|
|
28
|
+
|
|
29
|
+
### Accessibility
|
|
30
|
+
|
|
31
|
+
- Search input has `role="combobox"`, `aria-expanded`, `aria-controls`, `aria-autocomplete="list"`, `aria-haspopup="dialog"`
|
|
32
|
+
- Dropdown has `role="dialog"` with `aria-label` from the checkbox-list label
|
|
33
|
+
- Real `<input type="checkbox">` elements inside dropdown for native checked/unchecked announcements
|
|
34
|
+
- `aria-live="polite"` region announces filter result count and selection changes
|
|
35
|
+
- Pill remove buttons have `aria-label="remove category {path}"`
|
|
36
|
+
- Visually hidden "Selected terms:" label introduces the pill list for screen readers
|
|
37
|
+
- Pills are rendered as a list (`ul`/`li`) for semantic structure
|
|
38
|
+
|
|
39
|
+
### Progressive Enhancement
|
|
40
|
+
|
|
41
|
+
Without JS the checkbox-list renders normally (visible, no dropdown, no pills). The custom element upgrades the markup when JS is available.
|
|
42
|
+
|
|
43
|
+
### Note
|
|
44
|
+
|
|
45
|
+
The JS, especially for finding the input hierarchy, is highly dependent on checkbox-list and should be updated with it if any changes are made.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% from "../checkbox-list/_macro.njk" import MdsCheckboxList %}
|
|
2
|
+
{% from "../label/_macro.njk" import MdsInputLabel %}
|
|
3
|
+
{% from "../_message/_macro.njk" import MdsInputMessages %}
|
|
4
|
+
{% from "../../icons/_macro.njk" import MdsIcon %}
|
|
5
|
+
|
|
6
|
+
{%- if params.checkboxList -%}
|
|
7
|
+
{%- set pickerI18n = params.i18n or {} -%}
|
|
8
|
+
{%- set pickerId = params.checkboxList.id | default('category-picker') -%}
|
|
9
|
+
{%- set dropdownId = pickerId ~ '-dropdown' -%}
|
|
10
|
+
{%- set searchId = pickerId ~ '-search' -%}
|
|
11
|
+
|
|
12
|
+
{% if params.checkboxList.helpText %}
|
|
13
|
+
{% set helpTextId = [pickerId, '-help-text'] | join %}
|
|
14
|
+
{% endif %}
|
|
15
|
+
{% if params.checkboxList.validationError %}
|
|
16
|
+
{% set validationErrorId = [pickerId, '-validation-error'] | join %}
|
|
17
|
+
{% endif %}
|
|
18
|
+
{% if helpTextId or validationErrorId or params.checkboxList.describedBy %}
|
|
19
|
+
{% set ariaDescribedBy = [validationErrorId, helpTextId, params.checkboxList.describedBy] | join(' ') | trim %}
|
|
20
|
+
{% endif %}
|
|
21
|
+
|
|
22
|
+
<div class="mds-form-element{% if params.checkboxList.state %} mds-form-element--{{ params.checkboxList.state }}{% endif %}">
|
|
23
|
+
{{ MdsInputLabel({
|
|
24
|
+
labelText: params.checkboxList.labelText,
|
|
25
|
+
hideLabel: params.checkboxList.hideLabel,
|
|
26
|
+
inputId: searchId,
|
|
27
|
+
optional: params.checkboxList.optional,
|
|
28
|
+
tooltipMessage: params.checkboxList.tooltipMessage,
|
|
29
|
+
i18n: params.checkboxList.i18n
|
|
30
|
+
}) }}
|
|
31
|
+
{{ MdsInputMessages({
|
|
32
|
+
id: pickerId,
|
|
33
|
+
helpTextId: helpTextId,
|
|
34
|
+
helpText: params.checkboxList.helpText,
|
|
35
|
+
validationErrorId: validationErrorId,
|
|
36
|
+
validationError: params.checkboxList.validationError
|
|
37
|
+
}) }}
|
|
38
|
+
|
|
39
|
+
<mds-category-picker i18n="{{ pickerI18n | dump }}">
|
|
40
|
+
<span class="mds-category-picker__templates">{# Removed with JS on load #}
|
|
41
|
+
<ul><li class="mds-category-picker__pill mds-border"></li></ul>
|
|
42
|
+
<button class="mds-category-picker__clear-pill" type="button" aria-label="{{ params.removeButtonAria | default('remove category') }}">{{- MdsIcon({ iconName: 'close' }) -}}</button>
|
|
43
|
+
</span>
|
|
44
|
+
|
|
45
|
+
<div class="mds-category-picker__input-wrapper">
|
|
46
|
+
<span class="mds-visually-hidden">{{ pickerI18n.selectedTermsLabel | default('Selected terms:') }}</span>
|
|
47
|
+
<ul class="mds-category-picker__pillbox" role="list">{# Pills added with JS #}</ul>
|
|
48
|
+
<input
|
|
49
|
+
class="mds-category-picker__search"
|
|
50
|
+
id="{{ searchId }}"
|
|
51
|
+
type="text"
|
|
52
|
+
role="combobox"
|
|
53
|
+
autocomplete="off"
|
|
54
|
+
aria-expanded="false"
|
|
55
|
+
aria-controls="{{ dropdownId }}"
|
|
56
|
+
aria-autocomplete="list"
|
|
57
|
+
aria-haspopup="dialog"
|
|
58
|
+
placeholder="{{ params.searchPlaceholder | default('Search or select…') }}"
|
|
59
|
+
{% if ariaDescribedBy %}aria-describedby="{{ ariaDescribedBy }}"{% endif %}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div id="{{ dropdownId }}" class="mds-category-picker__dropdown" role="dialog" aria-label="{{ params.checkboxList.labelText | default('Options') }}">
|
|
64
|
+
{# Hide the checkbox-list's built-in legend since we render the label above #}
|
|
65
|
+
{{- MdsCheckboxList({
|
|
66
|
+
labelText: params.checkboxList.labelText,
|
|
67
|
+
hideLabel: true,
|
|
68
|
+
id: params.checkboxList.id,
|
|
69
|
+
name: params.checkboxList.name,
|
|
70
|
+
options: params.checkboxList.options,
|
|
71
|
+
selectedOptions: params.checkboxList.selectedOptions,
|
|
72
|
+
disabled: params.checkboxList.disabled,
|
|
73
|
+
containerClasses: params.checkboxList.containerClasses,
|
|
74
|
+
i18n: params.checkboxList.i18n,
|
|
75
|
+
describedBy: params.checkboxList.describedBy
|
|
76
|
+
}) -}}
|
|
77
|
+
<div class="mds-category-picker__no-results">{{ pickerI18n.noResults | default('No matching options') }}</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="mds-category-picker__live-region mds-visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
|
81
|
+
</mds-category-picker>
|
|
82
|
+
</div>
|
|
83
|
+
{%- endif -%}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
const options = [
|
|
2
|
+
{
|
|
3
|
+
value: 101,
|
|
4
|
+
labelText: 'Arts & heritage',
|
|
5
|
+
options: [
|
|
6
|
+
{ value: 600102, labelText: 'Artist agency & management', options: [] },
|
|
7
|
+
{ value: 600104, labelText: 'Arts education & training', options: [] },
|
|
8
|
+
{
|
|
9
|
+
value: 123,
|
|
10
|
+
labelText: 'Art sales',
|
|
11
|
+
options: [
|
|
12
|
+
{ value: 12301, labelText: 'Art auction', options: [] },
|
|
13
|
+
{ value: 12302, labelText: 'Art dealer', options: [] },
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
{ value: 104, labelText: 'Dance', options: [] },
|
|
17
|
+
{ value: 600105, labelText: 'Events', options: [] },
|
|
18
|
+
{ value: 600106, labelText: 'Festival', options: [] },
|
|
19
|
+
{ value: 600107, labelText: 'Funding body', options: [] },
|
|
20
|
+
{ value: 107, labelText: 'Heritage', options: [] },
|
|
21
|
+
{ value: 600109, labelText: 'Librarian & archivist', options: [] },
|
|
22
|
+
{ value: 108, labelText: 'Museums & galleries', options: [] },
|
|
23
|
+
{ value: 109, labelText: 'Music', options: [] },
|
|
24
|
+
{ value: 600110, labelText: 'Press, publicity & PR', options: [] },
|
|
25
|
+
{ value: 600111, labelText: 'Promoter', options: [] },
|
|
26
|
+
{ value: 110, labelText: 'Theatre', options: [] },
|
|
27
|
+
{ value: 600113, labelText: 'Venue', options: [] },
|
|
28
|
+
{ value: 106, labelText: 'Visual arts', options: [] },
|
|
29
|
+
{ value: 600114, labelText: 'Writing & literature', options: [] },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
value: 111,
|
|
34
|
+
labelText: 'Charities',
|
|
35
|
+
options: [
|
|
36
|
+
{ value: 112, labelText: 'Advice', options: [] },
|
|
37
|
+
{ value: 113, labelText: 'Advocacy', options: [] },
|
|
38
|
+
{ value: 114, labelText: 'Animal', options: [] },
|
|
39
|
+
{ value: 600116, labelText: 'Arts & culture', options: [] },
|
|
40
|
+
{ value: 600117, labelText: 'Charity & volunteering support', options: [] },
|
|
41
|
+
{ value: 115, labelText: 'Children', options: [] },
|
|
42
|
+
{ value: 600118, labelText: 'Community development', options: [] },
|
|
43
|
+
{ value: 600119, labelText: 'Crime', options: [] },
|
|
44
|
+
{ value: 116, labelText: 'Disability', options: [] },
|
|
45
|
+
{ value: 117, labelText: 'Environment', options: [] },
|
|
46
|
+
{ value: 118, labelText: 'Equality', options: [] },
|
|
47
|
+
{ value: 600120, labelText: 'Faith-based', options: [] },
|
|
48
|
+
{ value: 119, labelText: 'Fundraising', options: [] },
|
|
49
|
+
{ value: 121, labelText: 'Health', options: [] },
|
|
50
|
+
{ value: 600122, labelText: 'Housing & homelessness', options: [] },
|
|
51
|
+
{ value: 348, labelText: 'Human rights', options: [] },
|
|
52
|
+
{ value: 122, labelText: 'International', options: [] },
|
|
53
|
+
{ value: 300, labelText: 'Mental health', options: [] },
|
|
54
|
+
{ value: 600123, labelText: 'Policy & research', options: [] },
|
|
55
|
+
{ value: 600124, labelText: 'Poverty relief', options: [] },
|
|
56
|
+
{ value: 600125, labelText: 'Social welfare', options: [] },
|
|
57
|
+
{ value: 349, labelText: 'Trustee', options: [] },
|
|
58
|
+
{ value: 600126, labelText: 'Volunteer management', options: [] },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{ value: 124, labelText: 'Construction', options: [] },
|
|
62
|
+
{ value: 142, labelText: 'Design', options: [] },
|
|
63
|
+
{
|
|
64
|
+
value: 137,
|
|
65
|
+
labelText: 'Engineering',
|
|
66
|
+
options: [
|
|
67
|
+
{ value: 138, labelText: 'General', options: [] },
|
|
68
|
+
{ value: 139, labelText: 'Manufacturing', options: [] },
|
|
69
|
+
{ value: 140, labelText: 'Utility', options: [] },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
value: 141,
|
|
74
|
+
labelText: 'Environment',
|
|
75
|
+
options: [
|
|
76
|
+
{ value: 600131, labelText: 'Agriculture & land management', options: [] },
|
|
77
|
+
{ value: 600132, labelText: 'Auditing', options: [] },
|
|
78
|
+
{ value: 147, labelText: 'Built environment', options: [] },
|
|
79
|
+
{ value: 600136, labelText: 'Climate change', options: [] },
|
|
80
|
+
{ value: 600137, labelText: 'Corporate social responsibility', options: [] },
|
|
81
|
+
{ value: 600138, labelText: 'Ecology & conservation', options: [] },
|
|
82
|
+
{ value: 600139, labelText: 'Energy efficiency & renewable energy', options: [] },
|
|
83
|
+
{ value: 600140, labelText: 'Energy management', options: [] },
|
|
84
|
+
{ value: 143, labelText: 'Environmental education', options: [] },
|
|
85
|
+
{ value: 600141, labelText: 'Environmental management', options: [] },
|
|
86
|
+
{ value: 145, labelText: 'Green & clean technology', options: [] },
|
|
87
|
+
{ value: 600143, labelText: 'International development', options: [] },
|
|
88
|
+
{ value: 146, labelText: 'Policy, legislation & strategy', options: [] },
|
|
89
|
+
{ value: 148, labelText: 'Recycling & waste management', options: [] },
|
|
90
|
+
{ value: 600145, labelText: 'Supply chain', options: [] },
|
|
91
|
+
{ value: 600146, labelText: 'Sustainability', options: [] },
|
|
92
|
+
{ value: 600147, labelText: 'Water & wastewater', options: [] },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const defaultI18n = {
|
|
98
|
+
selectedTermsLabel: 'Selected terms:',
|
|
99
|
+
noResults: 'No matching options',
|
|
100
|
+
optionsSelectedSingular: '1 option selected',
|
|
101
|
+
optionsSelectedPlural: '{count} options selected',
|
|
102
|
+
optionsFoundSingular: '1 option found',
|
|
103
|
+
optionsFoundPlural: '{count} options found',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const variant = (name, context, { note } = {}) => ({
|
|
107
|
+
name,
|
|
108
|
+
context: {
|
|
109
|
+
sectionTitle: `<h2>${name}</h2>${note ? `<p><em>${note}</em></p>` : ''}`,
|
|
110
|
+
i18n: defaultI18n,
|
|
111
|
+
...context,
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
title: 'Category Picker',
|
|
117
|
+
status: 'ready',
|
|
118
|
+
context: {
|
|
119
|
+
sectionTitle: '<h2>Default</h2>',
|
|
120
|
+
searchPlaceholder: 'Search or select…',
|
|
121
|
+
i18n: defaultI18n,
|
|
122
|
+
checkboxList: {
|
|
123
|
+
labelText: 'Job sectors',
|
|
124
|
+
id: 'category-picker',
|
|
125
|
+
selectedOptions: [142, 12302],
|
|
126
|
+
i18n: {
|
|
127
|
+
accordionTrigger: 'Please find more options under {label}',
|
|
128
|
+
},
|
|
129
|
+
options,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
variants: [
|
|
133
|
+
variant('With error state', {
|
|
134
|
+
checkboxList: {
|
|
135
|
+
id: 'category-picker-error',
|
|
136
|
+
state: 'error',
|
|
137
|
+
validationError: 'This field is required',
|
|
138
|
+
selectedOptions: [142, 12302],
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
variant(
|
|
142
|
+
'No preselection',
|
|
143
|
+
{
|
|
144
|
+
checkboxList: {
|
|
145
|
+
id: 'category-picker-empty',
|
|
146
|
+
selectedOptions: [],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{ note: 'Empty state — click input to reveal dropdown.' },
|
|
150
|
+
),
|
|
151
|
+
variant(
|
|
152
|
+
'i18n example',
|
|
153
|
+
{
|
|
154
|
+
searchPlaceholder: 'Rechercher ou sélectionner…',
|
|
155
|
+
i18n: {
|
|
156
|
+
selectedTermsLabel: 'Termes sélectionnés :',
|
|
157
|
+
noResults: 'Aucune option correspondante',
|
|
158
|
+
optionsSelectedSingular: '1 option sélectionnée',
|
|
159
|
+
optionsSelectedPlural: '{count} options sélectionnées',
|
|
160
|
+
optionsFoundSingular: '1 option trouvée',
|
|
161
|
+
optionsFoundPlural: '{count} options trouvées',
|
|
162
|
+
},
|
|
163
|
+
checkboxList: {
|
|
164
|
+
labelText: "Secteurs d'activité",
|
|
165
|
+
id: 'category-picker-i18n',
|
|
166
|
+
selectedOptions: [142, 12302],
|
|
167
|
+
i18n: {
|
|
168
|
+
accordionTrigger: "Voir plus d'options sous {label}",
|
|
169
|
+
},
|
|
170
|
+
options,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{ note: 'Example with French translations for all i18n strings.' },
|
|
174
|
+
),
|
|
175
|
+
],
|
|
176
|
+
};
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
const ITEM = '.mds-form-check';
|
|
2
|
+
const CHECKBOX = '.mds-form-check__input';
|
|
3
|
+
const LABEL = '.mds-form-check__label';
|
|
4
|
+
const NESTED = '.mds-form-check__nested-container';
|
|
5
|
+
const PILL = '.mds-category-picker__pill';
|
|
6
|
+
const CLEAR = '.mds-category-picker__clear-pill';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_I18N = {
|
|
9
|
+
optionsSelectedSingular: '1 option selected',
|
|
10
|
+
optionsSelectedPlural: '{count} options selected',
|
|
11
|
+
optionsFoundSingular: '1 option found',
|
|
12
|
+
optionsFoundPlural: '{count} options found',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export class MdsCategoryPicker extends HTMLElement {
|
|
16
|
+
#controller;
|
|
17
|
+
#pillbox;
|
|
18
|
+
#pillTemplate;
|
|
19
|
+
#buttonTemplate;
|
|
20
|
+
#searchInput;
|
|
21
|
+
#inputWrapper;
|
|
22
|
+
#dropdown;
|
|
23
|
+
#liveRegion;
|
|
24
|
+
#noResults;
|
|
25
|
+
#i18n;
|
|
26
|
+
#buttonToCheckbox = new WeakMap();
|
|
27
|
+
#isOpen = false;
|
|
28
|
+
|
|
29
|
+
connectedCallback() {
|
|
30
|
+
const templates = this.querySelector('.mds-category-picker__templates');
|
|
31
|
+
this.#pillbox = this.querySelector('.mds-category-picker__pillbox');
|
|
32
|
+
this.#searchInput = this.querySelector('.mds-category-picker__search');
|
|
33
|
+
this.#inputWrapper = this.querySelector('.mds-category-picker__input-wrapper');
|
|
34
|
+
this.#dropdown = this.querySelector('.mds-category-picker__dropdown');
|
|
35
|
+
this.#liveRegion = this.querySelector('.mds-category-picker__live-region');
|
|
36
|
+
this.#noResults = this.querySelector('.mds-category-picker__no-results');
|
|
37
|
+
|
|
38
|
+
if (!templates || !this.#pillbox || !this.#searchInput || !this.#dropdown) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
this.#i18n = { ...DEFAULT_I18N, ...JSON.parse(this.getAttribute('i18n') || '{}') };
|
|
42
|
+
} catch {
|
|
43
|
+
this.#i18n = DEFAULT_I18N;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.#pillTemplate = templates.querySelector(PILL);
|
|
47
|
+
this.#buttonTemplate = templates.querySelector(CLEAR);
|
|
48
|
+
if (!this.#pillTemplate || !this.#buttonTemplate) return;
|
|
49
|
+
templates.remove();
|
|
50
|
+
|
|
51
|
+
this.#controller = new AbortController();
|
|
52
|
+
const o = { signal: this.#controller.signal };
|
|
53
|
+
|
|
54
|
+
this.#pillbox.addEventListener('click', this.#onRemovePill, o);
|
|
55
|
+
this.#dropdown.addEventListener('change', this.#onCheckboxChange, o);
|
|
56
|
+
this.#searchInput.addEventListener('blur', this.#onSearchBlur, o);
|
|
57
|
+
this.#searchInput.addEventListener('input', this.#onSearchInput, o);
|
|
58
|
+
this.#searchInput.addEventListener('keydown', this.#onSearchKeydown, o);
|
|
59
|
+
this.#inputWrapper.addEventListener('click', () => this.#searchInput.focus(), o);
|
|
60
|
+
this.#searchInput.addEventListener('focus', () => this.#open(), o);
|
|
61
|
+
this.#dropdown.addEventListener(
|
|
62
|
+
'mousedown',
|
|
63
|
+
(e) => {
|
|
64
|
+
if (e.target !== this.#searchInput) e.preventDefault();
|
|
65
|
+
},
|
|
66
|
+
o,
|
|
67
|
+
);
|
|
68
|
+
this.#dropdown.addEventListener(
|
|
69
|
+
'focusout',
|
|
70
|
+
(e) => {
|
|
71
|
+
if (!e.relatedTarget || !this.contains(e.relatedTarget)) this.#close();
|
|
72
|
+
},
|
|
73
|
+
o,
|
|
74
|
+
);
|
|
75
|
+
this.#dropdown.addEventListener(
|
|
76
|
+
'keydown',
|
|
77
|
+
(e) => {
|
|
78
|
+
if (e.key === 'Escape') {
|
|
79
|
+
this.#close();
|
|
80
|
+
this.#searchInput.focus();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
o,
|
|
84
|
+
);
|
|
85
|
+
document.addEventListener(
|
|
86
|
+
'click',
|
|
87
|
+
(e) => {
|
|
88
|
+
if (!e.composedPath().includes(this)) this.#close();
|
|
89
|
+
},
|
|
90
|
+
o,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
this.#syncSelection();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
disconnectedCallback() {
|
|
97
|
+
this.#controller?.abort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Open / Close ---
|
|
101
|
+
|
|
102
|
+
#open() {
|
|
103
|
+
if (this.#isOpen) return;
|
|
104
|
+
this.#isOpen = true;
|
|
105
|
+
this.classList.add('mds-category-picker--open');
|
|
106
|
+
this.#searchInput.setAttribute('aria-expanded', 'true');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#close() {
|
|
110
|
+
if (!this.#isOpen) return;
|
|
111
|
+
this.#isOpen = false;
|
|
112
|
+
this.classList.remove('mds-category-picker--open');
|
|
113
|
+
this.#searchInput.setAttribute('aria-expanded', 'false');
|
|
114
|
+
this.#searchInput.value = '';
|
|
115
|
+
this.#resetFilter();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#onSearchBlur = (e) => {
|
|
119
|
+
const related = e.relatedTarget;
|
|
120
|
+
if (related && (this.#dropdown.contains(related) || this.#inputWrapper.contains(related))) return;
|
|
121
|
+
this.#close();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// --- Selection ---
|
|
125
|
+
|
|
126
|
+
#syncSelection = () => {
|
|
127
|
+
this.#pillbox.replaceChildren();
|
|
128
|
+
this.#buttonToCheckbox = new WeakMap();
|
|
129
|
+
const cls = 'mds-form-check--has-selection';
|
|
130
|
+
|
|
131
|
+
for (const item of this.#dropdown.querySelectorAll(ITEM)) item.classList.remove(cls);
|
|
132
|
+
|
|
133
|
+
for (const cb of this.#dropdown.querySelectorAll(`${CHECKBOX}:checked`)) {
|
|
134
|
+
this.#addPill(cb);
|
|
135
|
+
|
|
136
|
+
let el = cb.closest(ITEM)?.parentElement;
|
|
137
|
+
while (el && el !== this.#dropdown) {
|
|
138
|
+
if (el.matches(ITEM)) el.classList.add(cls);
|
|
139
|
+
el = el.parentElement;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
#addPill(checkbox) {
|
|
145
|
+
const path = checkbox.dataset.path;
|
|
146
|
+
if (!path) return;
|
|
147
|
+
|
|
148
|
+
const pill = this.#pillTemplate.cloneNode(true);
|
|
149
|
+
const button = this.#buttonTemplate.cloneNode(true);
|
|
150
|
+
button.setAttribute('aria-label', `${button.getAttribute('aria-label')} ${path}`);
|
|
151
|
+
const textSpan = document.createElement('span');
|
|
152
|
+
textSpan.className = 'mds-category-picker__pill-text';
|
|
153
|
+
textSpan.textContent = path;
|
|
154
|
+
pill.appendChild(textSpan);
|
|
155
|
+
pill.appendChild(button);
|
|
156
|
+
this.#pillbox.appendChild(pill);
|
|
157
|
+
this.#buttonToCheckbox.set(button, checkbox);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#onRemovePill = ({ target }) => {
|
|
161
|
+
const cb = this.#buttonToCheckbox.get(target.closest(CLEAR));
|
|
162
|
+
if (cb) {
|
|
163
|
+
cb.checked = false;
|
|
164
|
+
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
#onCheckboxChange = () => {
|
|
169
|
+
this.#syncSelection();
|
|
170
|
+
this.#searchInput.value = '';
|
|
171
|
+
this.#resetFilter();
|
|
172
|
+
const count = this.#dropdown.querySelectorAll(`${CHECKBOX}:checked`).length;
|
|
173
|
+
this.#announce(this.#getAnnouncementMessage('optionsSelectedSingular', 'optionsSelectedPlural', count));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// --- Search / Filter ---
|
|
177
|
+
|
|
178
|
+
#onSearchInput = () => {
|
|
179
|
+
const query = this.#searchInput.value.trim();
|
|
180
|
+
|
|
181
|
+
if (query.length >= 2) {
|
|
182
|
+
this.#filterOptions(query);
|
|
183
|
+
} else {
|
|
184
|
+
this.#resetFilter();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
#filterOptions(query) {
|
|
189
|
+
const q = query.toLowerCase();
|
|
190
|
+
const items = this.#dropdown.querySelectorAll(ITEM);
|
|
191
|
+
const visibleParents = new Set();
|
|
192
|
+
const directMatchParents = new Set();
|
|
193
|
+
|
|
194
|
+
for (const item of items) {
|
|
195
|
+
const text = item.querySelector(LABEL)?.textContent.trim().toLowerCase() || '';
|
|
196
|
+
if (!text) continue;
|
|
197
|
+
|
|
198
|
+
if (item.querySelector(NESTED)) {
|
|
199
|
+
if (text.includes(q)) {
|
|
200
|
+
visibleParents.add(item);
|
|
201
|
+
directMatchParents.add(item);
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
item.hidden = !text.includes(q);
|
|
207
|
+
if (!item.hidden) {
|
|
208
|
+
let el = item.parentElement;
|
|
209
|
+
while (el && el !== this.#dropdown) {
|
|
210
|
+
if (el.matches(ITEM)) visibleParents.add(el);
|
|
211
|
+
el = el.parentElement;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const item of items) {
|
|
217
|
+
const nested = item.querySelector(NESTED);
|
|
218
|
+
if (!nested) continue;
|
|
219
|
+
|
|
220
|
+
if (visibleParents.has(item)) {
|
|
221
|
+
item.hidden = false;
|
|
222
|
+
item.querySelector('.mds-checkbox-accordion__button')?.setAttribute('aria-expanded', 'true');
|
|
223
|
+
if (directMatchParents.has(item)) {
|
|
224
|
+
for (const child of nested.querySelectorAll(ITEM)) child.hidden = false;
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
item.hidden = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let count = 0;
|
|
232
|
+
for (const item of items) if (!item.hidden) count++;
|
|
233
|
+
|
|
234
|
+
this.#showNoResults(count === 0);
|
|
235
|
+
this.#announce(this.#getAnnouncementMessage('optionsFoundSingular', 'optionsFoundPlural', count));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#resetFilter() {
|
|
239
|
+
for (const item of this.#dropdown.querySelectorAll(ITEM)) item.hidden = false;
|
|
240
|
+
this.#showNoResults(false);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#showNoResults(show) {
|
|
244
|
+
this.#noResults?.classList.toggle('mds-category-picker__no-results--visible', show);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Keyboard ---
|
|
248
|
+
|
|
249
|
+
#onSearchKeydown = (e) => {
|
|
250
|
+
switch (e.key) {
|
|
251
|
+
case 'Escape':
|
|
252
|
+
this.#close();
|
|
253
|
+
this.#searchInput.blur();
|
|
254
|
+
break;
|
|
255
|
+
case 'ArrowDown': {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
if (!this.#isOpen) this.#open();
|
|
258
|
+
for (const item of this.#dropdown.querySelectorAll(ITEM)) {
|
|
259
|
+
if (item.hidden) continue;
|
|
260
|
+
const cb = item.querySelector(CHECKBOX);
|
|
261
|
+
if (cb) {
|
|
262
|
+
cb.focus();
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
case 'Backspace':
|
|
269
|
+
if (this.#searchInput.value === '') {
|
|
270
|
+
const pills = this.#pillbox.querySelectorAll(PILL);
|
|
271
|
+
const lastPill = pills[pills.length - 1];
|
|
272
|
+
if (!lastPill) break;
|
|
273
|
+
const cb = this.#buttonToCheckbox.get(lastPill.querySelector(CLEAR));
|
|
274
|
+
if (cb) {
|
|
275
|
+
cb.checked = false;
|
|
276
|
+
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
case 'Tab':
|
|
281
|
+
this.#close();
|
|
282
|
+
break;
|
|
283
|
+
default:
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// --- Announcements ---
|
|
289
|
+
|
|
290
|
+
#getAnnouncementMessage(singularKey, pluralKey, count) {
|
|
291
|
+
if (count === 1) return this.#i18n[singularKey];
|
|
292
|
+
return this.#i18n[pluralKey].replace('{count}', String(count));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#announce(message) {
|
|
296
|
+
if (!this.#liveRegion) return;
|
|
297
|
+
this.#liveRegion.textContent = '';
|
|
298
|
+
requestAnimationFrame(() => {
|
|
299
|
+
this.#liveRegion.textContent = message;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|