@madgex/design-system 13.5.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.
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ {% from "./inputs/checkbox-list/_macro.njk" import MdsCheckboxList %}
2
+ {% from "./inputs/label/_macro.njk" import MdsInputLabel %}
3
+ {% from "./inputs/_message/_macro.njk" import MdsInputMessages %}
4
+ {% from "./icons/_macro.njk" import MdsIcon %}
5
+
6
+ {%- set pickerId = checkboxList.id | default('category-picker') -%}
7
+ {%- set dropdownId = pickerId ~ '-dropdown' -%}
8
+ {%- set searchId = pickerId ~ '-search' -%}
9
+
10
+ {% if checkboxList.helpText %}
11
+ {% set helpTextId = [pickerId, '-help-text'] | join %}
12
+ {% endif %}
13
+ {% if checkboxList.validationError %}
14
+ {% set validationErrorId = [pickerId, '-validation-error'] | join %}
15
+ {% endif %}
16
+ {% if helpTextId or validationErrorId or checkboxList.describedBy %}
17
+ {% set ariaDescribedBy = [validationErrorId, helpTextId, checkboxList.describedBy] | join(' ') | trim %}
18
+ {% endif %}
19
+
20
+ {{ sectionTitle | safe }}
21
+ <div class="mds-border mds-padding-b3 mds-margin-bottom-b12">
22
+ <div class="mds-form-element{% if checkboxList.state %} mds-form-element--{{ checkboxList.state }}{% endif %}">
23
+ {{ MdsInputLabel({
24
+ labelText: checkboxList.labelText,
25
+ hideLabel: checkboxList.hideLabel,
26
+ inputId: searchId,
27
+ optional: checkboxList.optional,
28
+ i18n: checkboxList.i18n
29
+ }) }}
30
+ {{ MdsInputMessages({
31
+ id: pickerId,
32
+ helpTextId: helpTextId,
33
+ helpText: checkboxList.helpText,
34
+ validationErrorId: validationErrorId,
35
+ validationError: checkboxList.validationError
36
+ }) }}
37
+
38
+ <mds-category-picker i18n="{{ (i18n or {}) | dump }}">
39
+ <span class="mds-category-picker__templates">{# Removed with JS on load #}
40
+ <ul><li class="mds-category-picker__pill mds-border"></li></ul>
41
+ <button class="mds-category-picker__clear-pill" type="button" aria-label="{{ removeButtonAria | default('remove category') }}">{{- MdsIcon({ iconName: 'close' }) -}}</button>
42
+ </span>
43
+
44
+ <div class="mds-category-picker__input-wrapper">
45
+ <span class="mds-visually-hidden">{{ (i18n or {}).selectedTermsLabel | default('Selected terms:') }}</span>
46
+ <ul class="mds-category-picker__pillbox" role="list">{# Pills added with JS #}</ul>
47
+ <input
48
+ class="mds-category-picker__search"
49
+ id="{{ searchId }}"
50
+ type="text"
51
+ role="combobox"
52
+ autocomplete="off"
53
+ aria-expanded="false"
54
+ aria-controls="{{ dropdownId }}"
55
+ aria-autocomplete="list"
56
+ aria-haspopup="dialog"
57
+ placeholder="{{ searchPlaceholder | default('Search or select…') }}"
58
+ {% if ariaDescribedBy %}aria-describedby="{{ ariaDescribedBy }}"{% endif %}
59
+ />
60
+ </div>
61
+
62
+ <div id="{{ dropdownId }}" class="mds-category-picker__dropdown" role="dialog" aria-label="{{ checkboxList.labelText | default('Options') }}">
63
+ {{ MdsCheckboxList({
64
+ labelText: checkboxList.labelText,
65
+ hideLabel: true,
66
+ id: checkboxList.id,
67
+ name: checkboxList.name,
68
+ options: checkboxList.options,
69
+ selectedOptions: checkboxList.selectedOptions,
70
+ disabled: checkboxList.disabled,
71
+ containerClasses: checkboxList.containerClasses,
72
+ i18n: checkboxList.i18n,
73
+ describedBy: checkboxList.describedBy
74
+ }) }}
75
+ <div class="mds-category-picker__no-results">{{ (i18n or {}).noResults | default('No matching options') }}</div>
76
+ </div>
77
+
78
+ <div class="mds-category-picker__live-region mds-visually-hidden" aria-live="polite" aria-atomic="true"></div>
79
+ </mds-category-picker>
80
+ </div>
81
+ </div>
@@ -0,0 +1,148 @@
1
+ mds-category-picker {
2
+ display: block;
3
+ position: relative;
4
+
5
+ // Author styles on .mds-form-check override the UA [hidden] rule.
6
+ // Force display:none so filtering works inside the checkbox-list.
7
+ [hidden] {
8
+ display: none !important;
9
+ }
10
+
11
+ .mds-category-picker__templates {
12
+ display: none;
13
+ }
14
+
15
+ // --- Input wrapper (looks like a text input) ---
16
+ .mds-category-picker__input-wrapper {
17
+ @extend .mds-form-control;
18
+ display: flex;
19
+ flex-wrap: wrap;
20
+ align-items: center;
21
+ gap: $constant-size-baseline;
22
+ padding: $constant-size-baseline ($constant-size-baseline * 2);
23
+ cursor: text;
24
+ min-height: $constant-size-baseline * 12; // match standard input height
25
+ }
26
+
27
+ // Pillbox becomes transparent to flex layout — pills are direct flex items
28
+ .mds-category-picker__pillbox {
29
+ display: contents;
30
+ list-style: none;
31
+ margin: 0;
32
+ padding: 0;
33
+ }
34
+
35
+ // --- Search input inside wrapper ---
36
+ .mds-category-picker__search {
37
+ flex: 1;
38
+ min-width: 80px;
39
+ border: 0;
40
+ outline: 0;
41
+ padding: $constant-size-baseline ($constant-size-baseline * 2);
42
+ font-size: inherit;
43
+ line-height: inherit;
44
+ background: transparent;
45
+ appearance: none;
46
+ }
47
+
48
+ // --- Pill tags inside input ---
49
+ .mds-category-picker__pill {
50
+ font-size: var(--mds-font-type-s-1-size);
51
+ display: inline-flex;
52
+ align-items: center;
53
+ gap: 0;
54
+ border-radius: $border-radius;
55
+ padding: $constant-size-baseline ($constant-size-baseline * 2);
56
+ max-width: 100%;
57
+ min-width: 0;
58
+ background-color: $constant-color-neutral-lightest;
59
+ }
60
+
61
+ .mds-category-picker__pill-text {
62
+ flex: 1 1 0;
63
+ min-width: 0;
64
+ overflow: hidden;
65
+ text-overflow: ellipsis;
66
+ white-space: nowrap;
67
+ }
68
+
69
+ .mds-category-picker__clear-pill {
70
+ background: none;
71
+ border: none;
72
+ padding: 0 0 0 ($constant-size-baseline);
73
+ margin: 0;
74
+ cursor: pointer;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ flex-shrink: 0;
78
+
79
+ .mds-icon {
80
+ top: 0;
81
+ }
82
+
83
+ &:focus {
84
+ @include inputFocusStyle();
85
+ border-radius: $border-radius;
86
+ }
87
+ }
88
+
89
+ // --- Dropdown panel ---
90
+ .mds-category-picker__dropdown {
91
+ display: none;
92
+ position: absolute;
93
+ left: 0;
94
+ right: 0;
95
+ top: 100%;
96
+ max-height: 300px;
97
+ overflow-y: auto;
98
+ border: $input-border;
99
+ border-top: none;
100
+ border-radius: 0 0 $border-radius $border-radius;
101
+ background-color: #fff;
102
+ @include z-index();
103
+ }
104
+
105
+ &.mds-category-picker--open .mds-category-picker__dropdown {
106
+ display: block;
107
+ }
108
+
109
+ // Remove fixed height constraint from checkbox container when inside dropdown
110
+ .mds-category-picker__dropdown .mds-form-check-container--border {
111
+ border: 0;
112
+ border-radius: 0;
113
+ max-height: none;
114
+ min-height: 0;
115
+ overflow: visible;
116
+ }
117
+
118
+ // Bold selected items and parent labels with checked children
119
+ .mds-form-check--has-selection > .mds-form-check__label,
120
+ .mds-form-check__input:checked + .mds-form-check__label {
121
+ font-weight: bold;
122
+ }
123
+
124
+ // --- No results message ---
125
+ .mds-category-picker__no-results {
126
+ display: none;
127
+ padding: ($constant-size-baseline * 3);
128
+ text-align: center;
129
+ color: $constant-color-neutral-base;
130
+
131
+ &--visible {
132
+ display: block;
133
+ @extend .mds-font-s-1;
134
+ }
135
+ }
136
+
137
+ // --- No-JS fallback: show checkbox list normally ---
138
+ html:not(.js) & .mds-category-picker__input-wrapper {
139
+ display: none;
140
+ }
141
+
142
+ html:not(.js) & .mds-category-picker__dropdown {
143
+ display: block;
144
+ position: static;
145
+ border: 0;
146
+ max-height: none;
147
+ }
148
+ }
@@ -12,6 +12,7 @@
12
12
  - `describedBy`: adding aria-describeby to the fieldset (if multiple checkboxes) or the input field if only one checkbox - **optional**
13
13
  - `options`: This is an array of objects containing the parameters for each option: `labelText`, `value`, `id`, `classes`
14
14
  - `selectedOptions`: Preselected checkboxes - Array or comma-separated string **optional**
15
+ - `containerClasses`: Extra classes to add to the scrollable checkbox container (e.g. `mds-form-check-container--border--tall` for a taller list) **optional**
15
16
  - `i18n`: Text to translate/customise (object) **optional**
16
17
 
17
18
  ```
@@ -130,7 +130,8 @@
130
130
  validationErrorId: validationErrorId,
131
131
  validationError: params.validationError
132
132
  }) }}
133
- <div class="mds-form-check-container{% if hasBorder %} mds-form-check-container--border{% endif %}">
133
+
134
+ <div class="mds-form-check-container{% if hasBorder %} mds-form-check-container--border{% endif %}{% if params.containerClasses %} {{ params.containerClasses }}{% endif %}">
134
135
  {{ createCheckboxes({
135
136
  options: params.options,
136
137
  selectedOptions: params.selectedOptions,
package/src/js/index.js CHANGED
@@ -14,6 +14,7 @@ import { MdsTimeoutDialog } from '../components/timeout-dialog/timeout-dialog';
14
14
  import { MdsCardLink } from '../components/card/card-link';
15
15
  import { MdsConditionalSection } from '../components/conditional-section/conditional-section';
16
16
  import { MdsImageCropper } from '../components/image-cropper/image-cropper';
17
+ import { MdsCategoryPicker } from '../components/inputs/category-picker/category-picker';
17
18
 
18
19
  if (!window.customElements.get('mds-dropdown-nav')) {
19
20
  window.customElements.define('mds-dropdown-nav', MdsDropdownNav);
@@ -30,6 +31,10 @@ if (!window.customElements.get('mds-conditional-section')) {
30
31
  if (!window.customElements.get('mds-image-cropper')) {
31
32
  window.customElements.define('mds-image-cropper', MdsImageCropper);
32
33
  }
34
+ if (!window.customElements.get('mds-category-picker')) {
35
+ window.customElements.define('mds-category-picker', MdsCategoryPicker);
36
+ }
37
+
33
38
  const initAll = () => {
34
39
  tabs.init();
35
40
  accordion.init();
@@ -9,6 +9,7 @@
9
9
  @import '../../components/popover/popover';
10
10
  @import '../../components/notification/notification';
11
11
  @import '../../components/inputs/form-elements';
12
+ @import '../../components/inputs/category-picker/category-picker';
12
13
  @import '../../components/inputs/combobox/combobox';
13
14
  @import '../../components/inputs/file-upload/file-upload';
14
15
  @import '../../components/inputs/textarea/textarea';