mtrl 0.3.1 → 0.3.2
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/.env +15 -0
- package/CONTRIBUTING.md +8 -8
- package/DOCS.md +3 -3
- package/README.md +43 -20
- package/TESTING.md +128 -18
- package/dist/index.js +14865 -0
- package/git-user-stats.js +545 -0
- package/index.ts +9 -67
- package/package.json +8 -3
- package/src/components/badge/api.ts +15 -1
- package/src/components/badge/badge.ts +43 -4
- package/src/components/badge/config.ts +40 -8
- package/src/components/badge/index.ts +64 -3
- package/src/components/badge/types.ts +175 -33
- package/src/components/button/api.ts +63 -1
- package/src/components/button/button.ts +39 -3
- package/src/components/button/config.ts +21 -4
- package/src/components/button/index.ts +26 -1
- package/src/components/button/types.ts +7 -1
- package/src/components/card/api.ts +78 -9
- package/src/components/card/card.ts +58 -3
- package/src/components/card/config.ts +41 -11
- package/src/components/card/features.ts +39 -12
- package/src/components/card/index.ts +84 -19
- package/src/components/card/types.ts +218 -29
- package/src/components/carousel/carousel.ts +92 -28
- package/src/components/carousel/constants.ts +107 -21
- package/src/components/carousel/index.ts +31 -13
- package/src/components/checkbox/checkbox.ts +83 -16
- package/src/components/checkbox/index.ts +43 -1
- package/src/components/checkbox/types.ts +219 -32
- package/src/components/chips/api.ts +194 -0
- package/src/components/{chip → chips/chip}/api.ts +42 -2
- package/src/components/chips/chip/chip.ts +131 -0
- package/src/components/{chip → chips/chip}/config.ts +3 -3
- package/src/components/chips/chip/index.ts +3 -0
- package/src/components/chips/chips.md +481 -0
- package/src/components/chips/chips.ts +75 -0
- package/src/components/chips/config.ts +109 -0
- package/src/components/chips/constants.ts +61 -0
- package/src/components/chips/features/chip-items.ts +33 -0
- package/src/components/chips/features/container.ts +77 -0
- package/src/components/chips/features/controller.ts +448 -0
- package/src/components/chips/features/index.ts +5 -0
- package/src/components/chips/features/label.ts +108 -0
- package/src/components/chips/index.ts +11 -0
- package/src/components/chips/schema.ts +61 -0
- package/src/components/{chip → chips}/types.ts +203 -92
- package/src/components/dialog/dialog.ts +99 -16
- package/src/components/dialog/index.ts +97 -1
- package/src/components/dialog/types.ts +375 -69
- package/src/components/divider/config.ts +90 -6
- package/src/components/divider/divider.ts +32 -2
- package/src/components/divider/features.ts +26 -0
- package/src/components/divider/index.ts +30 -0
- package/src/components/divider/types.ts +86 -9
- package/src/components/extended-fab/api.ts +53 -1
- package/src/components/extended-fab/config.ts +29 -1
- package/src/components/extended-fab/extended-fab.ts +28 -0
- package/src/components/extended-fab/index.ts +36 -0
- package/src/components/extended-fab/types.ts +458 -13
- package/src/components/fab/api.ts +42 -2
- package/src/components/fab/config.ts +29 -1
- package/src/components/fab/fab.ts +16 -2
- package/src/components/fab/index.ts +35 -0
- package/src/components/fab/types.ts +374 -10
- package/src/components/list/api.ts +12 -2
- package/src/components/list/config.ts +21 -0
- package/src/components/list/features.ts +6 -0
- package/src/components/list/index.ts +56 -1
- package/src/components/list/list-item.ts +46 -2
- package/src/components/list/list.ts +73 -2
- package/src/components/list/types.ts +172 -0
- package/src/components/list/utils.ts +26 -2
- package/src/components/menu/api.ts +217 -20
- package/src/components/menu/config.ts +27 -0
- package/src/components/menu/features/visibility.ts +55 -6
- package/src/components/menu/index.ts +64 -0
- package/src/components/menu/menu-item.ts +46 -3
- package/src/components/menu/menu.ts +77 -1
- package/src/components/menu/types.ts +404 -39
- package/src/components/sheet/config.ts +1 -2
- package/src/components/sheet/features/gestures.ts +1 -1
- package/src/components/sheet/features/position.ts +1 -2
- package/src/components/sheet/features/state.ts +1 -1
- package/src/components/sheet/index.ts +10 -2
- package/src/components/sheet/sheet.ts +1 -2
- package/src/components/sheet/types.ts +29 -1
- package/src/components/slider/api.ts +1 -1
- package/src/components/slider/config.ts +1 -1
- package/src/components/slider/features/controller.ts +1 -1
- package/src/components/slider/features/handlers.ts +1 -1
- package/src/components/slider/features/states.ts +1 -1
- package/src/components/slider/index.ts +12 -5
- package/src/components/slider/schema.ts +1 -1
- package/src/components/slider/types.ts +31 -0
- package/src/components/tabs/tab-api.ts +1 -1
- package/src/components/tabs/types.ts +1 -1
- package/src/components/tooltip/api.ts +6 -2
- package/src/components/tooltip/config.ts +9 -28
- package/src/components/tooltip/index.ts +10 -1
- package/src/components/tooltip/types.ts +38 -3
- package/src/index.ts +129 -31
- package/src/styles/abstract/_mixins.scss +23 -9
- package/src/styles/abstract/_variables.scss +14 -4
- package/src/styles/components/_card.scss +1 -1
- package/src/styles/components/_chip.scss +323 -113
- package/src/styles/components/_tabs.scss +1 -1
- package/CLAUDE.md +0 -33
- package/src/components/checkbox/constants.ts +0 -37
- package/src/components/chip/chip-set.ts +0 -225
- package/src/components/chip/chip.ts +0 -118
- package/src/components/chip/constants.ts +0 -28
- package/src/components/chip/index.ts +0 -12
- package/src/components/list/constants.ts +0 -116
- package/src/components/sheet/constants.ts +0 -20
- package/src/components/slider/constants.ts +0 -32
- package/src/components/tooltip/constants.ts +0 -27
- package/test/components/badge.test.ts +0 -545
- package/test/components/bottom-app-bar.test.ts +0 -303
- package/test/components/button.test.ts +0 -233
- package/test/components/card.test.ts +0 -560
- package/test/components/carousel.test.ts +0 -951
- package/test/components/checkbox.test.ts +0 -462
- package/test/components/chip.test.ts +0 -692
- package/test/components/datepicker.test.ts +0 -1124
- package/test/components/dialog.test.ts +0 -990
- package/test/components/divider.test.ts +0 -412
- package/test/components/extended-fab.test.ts +0 -672
- package/test/components/fab.test.ts +0 -561
- package/test/components/list.test.ts +0 -365
- package/test/components/menu.test.ts +0 -718
- package/test/components/navigation.test.ts +0 -186
- package/test/components/progress.test.ts +0 -567
- package/test/components/radios.test.ts +0 -699
- package/test/components/search.test.ts +0 -1135
- package/test/components/segmented-button.test.ts +0 -732
- package/test/components/sheet.test.ts +0 -641
- package/test/components/slider.test.ts +0 -1220
- package/test/components/snackbar.test.ts +0 -461
- package/test/components/switch.test.ts +0 -452
- package/test/components/tabs.test.ts +0 -1369
- package/test/components/textfield.test.ts +0 -400
- package/test/components/timepicker.test.ts +0 -592
- package/test/components/tooltip.test.ts +0 -630
- package/test/components/top-app-bar.test.ts +0 -566
- package/test/core/dom.attributes.test.ts +0 -148
- package/test/core/dom.classes.test.ts +0 -152
- package/test/core/dom.events.test.ts +0 -243
- package/test/core/emitter.test.ts +0 -141
- package/test/core/ripple.test.ts +0 -99
- package/test/core/state.store.test.ts +0 -189
- package/test/core/utils.normalize.test.ts +0 -61
- package/test/core/utils.object.test.ts +0 -120
- package/test/setup.js +0 -371
- package/test/setup.ts +0 -451
- package/tsconfig.json +0 -22
- package/typedoc.json +0 -28
- package/typedoc.simple.json +0 -14
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// src/components/chips/features/controller.ts
|
|
2
|
+
import { ChipsConfig } from '../types';
|
|
3
|
+
import createChip from '../chip/chip';
|
|
4
|
+
import { CHIPS_EVENTS } from '../constants';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Add controller functionality to chips component
|
|
8
|
+
* Manages state, events, user interactions, and UI rendering
|
|
9
|
+
*
|
|
10
|
+
* @param config Chips configuration
|
|
11
|
+
* @returns Component enhancer with chips controller functionality
|
|
12
|
+
*/
|
|
13
|
+
export const withController = (config: ChipsConfig) => component => {
|
|
14
|
+
// Ensure component has required properties
|
|
15
|
+
if (!component.element || !component.components) {
|
|
16
|
+
console.warn('Cannot initialize chips controller: missing element or components');
|
|
17
|
+
return component;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Store event listeners
|
|
21
|
+
const eventListeners = {
|
|
22
|
+
change: [],
|
|
23
|
+
add: [],
|
|
24
|
+
remove: []
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Track current focused chip index for keyboard navigation
|
|
28
|
+
let focusedChipIndex = -1;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Dispatches custom events to registered handlers
|
|
32
|
+
* @param {string} eventName - Name of the event to trigger
|
|
33
|
+
* @param {any[]} args - Arguments to pass to the handlers
|
|
34
|
+
*/
|
|
35
|
+
const dispatchEvent = (eventName, ...args) => {
|
|
36
|
+
if (eventListeners[eventName]) {
|
|
37
|
+
eventListeners[eventName].forEach(handler => handler(...args));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const handleSelection = (selectedChip) => {
|
|
42
|
+
// Always ensure the chip's class is set correctly
|
|
43
|
+
if (selectedChip.isSelected()) {
|
|
44
|
+
selectedChip.element.classList.add(`${component.getClass('chip')}--selected`);
|
|
45
|
+
selectedChip.element.setAttribute('aria-selected', 'true');
|
|
46
|
+
} else {
|
|
47
|
+
selectedChip.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
48
|
+
selectedChip.element.setAttribute('aria-selected', 'false');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!config.multiSelect) {
|
|
52
|
+
// Single selection mode - deselect all other chips
|
|
53
|
+
component.chipInstances.forEach(chip => {
|
|
54
|
+
if (chip !== selectedChip && chip.isSelected()) {
|
|
55
|
+
chip.setSelected(false);
|
|
56
|
+
chip.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
57
|
+
chip.element.setAttribute('aria-selected', 'false');
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// If this was a deselection, and it's the only selected chip in single-select mode,
|
|
62
|
+
// prevent deselection (keep it selected)
|
|
63
|
+
if (!selectedChip.isSelected() && getSelectedChips().length === 0) {
|
|
64
|
+
selectedChip.setSelected(true);
|
|
65
|
+
selectedChip.element.classList.add(`${component.getClass('chip')}--selected`);
|
|
66
|
+
selectedChip.element.setAttribute('aria-selected', 'true');
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
// In multi-select mode, we allow deselection of all chips
|
|
70
|
+
// No need to enforce at least one selection
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get all currently selected chips and their values
|
|
74
|
+
const selectedChips = component.chipInstances.filter(chip => chip.isSelected());
|
|
75
|
+
const selectedValues = selectedChips.map(chip => chip.getValue());
|
|
76
|
+
const changedValue = selectedChip ? selectedChip.getValue() : null;
|
|
77
|
+
|
|
78
|
+
// Call onChange callback if provided
|
|
79
|
+
if (typeof config.onChange === 'function') {
|
|
80
|
+
config.onChange(selectedValues, changedValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Dispatch change event to all registered handlers
|
|
84
|
+
dispatchEvent(CHIPS_EVENTS.CHANGE, selectedValues, changedValue);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Handles keyboard navigation between chips
|
|
89
|
+
* @param {KeyboardEvent} event - Keyboard event
|
|
90
|
+
*/
|
|
91
|
+
const handleKeyboardNavigation = (event) => {
|
|
92
|
+
if (component.chipInstances.length === 0) return;
|
|
93
|
+
|
|
94
|
+
// Only handle arrow keys, Enter, and Space
|
|
95
|
+
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' '].includes(event.key)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Handle enter and space for activation/selection
|
|
100
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
101
|
+
if (focusedChipIndex >= 0 && focusedChipIndex < component.chipInstances.length) {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
const chip = component.chipInstances[focusedChipIndex];
|
|
104
|
+
if (!chip.isDisabled()) {
|
|
105
|
+
chip.toggleSelected();
|
|
106
|
+
|
|
107
|
+
// Ensure selection state is reflected in the DOM
|
|
108
|
+
if (chip.isSelected()) {
|
|
109
|
+
chip.element.classList.add(`${component.getClass('chip')}--selected`);
|
|
110
|
+
chip.element.setAttribute('aria-selected', 'true');
|
|
111
|
+
} else {
|
|
112
|
+
chip.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
113
|
+
chip.element.setAttribute('aria-selected', 'false');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
handleSelection(chip);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Handle navigation keys
|
|
123
|
+
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
const isVertical = component.layout && component.layout.isVertical();
|
|
126
|
+
let newIndex = focusedChipIndex;
|
|
127
|
+
|
|
128
|
+
// If no chip is focused, start with the first one
|
|
129
|
+
if (focusedChipIndex === -1) {
|
|
130
|
+
newIndex = 0;
|
|
131
|
+
} else {
|
|
132
|
+
// Move based on key and layout direction
|
|
133
|
+
if ((isVertical && event.key === 'ArrowUp') || (!isVertical && event.key === 'ArrowLeft')) {
|
|
134
|
+
newIndex = Math.max(0, focusedChipIndex - 1);
|
|
135
|
+
} else if ((isVertical && event.key === 'ArrowDown') || (!isVertical && event.key === 'ArrowRight')) {
|
|
136
|
+
newIndex = Math.min(component.chipInstances.length - 1, focusedChipIndex + 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update focus if changed
|
|
141
|
+
if (newIndex !== focusedChipIndex) {
|
|
142
|
+
// Remove focus from current chip
|
|
143
|
+
if (focusedChipIndex >= 0 && focusedChipIndex < component.chipInstances.length) {
|
|
144
|
+
component.chipInstances[focusedChipIndex].element.blur();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Focus new chip
|
|
148
|
+
focusedChipIndex = newIndex;
|
|
149
|
+
component.chipInstances[focusedChipIndex].element.focus();
|
|
150
|
+
|
|
151
|
+
// If scrollable, ensure the focused chip is visible
|
|
152
|
+
if (component.layout && component.layout.isScrollable()) {
|
|
153
|
+
scrollToChip(focusedChipIndex);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Scrolls the chips container to make a specific chip visible
|
|
161
|
+
* @param {ChipComponent|number} chipOrIndex - Chip instance or index to scroll to
|
|
162
|
+
*/
|
|
163
|
+
const scrollToChip = (chipOrIndex) => {
|
|
164
|
+
const isScrollable = component.layout && component.layout.isScrollable();
|
|
165
|
+
if (!isScrollable) return;
|
|
166
|
+
|
|
167
|
+
const index = typeof chipOrIndex === 'number'
|
|
168
|
+
? chipOrIndex
|
|
169
|
+
: component.chipInstances.indexOf(chipOrIndex);
|
|
170
|
+
|
|
171
|
+
if (index >= 0 && index < component.chipInstances.length) {
|
|
172
|
+
const chipElement = component.chipInstances[index].element;
|
|
173
|
+
const container = component.components.chipContainer || component.element;
|
|
174
|
+
|
|
175
|
+
// Calculate scroll position to center the chip
|
|
176
|
+
const containerRect = container.getBoundingClientRect();
|
|
177
|
+
const chipRect = chipElement.getBoundingClientRect();
|
|
178
|
+
|
|
179
|
+
const isVertical = component.layout && component.layout.isVertical();
|
|
180
|
+
|
|
181
|
+
if (isVertical) {
|
|
182
|
+
// For vertical scroll
|
|
183
|
+
const scrollTop = chipElement.offsetTop - container.offsetTop -
|
|
184
|
+
(containerRect.height / 2) + (chipRect.height / 2);
|
|
185
|
+
|
|
186
|
+
container.scrollTo({
|
|
187
|
+
top: Math.max(0, scrollTop),
|
|
188
|
+
behavior: 'smooth'
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
// For horizontal scroll
|
|
192
|
+
const scrollLeft = chipElement.offsetLeft - container.offsetLeft -
|
|
193
|
+
(containerRect.width / 2) + (chipRect.width / 2);
|
|
194
|
+
|
|
195
|
+
container.scrollTo({
|
|
196
|
+
left: Math.max(0, scrollLeft),
|
|
197
|
+
behavior: 'smooth'
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Adds a chip to the chips container
|
|
205
|
+
* @param {Object} chipConfig - Configuration for the chip
|
|
206
|
+
* @returns {ChipComponent} The created chip instance
|
|
207
|
+
*/
|
|
208
|
+
const addChip = (chipConfig) => {
|
|
209
|
+
// CHANGE: Remove the onSelect handler that calls handleSelection
|
|
210
|
+
const chipInstance = createChip({
|
|
211
|
+
...chipConfig,
|
|
212
|
+
// Only pass through the user's onSelect handler, don't create a path to handleSelection
|
|
213
|
+
onSelect: chipConfig.onSelect
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Get the container element to append to
|
|
217
|
+
const container = component.components.chipContainer || component.element;
|
|
218
|
+
|
|
219
|
+
// Use DocumentFragment for better performance
|
|
220
|
+
const fragment = document.createDocumentFragment();
|
|
221
|
+
fragment.appendChild(chipInstance.element);
|
|
222
|
+
container.appendChild(fragment);
|
|
223
|
+
|
|
224
|
+
component.chipInstances.push(chipInstance);
|
|
225
|
+
|
|
226
|
+
// This click handler is the ONLY path to handleSelection
|
|
227
|
+
chipInstance.element.addEventListener('click', () => {
|
|
228
|
+
if (!chipInstance.isDisabled()) {
|
|
229
|
+
chipInstance.toggleSelected();
|
|
230
|
+
|
|
231
|
+
// Explicitly ensure selected state reflects in the DOM
|
|
232
|
+
if (chipInstance.isSelected()) {
|
|
233
|
+
chipInstance.element.classList.add(`${component.getClass('chip')}--selected`);
|
|
234
|
+
chipInstance.element.setAttribute('aria-selected', 'true');
|
|
235
|
+
} else {
|
|
236
|
+
chipInstance.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
237
|
+
chipInstance.element.setAttribute('aria-selected', 'false');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
handleSelection(chipInstance);
|
|
241
|
+
|
|
242
|
+
// Update focus tracking
|
|
243
|
+
focusedChipIndex = component.chipInstances.indexOf(chipInstance);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Dispatch add event
|
|
248
|
+
dispatchEvent(CHIPS_EVENTS.ADD, chipInstance);
|
|
249
|
+
|
|
250
|
+
return chipInstance;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Removes a chip from the chips container
|
|
255
|
+
* @param {ChipComponent|number} chipOrIndex - Chip instance or index to remove
|
|
256
|
+
*/
|
|
257
|
+
const removeChip = (chipOrIndex) => {
|
|
258
|
+
const index = typeof chipOrIndex === 'number'
|
|
259
|
+
? chipOrIndex
|
|
260
|
+
: component.chipInstances.indexOf(chipOrIndex);
|
|
261
|
+
|
|
262
|
+
if (index >= 0 && index < component.chipInstances.length) {
|
|
263
|
+
const chip = component.chipInstances[index];
|
|
264
|
+
|
|
265
|
+
// Dispatch remove event before actual removal
|
|
266
|
+
dispatchEvent(CHIPS_EVENTS.REMOVE, chip);
|
|
267
|
+
|
|
268
|
+
chip.destroy();
|
|
269
|
+
component.chipInstances.splice(index, 1);
|
|
270
|
+
|
|
271
|
+
// Update focused index if needed
|
|
272
|
+
if (index === focusedChipIndex) {
|
|
273
|
+
focusedChipIndex = -1;
|
|
274
|
+
} else if (index < focusedChipIndex) {
|
|
275
|
+
focusedChipIndex--;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Gets all chip instances in the container
|
|
282
|
+
* @returns {ChipComponent[]} Array of chip instances
|
|
283
|
+
*/
|
|
284
|
+
const getChips = () => {
|
|
285
|
+
return [...component.chipInstances];
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Gets currently selected chips
|
|
290
|
+
* @returns {ChipComponent[]} Array of selected chip instances
|
|
291
|
+
*/
|
|
292
|
+
const getSelectedChips = () => {
|
|
293
|
+
return component.chipInstances.filter(chip => chip.isSelected());
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Gets the values of selected chips
|
|
298
|
+
* @returns {(string|null)[]} Array of selected chip values
|
|
299
|
+
*/
|
|
300
|
+
const getSelectedValues = () => {
|
|
301
|
+
return getSelectedChips().map(chip => chip.getValue());
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Selects chips by their values
|
|
306
|
+
* @param {string|string[]} values - Value or array of values to select
|
|
307
|
+
* @param {boolean} triggerEvent - Whether to trigger change event (default: true)
|
|
308
|
+
*/
|
|
309
|
+
const selectByValue = (values, triggerEvent = true, exclusive = !config.multiSelect) => {
|
|
310
|
+
const valueArray = Array.isArray(values) ? values : [values];
|
|
311
|
+
let selectionChanged = false;
|
|
312
|
+
|
|
313
|
+
if (exclusive) {
|
|
314
|
+
// First handle deselection if exclusive mode
|
|
315
|
+
component.chipInstances.forEach(chip => {
|
|
316
|
+
const shouldSelect = valueArray.includes(chip.getValue());
|
|
317
|
+
if (!shouldSelect && chip.isSelected()) {
|
|
318
|
+
chip.setSelected(false);
|
|
319
|
+
chip.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
320
|
+
chip.element.setAttribute('aria-selected', 'false');
|
|
321
|
+
selectionChanged = true;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Then handle selection
|
|
327
|
+
component.chipInstances.forEach(chip => {
|
|
328
|
+
const shouldSelect = valueArray.includes(chip.getValue());
|
|
329
|
+
if (shouldSelect && !chip.isSelected()) {
|
|
330
|
+
chip.setSelected(true);
|
|
331
|
+
chip.element.classList.add(`${component.getClass('chip')}--selected`);
|
|
332
|
+
chip.element.setAttribute('aria-selected', 'true');
|
|
333
|
+
selectionChanged = true;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
// Dispatch change event if any chip selection has changed AND if triggerEvent is true
|
|
339
|
+
if (selectionChanged && triggerEvent) {
|
|
340
|
+
const selectedValues = getSelectedValues();
|
|
341
|
+
dispatchEvent(CHIPS_EVENTS.CHANGE, selectedValues, null);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Clears all selections
|
|
347
|
+
* @param {boolean} triggerEvent - Whether to trigger change event (default: true)
|
|
348
|
+
*/
|
|
349
|
+
const clearSelection = (triggerEvent = true) => {
|
|
350
|
+
const selectedValues = getSelectedValues();
|
|
351
|
+
const hadSelectedChips = selectedValues.length > 0;
|
|
352
|
+
|
|
353
|
+
component.chipInstances.forEach(chip => {
|
|
354
|
+
chip.setSelected(false);
|
|
355
|
+
chip.element.classList.remove(`${component.getClass('chip')}--selected`);
|
|
356
|
+
chip.element.setAttribute('aria-selected', 'false');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Only dispatch if there were actually chips deselected AND triggerEvent is true
|
|
360
|
+
if (hadSelectedChips && triggerEvent) {
|
|
361
|
+
dispatchEvent(CHIPS_EVENTS.CHANGE, [], null);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Enables keyboard navigation between chips in the container
|
|
367
|
+
*/
|
|
368
|
+
const enableKeyboardNavigation = () => {
|
|
369
|
+
// Add keyboard event listener to the chips container
|
|
370
|
+
component.element.tabIndex = 0; // Make the chips container focusable
|
|
371
|
+
component.element.addEventListener('keydown', handleKeyboardNavigation);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Disables keyboard navigation
|
|
376
|
+
*/
|
|
377
|
+
const disableKeyboardNavigation = () => {
|
|
378
|
+
component.element.removeEventListener('keydown', handleKeyboardNavigation);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Initialize keyboard navigation
|
|
382
|
+
enableKeyboardNavigation();
|
|
383
|
+
|
|
384
|
+
// Setup event listeners when element is available
|
|
385
|
+
if (component.element) {
|
|
386
|
+
component.element.addEventListener('keydown', handleKeyboardNavigation);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Setup lifecycle cleanup
|
|
390
|
+
if (component.lifecycle) {
|
|
391
|
+
const originalDestroy = component.lifecycle.destroy || (() => {});
|
|
392
|
+
|
|
393
|
+
component.lifecycle.destroy = () => {
|
|
394
|
+
// Clean up event listeners
|
|
395
|
+
component.element.removeEventListener('keydown', handleKeyboardNavigation);
|
|
396
|
+
|
|
397
|
+
// Clean up all chip instances
|
|
398
|
+
component.chipInstances.forEach(chip => chip.destroy());
|
|
399
|
+
component.chipInstances.length = 0;
|
|
400
|
+
|
|
401
|
+
// Clear all event listeners
|
|
402
|
+
Object.keys(eventListeners).forEach(event => {
|
|
403
|
+
eventListeners[event] = [];
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Call original destroy
|
|
407
|
+
originalDestroy();
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
...component,
|
|
413
|
+
// Add chips controller feature
|
|
414
|
+
chips: {
|
|
415
|
+
addChip,
|
|
416
|
+
removeChip,
|
|
417
|
+
getChips,
|
|
418
|
+
getSelectedChips,
|
|
419
|
+
getSelectedValues,
|
|
420
|
+
selectByValue,
|
|
421
|
+
clearSelection,
|
|
422
|
+
scrollToChip
|
|
423
|
+
},
|
|
424
|
+
// Add keyboard navigation feature
|
|
425
|
+
keyboard: {
|
|
426
|
+
enable: enableKeyboardNavigation,
|
|
427
|
+
disable: disableKeyboardNavigation
|
|
428
|
+
},
|
|
429
|
+
// Event management
|
|
430
|
+
on(event, handler) {
|
|
431
|
+
if (!eventListeners[event]) {
|
|
432
|
+
eventListeners[event] = [];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
eventListeners[event].push(handler);
|
|
436
|
+
return this;
|
|
437
|
+
},
|
|
438
|
+
off(event, handler) {
|
|
439
|
+
if (eventListeners[event]) {
|
|
440
|
+
const index = eventListeners[event].indexOf(handler);
|
|
441
|
+
if (index !== -1) {
|
|
442
|
+
eventListeners[event].splice(index, 1);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return this;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/components/chips/features/label.ts
|
|
2
|
+
import { ChipsConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds label functionality to chips component
|
|
6
|
+
*
|
|
7
|
+
* @param config Chips configuration
|
|
8
|
+
* @returns Component enhancer that adds label functionality
|
|
9
|
+
*/
|
|
10
|
+
export const withLabel = (config: ChipsConfig) => component => {
|
|
11
|
+
// Track current label state
|
|
12
|
+
const state = {
|
|
13
|
+
text: config.label || '',
|
|
14
|
+
position: config.labelPosition || 'start'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
...component,
|
|
19
|
+
|
|
20
|
+
// Label management API
|
|
21
|
+
label: {
|
|
22
|
+
/**
|
|
23
|
+
* Sets the label text
|
|
24
|
+
* @param {string} text - Label text
|
|
25
|
+
* @returns Label controller for chaining
|
|
26
|
+
*/
|
|
27
|
+
setText(text: string) {
|
|
28
|
+
state.text = text || '';
|
|
29
|
+
|
|
30
|
+
const labelElement = component.components?.label;
|
|
31
|
+
|
|
32
|
+
if (labelElement) {
|
|
33
|
+
labelElement.textContent = state.text;
|
|
34
|
+
|
|
35
|
+
// Update class based on whether label exists
|
|
36
|
+
if (state.text) {
|
|
37
|
+
component.element.classList.add(`${component.getClass('chips')}--with-label`);
|
|
38
|
+
} else {
|
|
39
|
+
component.element.classList.remove(`${component.getClass('chips')}--with-label`);
|
|
40
|
+
}
|
|
41
|
+
} else if (state.text && component.components && component.element) {
|
|
42
|
+
// Create label if it doesn't exist but we need one
|
|
43
|
+
const label = document.createElement('label');
|
|
44
|
+
label.className = component.getClass('chips-label');
|
|
45
|
+
label.textContent = state.text;
|
|
46
|
+
|
|
47
|
+
// Add to beginning if start, end if end
|
|
48
|
+
if (state.position === 'end') {
|
|
49
|
+
component.element.appendChild(label);
|
|
50
|
+
} else {
|
|
51
|
+
component.element.insertBefore(label, component.element.firstChild);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Store for future reference
|
|
55
|
+
component.components.label = label;
|
|
56
|
+
component.element.classList.add(`${component.getClass('chips')}--with-label`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return this;
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Gets the label text
|
|
64
|
+
* @returns {string} Label text
|
|
65
|
+
*/
|
|
66
|
+
getText() {
|
|
67
|
+
return state.text;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sets the label position
|
|
72
|
+
* @param {string} position - Label position ('start' or 'end')
|
|
73
|
+
* @returns Label controller for chaining
|
|
74
|
+
*/
|
|
75
|
+
setPosition(position: 'start' | 'end') {
|
|
76
|
+
state.position = position || 'start';
|
|
77
|
+
|
|
78
|
+
if (component.element && component.components?.label) {
|
|
79
|
+
const label = component.components.label;
|
|
80
|
+
|
|
81
|
+
// Update position class
|
|
82
|
+
if (position === 'end') {
|
|
83
|
+
component.element.classList.add(`${component.getClass('chips')}--label-end`);
|
|
84
|
+
} else {
|
|
85
|
+
component.element.classList.remove(`${component.getClass('chips')}--label-end`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Move label element to correct position
|
|
89
|
+
if (position === 'end') {
|
|
90
|
+
component.element.appendChild(label);
|
|
91
|
+
} else {
|
|
92
|
+
component.element.insertBefore(label, component.element.firstChild);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Gets the label position
|
|
101
|
+
* @returns {string} Label position
|
|
102
|
+
*/
|
|
103
|
+
getPosition() {
|
|
104
|
+
return state.position;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/components/chips/index.ts
|
|
2
|
+
export { default as createChip } from './chip';
|
|
3
|
+
export { default as createChips } from './chips';
|
|
4
|
+
export * from './constants';
|
|
5
|
+
export type {
|
|
6
|
+
ChipConfig,
|
|
7
|
+
ChipComponent,
|
|
8
|
+
ChipVariant,
|
|
9
|
+
ChipsConfig,
|
|
10
|
+
ChipsComponent
|
|
11
|
+
} from './types';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/components/chips/schema.ts
|
|
2
|
+
import { ChipsConfig } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the base chips structure definition
|
|
6
|
+
*
|
|
7
|
+
* @param component Component for class name generation
|
|
8
|
+
* @param config Chips configuration
|
|
9
|
+
* @returns Structure schema object
|
|
10
|
+
*/
|
|
11
|
+
export function createChipsSchema(component, config: ChipsConfig) {
|
|
12
|
+
// Get prefixed class names
|
|
13
|
+
const getClass = (className) => component.getClass(className);
|
|
14
|
+
|
|
15
|
+
// Set default values
|
|
16
|
+
const scrollable = config.scrollable === true;
|
|
17
|
+
const vertical = config.vertical === true;
|
|
18
|
+
const isMultiSelect = config.multiSelect === true;
|
|
19
|
+
const hasLabel = config.label && config.label.trim().length > 0;
|
|
20
|
+
const labelPosition = config.labelPosition || 'start';
|
|
21
|
+
|
|
22
|
+
// Return base structure definition formatted for createStructure
|
|
23
|
+
return {
|
|
24
|
+
element: {
|
|
25
|
+
options: {
|
|
26
|
+
className: [
|
|
27
|
+
getClass('chips'),
|
|
28
|
+
scrollable ? getClass('chips--scrollable') : null,
|
|
29
|
+
vertical ? getClass('chips--vertical') : null,
|
|
30
|
+
hasLabel ? getClass('chips--with-label') : null,
|
|
31
|
+
hasLabel && labelPosition === 'end' ? getClass('chips--label-end') : null,
|
|
32
|
+
config.class
|
|
33
|
+
].filter(Boolean),
|
|
34
|
+
attrs: {
|
|
35
|
+
tabindex: '0',
|
|
36
|
+
role: 'group',
|
|
37
|
+
'aria-multiselectable': isMultiSelect ? 'true' : 'false'
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
children: {
|
|
41
|
+
// Label element if provided
|
|
42
|
+
...(hasLabel ? {
|
|
43
|
+
label: {
|
|
44
|
+
options: {
|
|
45
|
+
tag: 'label',
|
|
46
|
+
className: getClass('chips-label'),
|
|
47
|
+
text: config.label
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} : {}),
|
|
51
|
+
|
|
52
|
+
// Chips container where chip instances will be inserted
|
|
53
|
+
chipContainer: {
|
|
54
|
+
options: {
|
|
55
|
+
className: getClass('chips-container')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|