@rettangoli/ui 1.8.2 → 1.9.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.
- package/dist/rettangoli-iife-layout.min.js +1 -1
- package/dist/rettangoli-iife-ui.min.js +67 -67
- package/package.json +1 -1
- package/src/components/form/form.view.yaml +1 -1
- package/src/components/select/select.handlers.js +89 -1
- package/src/components/select/select.schema.yaml +6 -0
- package/src/components/select/select.store.js +156 -7
- package/src/components/select/select.view.yaml +19 -5
- package/src/primitives/popover.js +2 -0
package/package.json
CHANGED
|
@@ -67,7 +67,7 @@ template:
|
|
|
67
67
|
- $if field.type == "input-textarea":
|
|
68
68
|
- rtgl-textarea#field${field._idx} data-field-name=${field.name} w=f rows=${field.rows} placeholder=${field.placeholder} ?disabled=${field._disabled}: null
|
|
69
69
|
- $if field.type == "select":
|
|
70
|
-
- rtgl-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled}: null
|
|
70
|
+
- rtgl-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} ?no-clear=${field.noClear} :selectedValue=${field._selectedValue} :placeholder=${field.placeholder} ?disabled=${field._disabled} ?searchable=${field.searchable} :searchPlaceholder=${field.searchPlaceholder} :emptySearchLabel=${field.emptySearchLabel}: null
|
|
71
71
|
- $if field.type == "tag-select":
|
|
72
72
|
- rtgl-tag-select#field${field._idx} data-field-name=${field.name} w=f :options=${field.options} :selectedValues=${field._selectedValues} :placeholder=${field.placeholder} :addOption=${field.addOption} ?no-add=${field.noAdd} ?disabled=${field._disabled}: null
|
|
73
73
|
- $if field.type == "segmented-control":
|
|
@@ -12,6 +12,50 @@ const getOptionType = (option = {}) => {
|
|
|
12
12
|
return 'item';
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
const focusSearchInput = (refs = {}) => {
|
|
16
|
+
const input = refs.searchInput;
|
|
17
|
+
|
|
18
|
+
if (!input) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (typeof input.focus === "function") {
|
|
23
|
+
input.focus();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const innerInput = input.shadowRoot?.querySelector("input, textarea");
|
|
27
|
+
if (innerInput && typeof innerInput.focus === "function") {
|
|
28
|
+
innerInput.focus();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const focusSearchInputWhenReady = (refs = {}, remainingAttempts = 10) => {
|
|
33
|
+
const popover = refs.popover;
|
|
34
|
+
|
|
35
|
+
if (!popover?.hasAttribute?.("open")) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (popover.hasAttribute("positioned") || remainingAttempts === 0) {
|
|
40
|
+
focusSearchInput(refs);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setTimeout(() => focusSearchInputWhenReady(refs, remainingAttempts - 1), 16);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const refreshOpenPopover = (refs = {}) => {
|
|
48
|
+
refs?.popover?.refreshContent?.();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getEventValue = (event = {}) => {
|
|
52
|
+
if (event.detail && Object.prototype.hasOwnProperty.call(event.detail, "value")) {
|
|
53
|
+
return event.detail.value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return event.currentTarget?.value ?? "";
|
|
57
|
+
};
|
|
58
|
+
|
|
15
59
|
export const handleBeforeMount = (deps) => {
|
|
16
60
|
const { store, props, render } = deps;
|
|
17
61
|
|
|
@@ -47,11 +91,25 @@ export const handleOnUpdate = (deps, payload) => {
|
|
|
47
91
|
shouldRefreshPopover = true;
|
|
48
92
|
}
|
|
49
93
|
|
|
94
|
+
if (oldProps.searchable !== newProps.searchable) {
|
|
95
|
+
store.setSearchQuery?.({ query: "" });
|
|
96
|
+
shouldRender = true;
|
|
97
|
+
shouldRefreshPopover = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (
|
|
101
|
+
oldProps.searchPlaceholder !== newProps.searchPlaceholder
|
|
102
|
+
|| oldProps.emptySearchLabel !== newProps.emptySearchLabel
|
|
103
|
+
) {
|
|
104
|
+
shouldRender = true;
|
|
105
|
+
shouldRefreshPopover = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
50
108
|
if (shouldRender) {
|
|
51
109
|
render();
|
|
52
110
|
|
|
53
111
|
if (shouldRefreshPopover && store.selectState?.().isOpen) {
|
|
54
|
-
refs
|
|
112
|
+
refreshOpenPopover(refs);
|
|
55
113
|
}
|
|
56
114
|
}
|
|
57
115
|
}
|
|
@@ -82,6 +140,10 @@ export const handleButtonClick = (deps, payload) => {
|
|
|
82
140
|
selectedIndex
|
|
83
141
|
})
|
|
84
142
|
render();
|
|
143
|
+
|
|
144
|
+
if (props.searchable) {
|
|
145
|
+
focusSearchInputWhenReady(refs);
|
|
146
|
+
}
|
|
85
147
|
}
|
|
86
148
|
|
|
87
149
|
export const handleButtonKeyDown = (deps, payload) => {
|
|
@@ -100,6 +162,32 @@ export const handleClickOptionsPopoverOverlay = (deps) => {
|
|
|
100
162
|
render();
|
|
101
163
|
}
|
|
102
164
|
|
|
165
|
+
export const handleSearchInput = (deps, payload) => {
|
|
166
|
+
const { store, render } = deps;
|
|
167
|
+
const event = payload._event;
|
|
168
|
+
|
|
169
|
+
event.stopPropagation();
|
|
170
|
+
store.setSearchQuery({ query: getEventValue(event) });
|
|
171
|
+
render();
|
|
172
|
+
// The popover observes size changes itself. Resyncing here re-slots the
|
|
173
|
+
// active search field and causes Chromium to drop focus while typing.
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const handleSearchKeyDown = (deps, payload) => {
|
|
177
|
+
const { store, render } = deps;
|
|
178
|
+
const event = payload._event;
|
|
179
|
+
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
|
|
182
|
+
if (event.key !== "Escape") {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
store.closeOptionsPopover({});
|
|
188
|
+
render();
|
|
189
|
+
};
|
|
190
|
+
|
|
103
191
|
export const handleOptionClick = (deps, payload) => {
|
|
104
192
|
const { render, dispatchEvent, props, store } = deps;
|
|
105
193
|
if (props.disabled) return;
|
|
@@ -15,6 +15,9 @@ const blacklistedProps = [
|
|
|
15
15
|
"noClear",
|
|
16
16
|
"addOption",
|
|
17
17
|
"disabled",
|
|
18
|
+
"searchable",
|
|
19
|
+
"searchPlaceholder",
|
|
20
|
+
"emptySearchLabel",
|
|
18
21
|
];
|
|
19
22
|
|
|
20
23
|
const stringifyProps = (props = {}) => {
|
|
@@ -54,6 +57,120 @@ const getOptionSuffixText = (option = {}) => {
|
|
|
54
57
|
return '';
|
|
55
58
|
};
|
|
56
59
|
|
|
60
|
+
const normalizeSearchQuery = (query) => {
|
|
61
|
+
if (query === undefined || query === null) {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return String(query).trim().toLowerCase();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const optionLabelMatches = (option = {}, query = "") => {
|
|
69
|
+
if (!query) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return String(option.label ?? "").toLowerCase().includes(query);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const collectOptionGroups = (options = []) => {
|
|
77
|
+
const groups = [];
|
|
78
|
+
let currentGroup = {
|
|
79
|
+
section: null,
|
|
80
|
+
entries: [],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
options.forEach((option, index) => {
|
|
84
|
+
const entry = {
|
|
85
|
+
option,
|
|
86
|
+
index,
|
|
87
|
+
type: getOptionType(option),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (entry.type === "section") {
|
|
91
|
+
if (currentGroup.section || currentGroup.entries.length > 0) {
|
|
92
|
+
groups.push(currentGroup);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
currentGroup = {
|
|
96
|
+
section: entry,
|
|
97
|
+
entries: [],
|
|
98
|
+
};
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
currentGroup.entries.push(entry);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (currentGroup.section || currentGroup.entries.length > 0) {
|
|
106
|
+
groups.push(currentGroup);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return groups;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const filterOptionEntriesBySearch = (options = [], rawQuery = "") => {
|
|
113
|
+
const query = normalizeSearchQuery(rawQuery);
|
|
114
|
+
|
|
115
|
+
if (!query) {
|
|
116
|
+
return options.map((option, index) => ({
|
|
117
|
+
option,
|
|
118
|
+
index,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return collectOptionGroups(options).flatMap((group) => {
|
|
123
|
+
const sectionMatches = group.section ? optionLabelMatches(group.section.option, query) : false;
|
|
124
|
+
const itemVisibility = group.entries.map((entry) => {
|
|
125
|
+
if (entry.type !== "item") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return sectionMatches || optionLabelMatches(entry.option, query);
|
|
130
|
+
});
|
|
131
|
+
const hasVisibleItem = itemVisibility.some(Boolean);
|
|
132
|
+
|
|
133
|
+
if (!sectionMatches && !hasVisibleItem) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const visibleEntries = [];
|
|
138
|
+
|
|
139
|
+
if (group.section) {
|
|
140
|
+
visibleEntries.push(group.section);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
group.entries.forEach((entry, entryIndex) => {
|
|
144
|
+
if (entry.type === "item") {
|
|
145
|
+
if (itemVisibility[entryIndex]) {
|
|
146
|
+
visibleEntries.push(entry);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (entry.type !== "separator") {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasVisibleItemBefore = itemVisibility
|
|
156
|
+
.slice(0, entryIndex)
|
|
157
|
+
.some(Boolean);
|
|
158
|
+
const hasVisibleItemAfter = itemVisibility
|
|
159
|
+
.slice(entryIndex + 1)
|
|
160
|
+
.some(Boolean);
|
|
161
|
+
|
|
162
|
+
if (hasVisibleItemBefore && hasVisibleItemAfter) {
|
|
163
|
+
visibleEntries.push(entry);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return visibleEntries.map(({ option, index }) => ({
|
|
168
|
+
option,
|
|
169
|
+
index,
|
|
170
|
+
}));
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
57
174
|
const normalizeOption = (option = {}, index, currentValue, hoveredOptionId, hasIconColumn) => {
|
|
58
175
|
const type = getOptionType(option);
|
|
59
176
|
const isSection = type === 'section';
|
|
@@ -116,6 +233,7 @@ export const createInitialState = () => Object.freeze({
|
|
|
116
233
|
selectedValue: null,
|
|
117
234
|
hoveredOptionId: null,
|
|
118
235
|
hoveredAddOption: false,
|
|
236
|
+
searchQuery: "",
|
|
119
237
|
});
|
|
120
238
|
|
|
121
239
|
export const selectViewData = ({ state, props }) => {
|
|
@@ -139,10 +257,24 @@ export const selectViewData = ({ state, props }) => {
|
|
|
139
257
|
}
|
|
140
258
|
|
|
141
259
|
const hasIconColumn = options.some((option) => isSelectableOption(option) && hasOwnProp(option, 'icon'));
|
|
142
|
-
const
|
|
143
|
-
|
|
260
|
+
const visibleOptionEntries = filterOptionEntriesBySearch(
|
|
261
|
+
options,
|
|
262
|
+
props.searchable ? state.searchQuery : "",
|
|
263
|
+
);
|
|
264
|
+
const visibleOptionIndexes = new Set(visibleOptionEntries.map(({ index }) => index));
|
|
265
|
+
const renderOptions = options.map((option, index) => {
|
|
266
|
+
return {
|
|
267
|
+
...normalizeOption(option, index, currentValue, state.hoveredOptionId, hasIconColumn),
|
|
268
|
+
isSearchHidden: !visibleOptionIndexes.has(index),
|
|
269
|
+
};
|
|
144
270
|
});
|
|
145
|
-
const
|
|
271
|
+
const optionsWithSelection = renderOptions.filter((option) => !option.isSearchHidden);
|
|
272
|
+
const selectedOptionIcon = getOptionIcon(selectedOption);
|
|
273
|
+
const selectedOptionSuffixText = getOptionSuffixText(selectedOption);
|
|
274
|
+
const searchQuery = props.searchable ? state.searchQuery : "";
|
|
275
|
+
const hasSearchQuery = normalizeSearchQuery(searchQuery).length > 0;
|
|
276
|
+
const hasVisibleSelectableOptions = optionsWithSelection.some((option) => option.isItem);
|
|
277
|
+
const showEmptySearch = !!props.searchable && hasSearchQuery && !hasVisibleSelectableOptions;
|
|
146
278
|
|
|
147
279
|
return {
|
|
148
280
|
containerAttrString,
|
|
@@ -150,14 +282,15 @@ export const selectViewData = ({ state, props }) => {
|
|
|
150
282
|
isOpen: state.isOpen,
|
|
151
283
|
position: state.position,
|
|
152
284
|
options: optionsWithSelection,
|
|
285
|
+
renderOptions,
|
|
153
286
|
selectedValue: currentValue,
|
|
154
287
|
selectedLabel: displayLabel,
|
|
155
288
|
selectedLabelColor: isPlaceholderLabel ? "mu-fg" : "fg",
|
|
156
|
-
selectedIcon:
|
|
157
|
-
hasSelectedIcon:
|
|
289
|
+
selectedIcon: selectedOptionIcon,
|
|
290
|
+
hasSelectedIcon: selectedOptionIcon.length > 0,
|
|
158
291
|
selectedIconColor: isPlaceholderLabel ? "mu-fg" : "fg",
|
|
159
|
-
selectedSuffixText:
|
|
160
|
-
hasSelectedSuffixText:
|
|
292
|
+
selectedSuffixText: selectedOptionSuffixText,
|
|
293
|
+
hasSelectedSuffixText: selectedOptionSuffixText.length > 0,
|
|
161
294
|
selectedSuffixTextColor: "mu-fg",
|
|
162
295
|
selectButtonCursor: isDisabled ? "not-allowed" : "pointer",
|
|
163
296
|
selectButtonHoverBorderColor: isDisabled ? "bo" : "ac",
|
|
@@ -167,6 +300,12 @@ export const selectViewData = ({ state, props }) => {
|
|
|
167
300
|
showAddOption: !isDisabled && !!props.addOption,
|
|
168
301
|
addOptionLabel: props.addOption?.label ? `+ ${props.addOption.label}` : "+ Add",
|
|
169
302
|
addOptionBgc: state.hoveredAddOption ? "ac" : "",
|
|
303
|
+
showSearch: !isDisabled && !!props.searchable,
|
|
304
|
+
searchQuery,
|
|
305
|
+
searchPlaceholder: props.searchPlaceholder || "Search options",
|
|
306
|
+
showEmptySearch,
|
|
307
|
+
hideEmptySearch: !showEmptySearch,
|
|
308
|
+
emptySearchLabel: props.emptySearchLabel || "No matching options",
|
|
170
309
|
};
|
|
171
310
|
};
|
|
172
311
|
|
|
@@ -182,6 +321,7 @@ export const openOptionsPopover = ({ state }, payload = {}) => {
|
|
|
182
321
|
const { position, selectedIndex } = payload;
|
|
183
322
|
state.position = position;
|
|
184
323
|
state.isOpen = true;
|
|
324
|
+
state.searchQuery = "";
|
|
185
325
|
// Set hoveredOptionId to the selected option's index if available
|
|
186
326
|
if (selectedIndex !== undefined && selectedIndex !== null) {
|
|
187
327
|
state.hoveredOptionId = selectedIndex;
|
|
@@ -190,11 +330,13 @@ export const openOptionsPopover = ({ state }, payload = {}) => {
|
|
|
190
330
|
|
|
191
331
|
export const closeOptionsPopover = ({ state }) => {
|
|
192
332
|
state.isOpen = false;
|
|
333
|
+
state.searchQuery = "";
|
|
193
334
|
};
|
|
194
335
|
|
|
195
336
|
export const updateSelectedValue = ({ state }, payload = {}) => {
|
|
196
337
|
state.selectedValue = payload.value;
|
|
197
338
|
state.isOpen = false;
|
|
339
|
+
state.searchQuery = "";
|
|
198
340
|
};
|
|
199
341
|
|
|
200
342
|
export const resetSelection = ({ state }) => {
|
|
@@ -216,3 +358,10 @@ export const clearSelectedValue = ({ state }) => {
|
|
|
216
358
|
export const setHoveredAddOption = ({ state }, payload = {}) => {
|
|
217
359
|
state.hoveredAddOption = !!payload.isHovered;
|
|
218
360
|
};
|
|
361
|
+
|
|
362
|
+
export const setSearchQuery = ({ state }, payload = {}) => {
|
|
363
|
+
state.searchQuery = payload.query === undefined || payload.query === null
|
|
364
|
+
? ""
|
|
365
|
+
: String(payload.query);
|
|
366
|
+
state.hoveredOptionId = null;
|
|
367
|
+
};
|
|
@@ -9,6 +9,14 @@ refs:
|
|
|
9
9
|
eventListeners:
|
|
10
10
|
click:
|
|
11
11
|
handler: handleClearClick
|
|
12
|
+
searchInput:
|
|
13
|
+
eventListeners:
|
|
14
|
+
value-input:
|
|
15
|
+
handler: handleSearchInput
|
|
16
|
+
stopPropagation: true
|
|
17
|
+
keydown:
|
|
18
|
+
handler: handleSearchKeyDown
|
|
19
|
+
stopPropagation: true
|
|
12
20
|
popover:
|
|
13
21
|
eventListeners:
|
|
14
22
|
close:
|
|
@@ -46,13 +54,16 @@ template:
|
|
|
46
54
|
- $if showClear:
|
|
47
55
|
- rtgl-svg#clearButton svg=x wh=16 c=mu-fg cur=pointer data-testid="select-clear-button": null
|
|
48
56
|
- rtgl-svg svg=chevronDown wh=16 c=mu-fg: null
|
|
49
|
-
- rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=rs content-wh=300 content-g=xs content-sv=true content-pv=sm:
|
|
50
|
-
- $
|
|
57
|
+
- rtgl-popover#popover ?open=${isOpen} x=${position.x} y=${position.y} place=rs content-wh=300 content-g=xs content-sv=true ?content-hsb=${showSearch} content-pv=sm:
|
|
58
|
+
- $if showSearch:
|
|
59
|
+
- rtgl-view w=f mt=md mh=md mb=xs:
|
|
60
|
+
- 'rtgl-input#searchInput w=f s=sm value=${searchQuery} placeholder="${searchPlaceholder}"': null
|
|
61
|
+
- $for option, i in renderOptions:
|
|
51
62
|
- $if option.isSection:
|
|
52
|
-
- rtgl-view w=f p=md:
|
|
63
|
+
- rtgl-view w=f p=md ?hide=${option.isSearchHidden}:
|
|
53
64
|
- rtgl-text s=xs c=mu-fg: ${option.label}
|
|
54
65
|
$elif option.isItem:
|
|
55
|
-
- rtgl-view#option${option.index} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId}:
|
|
66
|
+
- rtgl-view#option${option.index} w=f ph=lg pv=md cur=pointer bgc=${option.bgc} data-testid=${option.testId} ?hide=${option.isSearchHidden}:
|
|
56
67
|
- rtgl-view d=h av=c w=f g=md:
|
|
57
68
|
- rtgl-view d=h av=c g=md w=1fg:
|
|
58
69
|
- $if option.hasIconSlot:
|
|
@@ -64,7 +75,10 @@ template:
|
|
|
64
75
|
- $if option.hasSuffixText:
|
|
65
76
|
- rtgl-text s=xs c=${option.suffixTextColor} ta=e: ${option.suffixText}
|
|
66
77
|
$elif option.isSeparator:
|
|
67
|
-
- rtgl-view w=f h=1 bgc=mu mv=sm: null
|
|
78
|
+
- rtgl-view w=f h=1 bgc=mu mv=sm ?hide=${option.isSearchHidden}: null
|
|
79
|
+
- $if showSearch:
|
|
80
|
+
- rtgl-view w=f ph=lg pv=md ?hide=${hideEmptySearch}:
|
|
81
|
+
- rtgl-text s=sm c=mu-fg: ${emptySearchLabel}
|
|
68
82
|
- $if showAddOption:
|
|
69
83
|
- rtgl-view w=f bw=xs bc=mu bwt=sm: null
|
|
70
84
|
- rtgl-view#optionAdd w=f ph=lg pv=md cur=pointer bgc=${addOptionBgc} data-testid="select-add-option":
|
|
@@ -188,6 +188,7 @@ class RettangoliPopoverElement extends HTMLElement {
|
|
|
188
188
|
"content-wh",
|
|
189
189
|
"content-g",
|
|
190
190
|
"content-sv",
|
|
191
|
+
"content-hsb",
|
|
191
192
|
"content-ph",
|
|
192
193
|
"content-pv",
|
|
193
194
|
"content-bgc",
|
|
@@ -375,6 +376,7 @@ class RettangoliPopoverElement extends HTMLElement {
|
|
|
375
376
|
["content-wh", "wh"],
|
|
376
377
|
["content-g", "g"],
|
|
377
378
|
["content-sv", "sv"],
|
|
379
|
+
["content-hsb", "hsb"],
|
|
378
380
|
];
|
|
379
381
|
|
|
380
382
|
for (const [sourceAttr, targetAttr] of attrs) {
|