@flux-ui/components 3.0.0-next.31 → 3.0.0-next.32
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/component/FluxFormTreeViewSelect.vue.d.ts +209 -0
- package/dist/component/FluxOverflowBar.vue.d.ts +2 -0
- package/dist/component/FluxTreeView.vue.d.ts +16 -0
- package/dist/component/index.d.ts +2 -0
- package/dist/composable/private/index.d.ts +2 -0
- package/dist/composable/private/useTreeView.d.ts +28 -0
- package/dist/index.css +258 -0
- package/dist/index.js +717 -185
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/component/FluxCheckbox.vue +2 -2
- package/src/component/FluxFilterBar.vue +1 -0
- package/src/component/FluxFormSlider.vue +1 -1
- package/src/component/FluxFormTreeViewSelect.vue +359 -0
- package/src/component/FluxOverflowBar.vue +2 -2
- package/src/component/FluxPressable.vue +13 -12
- package/src/component/FluxSegmentedControl.vue +35 -8
- package/src/component/FluxTreeView.vue +116 -0
- package/src/component/index.ts +2 -0
- package/src/composable/private/index.ts +2 -0
- package/src/composable/private/useTreeView.ts +186 -0
- package/src/css/component/TreeView.module.scss +34 -0
- package/src/css/component/TreeViewSelect.module.scss +73 -0
- package/src/css/mixin/index.scss +1 -0
- package/src/css/mixin/tree-node.scss +94 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flux-ui/components",
|
|
3
3
|
"description": "A set of opiniated UI components.",
|
|
4
|
-
"version": "3.0.0-next.
|
|
4
|
+
"version": "3.0.0-next.32",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"funding": "https://github.com/sponsors/basmilius",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@basmilius/common": "^3.11.0",
|
|
55
55
|
"@basmilius/utils": "^3.11.0",
|
|
56
|
-
"@flux-ui/internals": "3.0.0-next.
|
|
57
|
-
"@flux-ui/types": "3.0.0-next.
|
|
56
|
+
"@flux-ui/internals": "3.0.0-next.32",
|
|
57
|
+
"@flux-ui/types": "3.0.0-next.32",
|
|
58
58
|
"@fortawesome/fontawesome-common-types": "^7.2.0",
|
|
59
59
|
"clsx": "^2.1.1",
|
|
60
60
|
"imask": "^7.6.1",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
localValue.value = (unref(modelValue) - min) / (max - min);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
watch(
|
|
118
|
+
watch([() => max, () => min, localValue, () => step], () => {
|
|
119
119
|
const value = clampWithStepPrecision(unref(localValue), min, max, step);
|
|
120
120
|
modelValue.value = value;
|
|
121
121
|
percentage.value = (value - min) / (max - min);
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Anchor
|
|
3
|
+
ref="anchor"
|
|
4
|
+
:="$attrs"
|
|
5
|
+
:class="clsx(
|
|
6
|
+
$formStyle.formSelect,
|
|
7
|
+
disabled && $formStyle.isDisabled,
|
|
8
|
+
isPopupOpen && $formStyle.isFocused
|
|
9
|
+
)"
|
|
10
|
+
:id="id"
|
|
11
|
+
:aria-disabled="disabled ? true : undefined"
|
|
12
|
+
tabindex="0"
|
|
13
|
+
tag-name="div"
|
|
14
|
+
@click="toggle()"
|
|
15
|
+
@keydown="onKeyDown">
|
|
16
|
+
|
|
17
|
+
<template v-if="isMultiple && selectedOptions.length > 0">
|
|
18
|
+
<FluxTag
|
|
19
|
+
v-for="option in selectedOptions"
|
|
20
|
+
:key="option.id"
|
|
21
|
+
:label="option.label"
|
|
22
|
+
is-deletable
|
|
23
|
+
@delete="deselect(option.id)"/>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<template v-else-if="!isMultiple && selectedOptions[0]">
|
|
27
|
+
<span :class="$style.treeViewSelectValue">{{ selectedOptions[0].label }}</span>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<template v-else-if="placeholder">
|
|
31
|
+
<span :class="$formStyle.formSelectPlaceholder">{{ placeholder }}</span>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<FluxIcon
|
|
35
|
+
:class="$formStyle.formSelectIcon"
|
|
36
|
+
name="angle-down"/>
|
|
37
|
+
</Anchor>
|
|
38
|
+
|
|
39
|
+
<Teleport to="body">
|
|
40
|
+
<FluxFadeTransition>
|
|
41
|
+
<AnchorPopup
|
|
42
|
+
v-if="isPopupOpen && !disabled"
|
|
43
|
+
ref="anchorPopup"
|
|
44
|
+
:class="$formStyle.formSelectPopup"
|
|
45
|
+
:anchor="anchorRef"
|
|
46
|
+
direction="vertical"
|
|
47
|
+
use-anchor-width>
|
|
48
|
+
|
|
49
|
+
<FluxFormInput
|
|
50
|
+
v-if="isSearchable"
|
|
51
|
+
v-model="searchQuery"
|
|
52
|
+
ref="searchInput"
|
|
53
|
+
auto-complete="off"
|
|
54
|
+
:class="$formStyle.formSelectInput"
|
|
55
|
+
type="search"
|
|
56
|
+
icon-trailing="magnifying-glass"
|
|
57
|
+
:placeholder="translate('flux.search')"
|
|
58
|
+
@keydown="onKeyDown"/>
|
|
59
|
+
|
|
60
|
+
<div :class="$style.treeViewSelectList">
|
|
61
|
+
<div
|
|
62
|
+
v-if="visibleNodes.length === 0"
|
|
63
|
+
:class="$style.treeViewSelectEmpty">
|
|
64
|
+
{{ translate('flux.noItems') }}
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<template v-else>
|
|
68
|
+
<div
|
|
69
|
+
v-for="(node, nodeIndex) in visibleNodes"
|
|
70
|
+
ref="nodeElements"
|
|
71
|
+
:key="node.id"
|
|
72
|
+
:class="clsx(
|
|
73
|
+
$style.treeNode,
|
|
74
|
+
node.selectable !== false && $style.isSelectable,
|
|
75
|
+
node.selectable === false && !!node.children?.length && $style.isExpandable,
|
|
76
|
+
selectedIds.has(node.id) && $style.isSelected,
|
|
77
|
+
nodeIndex === highlightedIndex && $style.isHighlighted
|
|
78
|
+
)"
|
|
79
|
+
:role="node.selectable !== false ? 'option' : undefined"
|
|
80
|
+
:tabindex="node.selectable !== false ? 0 : undefined"
|
|
81
|
+
:aria-selected="node.selectable !== false ? selectedIds.has(node.id) : undefined"
|
|
82
|
+
@click="onNodeClick(node)"
|
|
83
|
+
@keydown.enter.prevent="onNodeClick(node)"
|
|
84
|
+
@keydown.space.prevent="onNodeClick(node)">
|
|
85
|
+
|
|
86
|
+
<!-- Line guides and expand button grouped without gap -->
|
|
87
|
+
<div :class="$style.treeNodeLineArea">
|
|
88
|
+
<span
|
|
89
|
+
v-for="(showLine, guideIndex) in node.lineGuides"
|
|
90
|
+
:key="`g-${guideIndex}`"
|
|
91
|
+
:class="[$style.treeIndent, showLine && $style.hasLine]"/>
|
|
92
|
+
|
|
93
|
+
<span
|
|
94
|
+
v-if="node.depth > 0"
|
|
95
|
+
:class="[$style.treeConnector, node.isLast && $style.isLast]"/>
|
|
96
|
+
|
|
97
|
+
<span
|
|
98
|
+
:class="$style.treeNodeExpand"
|
|
99
|
+
@click="onExpandClick(node, $event)">
|
|
100
|
+
<FluxIcon
|
|
101
|
+
v-if="node.children?.length"
|
|
102
|
+
:name="expandedIds.has(node.id) ? 'angle-down' : 'angle-right'"
|
|
103
|
+
:size="12"/>
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<span
|
|
108
|
+
v-if="getLevelColor(node.depth, levelColors)"
|
|
109
|
+
:class="$style.treeNodeColorDot"
|
|
110
|
+
:style="{ background: getLevelColor(node.depth, levelColors) }"/>
|
|
111
|
+
|
|
112
|
+
<FluxIcon
|
|
113
|
+
v-if="node.icon"
|
|
114
|
+
:class="$style.treeNodeIcon"
|
|
115
|
+
:name="node.icon"
|
|
116
|
+
:size="16"/>
|
|
117
|
+
|
|
118
|
+
<span :class="$style.treeNodeLabel">{{ node.label }}</span>
|
|
119
|
+
|
|
120
|
+
<FluxIcon
|
|
121
|
+
v-if="selectedIds.has(node.id)"
|
|
122
|
+
:class="$style.treeNodeCheck"
|
|
123
|
+
name="check"
|
|
124
|
+
:size="14"/>
|
|
125
|
+
</div>
|
|
126
|
+
</template>
|
|
127
|
+
</div>
|
|
128
|
+
</AnchorPopup>
|
|
129
|
+
</FluxFadeTransition>
|
|
130
|
+
</Teleport>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<script
|
|
134
|
+
lang="ts"
|
|
135
|
+
setup>
|
|
136
|
+
import { useClickOutside } from '@basmilius/common';
|
|
137
|
+
import { unrefTemplateElement } from '@flux-ui/internals';
|
|
138
|
+
import type { FluxColor, FluxFormTreeViewSelectOption, FluxFormTreeViewSelectValue } from '@flux-ui/types';
|
|
139
|
+
import { clsx } from 'clsx';
|
|
140
|
+
import { type ComponentPublicInstance, computed, nextTick, ref, toRef, unref, useTemplateRef, watch } from 'vue';
|
|
141
|
+
import { useDisabled, useFormFieldInjection } from '$flux/composable';
|
|
142
|
+
import { flattenAll, flattenVisible, getLevelColor, INITIAL_HIGHLIGHTED_INDEX, useTranslate, useTreeView } from '$flux/composable/private';
|
|
143
|
+
import type { TreeFlatNode } from '$flux/composable/private';
|
|
144
|
+
import { FluxFadeTransition } from '$flux/transition';
|
|
145
|
+
import FluxFormInput from '$flux/component/FluxFormInput.vue';
|
|
146
|
+
import FluxIcon from '$flux/component/FluxIcon.vue';
|
|
147
|
+
import FluxTag from '$flux/component/FluxTag.vue';
|
|
148
|
+
import Anchor from '$flux/component/primitive/Anchor.vue';
|
|
149
|
+
import AnchorPopup from '$flux/component/primitive/AnchorPopup.vue';
|
|
150
|
+
import $formStyle from '$flux/css/component/Form.module.scss';
|
|
151
|
+
import $style from '$flux/css/component/TreeViewSelect.module.scss';
|
|
152
|
+
|
|
153
|
+
type FlatNode = TreeFlatNode<FluxFormTreeViewSelectOption>;
|
|
154
|
+
|
|
155
|
+
defineOptions({
|
|
156
|
+
inheritAttrs: false
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const modelValue = defineModel<FluxFormTreeViewSelectValue>({ required: true });
|
|
160
|
+
|
|
161
|
+
const {
|
|
162
|
+
disabled: componentDisabled,
|
|
163
|
+
isMultiple,
|
|
164
|
+
isSearchable,
|
|
165
|
+
levelColors,
|
|
166
|
+
options,
|
|
167
|
+
placeholder
|
|
168
|
+
} = defineProps<{
|
|
169
|
+
readonly disabled?: boolean;
|
|
170
|
+
readonly isMultiple?: boolean;
|
|
171
|
+
readonly isSearchable?: boolean;
|
|
172
|
+
readonly levelColors?: (FluxColor | string)[];
|
|
173
|
+
readonly options: FluxFormTreeViewSelectOption[];
|
|
174
|
+
readonly placeholder?: string;
|
|
175
|
+
}>();
|
|
176
|
+
|
|
177
|
+
const disabled = useDisabled(toRef(() => componentDisabled));
|
|
178
|
+
const {id} = useFormFieldInjection();
|
|
179
|
+
const translate = useTranslate();
|
|
180
|
+
|
|
181
|
+
const anchorRef = useTemplateRef<ComponentPublicInstance>('anchor');
|
|
182
|
+
const anchorPopupRef = useTemplateRef('anchorPopup');
|
|
183
|
+
const nodeElementRefs = useTemplateRef<HTMLDivElement[]>('nodeElements');
|
|
184
|
+
const searchInputRef = useTemplateRef<ComponentPublicInstance<typeof FluxFormInput>>('searchInput');
|
|
185
|
+
|
|
186
|
+
const expandedIds = ref(new Set<string | number>());
|
|
187
|
+
const isPopupOpen = ref(false);
|
|
188
|
+
const searchQuery = ref('');
|
|
189
|
+
|
|
190
|
+
const focusElement = computed(() => unrefTemplateElement(searchInputRef) ?? unrefTemplateElement(anchorRef));
|
|
191
|
+
|
|
192
|
+
const selectedIds = computed(() => {
|
|
193
|
+
const value = unref(modelValue);
|
|
194
|
+
if (Array.isArray(value)) {
|
|
195
|
+
return new Set(value);
|
|
196
|
+
}
|
|
197
|
+
return new Set<string | number>(value !== undefined ? [value] : []);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const selectedOptions = computed(() => {
|
|
201
|
+
const ids = unref(selectedIds);
|
|
202
|
+
if (ids.size === 0) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
return flattenAll(options).filter(node => ids.has(node.id));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const visibleNodes = computed((): FlatNode[] => {
|
|
209
|
+
const query = unref(searchQuery).toLowerCase().trim();
|
|
210
|
+
if (query) {
|
|
211
|
+
return flattenAll(options)
|
|
212
|
+
.filter(node => node.label.toLowerCase().includes(query))
|
|
213
|
+
.map(node => ({...node, depth: 0, isLast: false, lineGuides: [] as boolean[]}));
|
|
214
|
+
}
|
|
215
|
+
return flattenVisible(options, 0, unref(expandedIds));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const {highlightedIndex, toggleExpand, onExpandClick, onKeyNavigate} = useTreeView({
|
|
219
|
+
expandedIds,
|
|
220
|
+
nodeElementRefs,
|
|
221
|
+
visibleNodes,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
useClickOutside([anchorRef, anchorPopupRef], isPopupOpen, () => isPopupOpen.value = false);
|
|
225
|
+
useClickOutside(anchorRef, isPopupOpen, () => unref(focusElement)?.focus());
|
|
226
|
+
|
|
227
|
+
function toggle(): void {
|
|
228
|
+
if (unref(disabled)) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
isPopupOpen.value = !unref(isPopupOpen);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function select(nodeId: string | number): void {
|
|
235
|
+
if (unref(isMultiple)) {
|
|
236
|
+
const current = [...unref(selectedIds)];
|
|
237
|
+
if (current.includes(nodeId)) {
|
|
238
|
+
modelValue.value = current.filter(v => v !== nodeId);
|
|
239
|
+
} else {
|
|
240
|
+
modelValue.value = [...current, nodeId];
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
modelValue.value = nodeId;
|
|
244
|
+
isPopupOpen.value = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function deselect(nodeId: string | number): void {
|
|
249
|
+
const current = unref(modelValue);
|
|
250
|
+
if (Array.isArray(current)) {
|
|
251
|
+
modelValue.value = current.filter(v => v !== nodeId);
|
|
252
|
+
}
|
|
253
|
+
nextTick(() => unrefTemplateElement(anchorRef)?.focus());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onNodeClick(node: FlatNode): void {
|
|
257
|
+
if (node.selectable !== false) {
|
|
258
|
+
select(node.id);
|
|
259
|
+
if (node.children?.length && !unref(expandedIds).has(node.id)) {
|
|
260
|
+
toggleExpand(node.id);
|
|
261
|
+
}
|
|
262
|
+
} else if (node.children?.length) {
|
|
263
|
+
toggleExpand(node.id);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function onKeyDown(evt: KeyboardEvent): void {
|
|
268
|
+
if (!unref(isPopupOpen)) {
|
|
269
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
270
|
+
evt.preventDefault();
|
|
271
|
+
isPopupOpen.value = true;
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
switch (evt.key) {
|
|
277
|
+
case 'Backspace':
|
|
278
|
+
if (!unref(isMultiple)) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (unref(searchQuery).length > 0 || unref(selectedIds).size === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const selectedList = [...unref(selectedIds)];
|
|
285
|
+
deselect(selectedList[selectedList.length - 1]);
|
|
286
|
+
return;
|
|
287
|
+
|
|
288
|
+
case 'Escape':
|
|
289
|
+
isPopupOpen.value = false;
|
|
290
|
+
nextTick(() => unrefTemplateElement(anchorRef)?.focus());
|
|
291
|
+
return;
|
|
292
|
+
|
|
293
|
+
case 'Tab':
|
|
294
|
+
isPopupOpen.value = false;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// When searchable, don't intercept letter keys — let the search input handle them
|
|
299
|
+
if (isSearchable && evt.key.length === 1 && evt.key !== 'Enter' && evt.key !== ' ') {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
onKeyNavigate(evt, onNodeClick);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
watch(isPopupOpen, isOpen => {
|
|
307
|
+
if (!isOpen) {
|
|
308
|
+
searchQuery.value = '';
|
|
309
|
+
highlightedIndex.value = INITIAL_HIGHLIGHTED_INDEX;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
autoExpandSelected();
|
|
314
|
+
|
|
315
|
+
nextTick(() => {
|
|
316
|
+
const ids = unref(selectedIds);
|
|
317
|
+
if (ids.size > 0) {
|
|
318
|
+
const firstSelectedIndex = unref(visibleNodes).findIndex(n => ids.has(n.id));
|
|
319
|
+
if (firstSelectedIndex >= 0) {
|
|
320
|
+
highlightedIndex.value = firstSelectedIndex;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
unref(focusElement)?.focus();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
watch(searchQuery, () => {
|
|
329
|
+
highlightedIndex.value = INITIAL_HIGHLIGHTED_INDEX;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
function autoExpandSelected(): void {
|
|
333
|
+
const ids = unref(selectedIds);
|
|
334
|
+
if (ids.size === 0) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const expanded = new Set(unref(expandedIds));
|
|
339
|
+
|
|
340
|
+
function expandAncestors(nodes: FluxFormTreeViewSelectOption[], targetId: string | number): boolean {
|
|
341
|
+
for (const node of nodes) {
|
|
342
|
+
if (node.id === targetId) {
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (node.children && expandAncestors(node.children, targetId)) {
|
|
346
|
+
expanded.add(node.id);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const selectedId of ids) {
|
|
354
|
+
expandAncestors(options, selectedId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
expandedIds.value = expanded;
|
|
358
|
+
}
|
|
359
|
+
</script>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
:class="$style.overflowBarOverflow">
|
|
22
22
|
<slot
|
|
23
23
|
name="overflow"
|
|
24
|
-
v-bind="{items: hiddenItems}"/>
|
|
24
|
+
v-bind="{hasOverflow: hiddenItems.length > 0, items: hiddenItems}"/>
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
const slots = defineSlots<{
|
|
55
55
|
default?(): any;
|
|
56
|
-
overflow?(props: { items: VNode[] }): any;
|
|
56
|
+
overflow?(props: { hasOverflow: boolean; items: VNode[] }): any;
|
|
57
57
|
}>();
|
|
58
58
|
|
|
59
59
|
const barRef = useTemplateRef('bar');
|
|
@@ -2,42 +2,38 @@
|
|
|
2
2
|
<router-link
|
|
3
3
|
v-if="componentType === 'route'"
|
|
4
4
|
v-bind="$attrs"
|
|
5
|
+
v-on="hoverListeners"
|
|
5
6
|
:rel="rel"
|
|
6
7
|
:target="target"
|
|
7
8
|
:to="to as any"
|
|
8
|
-
@click="onClick($event)"
|
|
9
|
-
@mouseenter="$emit('mouseenter', $event)"
|
|
10
|
-
@mouseleave="$emit('mouseleave', $event)">
|
|
9
|
+
@click="onClick($event)">
|
|
11
10
|
<slot/>
|
|
12
11
|
</router-link>
|
|
13
12
|
|
|
14
13
|
<a
|
|
15
14
|
v-else-if="componentType === 'link'"
|
|
16
15
|
v-bind="$attrs"
|
|
16
|
+
v-on="hoverListeners"
|
|
17
17
|
:href="href"
|
|
18
18
|
:rel="rel"
|
|
19
19
|
:target="target"
|
|
20
|
-
@click="onClick($event)"
|
|
21
|
-
@mouseenter="$emit('mouseenter', $event)"
|
|
22
|
-
@mouseleave="$emit('mouseleave', $event)">
|
|
20
|
+
@click="onClick($event)">
|
|
23
21
|
<slot/>
|
|
24
22
|
</a>
|
|
25
23
|
|
|
26
24
|
<button
|
|
27
25
|
v-else-if="componentType === 'button'"
|
|
28
26
|
v-bind="$attrs"
|
|
29
|
-
|
|
30
|
-
@
|
|
31
|
-
@mouseleave="$emit('mouseleave', $event)">
|
|
27
|
+
v-on="hoverListeners"
|
|
28
|
+
@click="onClick($event)">
|
|
32
29
|
<slot/>
|
|
33
30
|
</button>
|
|
34
31
|
|
|
35
32
|
<div
|
|
36
33
|
v-else
|
|
37
34
|
v-bind="$attrs"
|
|
38
|
-
|
|
39
|
-
@
|
|
40
|
-
@mouseleave="$emit('mouseleave', $event)">
|
|
35
|
+
v-on="hoverListeners"
|
|
36
|
+
@click="onClick">
|
|
41
37
|
<slot/>
|
|
42
38
|
</div>
|
|
43
39
|
</template>
|
|
@@ -65,6 +61,11 @@
|
|
|
65
61
|
default(): any;
|
|
66
62
|
}>();
|
|
67
63
|
|
|
64
|
+
const hoverListeners = {
|
|
65
|
+
onMouseenter: (evt: MouseEvent) => emit('mouseenter', evt),
|
|
66
|
+
onMouseleave: (evt: MouseEvent) => emit('mouseleave', evt)
|
|
67
|
+
};
|
|
68
|
+
|
|
68
69
|
function onClick(evt: MouseEvent, navigate?: (evt: MouseEvent) => void): void {
|
|
69
70
|
emit('click', evt);
|
|
70
71
|
|
|
@@ -41,9 +41,10 @@
|
|
|
41
41
|
<script
|
|
42
42
|
lang="ts"
|
|
43
43
|
setup>
|
|
44
|
+
import { useResizeObserver } from '@basmilius/common';
|
|
44
45
|
import type { FluxSegmentedControlItemObject } from '@flux-ui/types';
|
|
45
46
|
import { clsx } from 'clsx';
|
|
46
|
-
import {
|
|
47
|
+
import { onBeforeUnmount, ref, unref, useTemplateRef, watchEffect } from 'vue';
|
|
47
48
|
import FluxIcon from './FluxIcon.vue';
|
|
48
49
|
import $style from '$flux/css/component/SegmentedControl.module.scss';
|
|
49
50
|
|
|
@@ -61,17 +62,43 @@
|
|
|
61
62
|
|
|
62
63
|
const activeItemX = ref(0);
|
|
63
64
|
const activeItemWidth = ref(0);
|
|
65
|
+
const isAlive = ref(true);
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
onBeforeUnmount(() => {
|
|
68
|
+
isAlive.value = false;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
watchEffect(() => updateHighlight(unref(modelValue)), {flush: 'post'});
|
|
72
|
+
|
|
73
|
+
useResizeObserver(controlRef, () => updateHighlight(unref(modelValue)));
|
|
67
74
|
|
|
68
75
|
function activate(index: number): void {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
modelValue.value = index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateHighlight(index: number): void {
|
|
80
|
+
if (!isAlive.value) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
const itemRef = itemRefs.value?.[index];
|
|
85
|
+
const control = controlRef.value;
|
|
86
|
+
|
|
87
|
+
if (!itemRef || !control) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const width = itemRef.offsetWidth;
|
|
92
|
+
|
|
93
|
+
if (width === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const controlRect = control.getBoundingClientRect();
|
|
98
|
+
const itemRect = itemRef.getBoundingClientRect();
|
|
99
|
+
const scaleX = control.offsetWidth > 0 ? controlRect.width / control.offsetWidth : 1;
|
|
100
|
+
|
|
101
|
+
activeItemX.value = (itemRect.left - controlRect.left) / scaleX;
|
|
74
102
|
activeItemWidth.value = width;
|
|
75
|
-
modelValue.value = index;
|
|
76
103
|
}
|
|
77
104
|
</script>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
:class="$style.treeView"
|
|
4
|
+
role="tree"
|
|
5
|
+
tabindex="0"
|
|
6
|
+
@keydown="onKeyDown">
|
|
7
|
+
|
|
8
|
+
<div
|
|
9
|
+
v-for="(node, nodeIndex) in visibleNodes"
|
|
10
|
+
ref="nodeElements"
|
|
11
|
+
:key="node.id"
|
|
12
|
+
:class="clsx(
|
|
13
|
+
$style.treeNode,
|
|
14
|
+
nodeIndex === highlightedIndex && $style.isHighlighted
|
|
15
|
+
)"
|
|
16
|
+
role="treeitem"
|
|
17
|
+
tabindex="-1"
|
|
18
|
+
:aria-expanded="node.children?.length ? expandedIds.has(node.id) : undefined"
|
|
19
|
+
@click="onNodeClick(node)"
|
|
20
|
+
@dblclick="onNodeDblClick(node)">
|
|
21
|
+
|
|
22
|
+
<div :class="$style.treeNodeLineArea">
|
|
23
|
+
<span
|
|
24
|
+
v-for="(showLine, guideIndex) in node.lineGuides"
|
|
25
|
+
:key="`g-${guideIndex}`"
|
|
26
|
+
:class="[$style.treeIndent, showLine && $style.hasLine]"/>
|
|
27
|
+
|
|
28
|
+
<span
|
|
29
|
+
v-if="node.depth > 0"
|
|
30
|
+
:class="[$style.treeConnector, node.isLast && $style.isLast]"/>
|
|
31
|
+
|
|
32
|
+
<span
|
|
33
|
+
:class="$style.treeNodeExpand"
|
|
34
|
+
@click="onExpandClick(node, $event)">
|
|
35
|
+
<FluxIcon
|
|
36
|
+
v-if="node.children?.length"
|
|
37
|
+
:name="expandedIds.has(node.id) ? 'angle-down' : 'angle-right'"
|
|
38
|
+
:size="12"/>
|
|
39
|
+
</span>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<span
|
|
43
|
+
v-if="getLevelColor(node.depth, levelColors)"
|
|
44
|
+
:class="$style.treeNodeColorDot"
|
|
45
|
+
:style="{ background: getLevelColor(node.depth, levelColors) }"/>
|
|
46
|
+
|
|
47
|
+
<FluxIcon
|
|
48
|
+
v-if="node.icon"
|
|
49
|
+
:class="$style.treeNodeIcon"
|
|
50
|
+
:name="node.icon"
|
|
51
|
+
:size="16"/>
|
|
52
|
+
|
|
53
|
+
<span :class="$style.treeNodeLabel">{{ node.label }}</span>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<script
|
|
59
|
+
lang="ts"
|
|
60
|
+
setup>
|
|
61
|
+
import type { FluxColor, FluxTreeViewOption } from '@flux-ui/types';
|
|
62
|
+
import { clsx } from 'clsx';
|
|
63
|
+
import { computed, ref, unref, useTemplateRef } from 'vue';
|
|
64
|
+
import { flattenVisible, getLevelColor, useTreeView } from '$flux/composable/private';
|
|
65
|
+
import type { TreeFlatNode } from '$flux/composable/private';
|
|
66
|
+
import FluxIcon from '$flux/component/FluxIcon.vue';
|
|
67
|
+
import $style from '$flux/css/component/TreeView.module.scss';
|
|
68
|
+
|
|
69
|
+
type FlatNode = TreeFlatNode<FluxTreeViewOption>;
|
|
70
|
+
|
|
71
|
+
const emit = defineEmits<{
|
|
72
|
+
click: [option: FluxTreeViewOption];
|
|
73
|
+
dblclick: [option: FluxTreeViewOption];
|
|
74
|
+
}>();
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
levelColors,
|
|
78
|
+
options
|
|
79
|
+
} = defineProps<{
|
|
80
|
+
readonly levelColors?: (FluxColor | string)[];
|
|
81
|
+
readonly options: FluxTreeViewOption[];
|
|
82
|
+
}>();
|
|
83
|
+
|
|
84
|
+
const nodeElementRefs = useTemplateRef<HTMLDivElement[]>('nodeElements');
|
|
85
|
+
|
|
86
|
+
const expandedIds = ref(new Set<string | number>());
|
|
87
|
+
|
|
88
|
+
const visibleNodes = computed(() => flattenVisible(options, 0, unref(expandedIds)));
|
|
89
|
+
|
|
90
|
+
const {highlightedIndex, toggleExpand, onExpandClick, onKeyNavigate} = useTreeView({
|
|
91
|
+
expandedIds,
|
|
92
|
+
nodeElementRefs,
|
|
93
|
+
visibleNodes,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
function onNodeClick(node: FlatNode): void {
|
|
97
|
+
const index = unref(visibleNodes).findIndex(n => n.id === node.id);
|
|
98
|
+
highlightedIndex.value = index;
|
|
99
|
+
|
|
100
|
+
const {depth: _depth, isLast: _isLast, lineGuides: _lineGuides, ...option} = node;
|
|
101
|
+
emit('click', option as FluxTreeViewOption);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onNodeDblClick(node: FlatNode): void {
|
|
105
|
+
if (node.children?.length) {
|
|
106
|
+
toggleExpand(node.id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const {depth: _depth, isLast: _isLast, lineGuides: _lineGuides, ...option} = node;
|
|
110
|
+
emit('dblclick', option as FluxTreeViewOption);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onKeyDown(evt: KeyboardEvent): void {
|
|
114
|
+
onKeyNavigate(evt, onNodeClick);
|
|
115
|
+
}
|
|
116
|
+
</script>
|
package/src/component/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export { default as FluxFormSelectAsync } from './FluxFormSelectAsync.vue';
|
|
|
67
67
|
export { default as FluxFormSlider } from './FluxFormSlider.vue';
|
|
68
68
|
export { default as FluxFormTextArea } from './FluxFormTextArea.vue';
|
|
69
69
|
export { default as FluxFormTimeZonePicker } from './FluxFormTimeZonePicker.vue';
|
|
70
|
+
export { default as FluxFormTreeViewSelect } from './FluxFormTreeViewSelect.vue';
|
|
70
71
|
export { default as FluxGallery } from './FluxGallery.vue';
|
|
71
72
|
export { default as FluxGalleryItem } from './FluxGalleryItem.vue';
|
|
72
73
|
export { default as FluxGrid } from './FluxGrid.vue';
|
|
@@ -151,4 +152,5 @@ export { default as FluxToolbar } from './FluxToolbar.vue';
|
|
|
151
152
|
export { default as FluxToolbarGroup } from './FluxToolbarGroup.vue';
|
|
152
153
|
export { default as FluxTooltip } from './FluxTooltip.vue';
|
|
153
154
|
export { default as FluxTooltipProvider } from './FluxTooltipProvider.vue';
|
|
155
|
+
export { default as FluxTreeView } from './FluxTreeView.vue';
|
|
154
156
|
export { default as FluxWindow } from './FluxWindow.vue';
|