@flux-ui/components 3.0.0-next.31 → 3.0.0-next.33

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,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>
@@ -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';
@@ -1,2 +1,4 @@
1
1
  export { default as useFormSelect } from './useFormSelect';
2
2
  export { default as useTranslate } from './useTranslate';
3
+ export { flattenAll, flattenVisible, getLevelColor, INITIAL_HIGHLIGHTED_INDEX, useTreeView } from './useTreeView';
4
+ export type { TreeBaseOption, TreeFlatNode } from './useTreeView';
@@ -0,0 +1,186 @@
1
+ import type { FluxColor, FluxIconName } from '@flux-ui/types';
2
+ import { type ComputedRef, nextTick, ref, type Ref, unref, watch } from 'vue';
3
+
4
+ export type TreeBaseOption = {
5
+ readonly id: string | number;
6
+ readonly label: string;
7
+ readonly icon?: FluxIconName;
8
+ readonly children?: TreeBaseOption[];
9
+ };
10
+
11
+ export type TreeFlatNode<TOption extends TreeBaseOption = TreeBaseOption> = TOption & {
12
+ readonly depth: number;
13
+ readonly isLast: boolean;
14
+ readonly lineGuides: boolean[];
15
+ };
16
+
17
+ export const FLUX_COLORS: FluxColor[] = ['gray', 'primary', 'danger', 'info', 'success', 'warning'];
18
+ export const INITIAL_HIGHLIGHTED_INDEX = -1;
19
+
20
+ export function flattenVisible<TOption extends TreeBaseOption>(
21
+ nodes: TOption[],
22
+ depth: number,
23
+ expanded: Set<string | number>,
24
+ parentGuides: boolean[] = []
25
+ ): TreeFlatNode<TOption>[] {
26
+ return nodes.flatMap((node, index) => {
27
+ const isLast = index === nodes.length - 1;
28
+ const flatNode = {...node, depth, isLast, lineGuides: parentGuides} as TreeFlatNode<TOption>;
29
+
30
+ if (node.children?.length && expanded.has(node.id)) {
31
+ const childGuides = [...parentGuides, !isLast];
32
+ return [flatNode, ...flattenVisible(node.children as TOption[], depth + 1, expanded, childGuides)];
33
+ }
34
+
35
+ return [flatNode];
36
+ });
37
+ }
38
+
39
+ export function flattenAll<TOption extends TreeBaseOption>(
40
+ nodes: TOption[],
41
+ depth = 0
42
+ ): TreeFlatNode<TOption>[] {
43
+ return nodes.flatMap(node => [
44
+ {...node, depth, isLast: false, lineGuides: [] as boolean[]} as TreeFlatNode<TOption>,
45
+ ...(node.children ? flattenAll(node.children as TOption[], depth + 1) : [])
46
+ ]);
47
+ }
48
+
49
+ export function getLevelColor(depth: number, levelColors?: (FluxColor | string)[]): string | undefined {
50
+ if (!levelColors || depth >= levelColors.length) {
51
+ return undefined;
52
+ }
53
+
54
+ const color = levelColors[depth];
55
+
56
+ if (FLUX_COLORS.includes(color as FluxColor)) {
57
+ return `var(--${color}-600)`;
58
+ }
59
+
60
+ return color;
61
+ }
62
+
63
+ export function useTreeView<TNode extends TreeFlatNode>(params: {
64
+ readonly expandedIds: Ref<Set<string | number>>;
65
+ readonly nodeElementRefs: Readonly<Ref<HTMLDivElement[] | null | undefined>>;
66
+ readonly visibleNodes: ComputedRef<TNode[]>;
67
+ }): {
68
+ readonly highlightedIndex: Ref<number>;
69
+ toggleExpand: (nodeId: string | number) => void;
70
+ onExpandClick: (node: TNode, evt: MouseEvent) => void;
71
+ onKeyNavigate: (evt: KeyboardEvent, onActivate: (node: TNode) => void) => boolean;
72
+ } {
73
+ const highlightedIndex = ref(INITIAL_HIGHLIGHTED_INDEX);
74
+
75
+ function toggleExpand(nodeId: string | number): void {
76
+ const ids = new Set(unref(params.expandedIds));
77
+
78
+ if (ids.has(nodeId)) {
79
+ ids.delete(nodeId);
80
+ } else {
81
+ ids.add(nodeId);
82
+ }
83
+
84
+ params.expandedIds.value = ids;
85
+ }
86
+
87
+ function onExpandClick(node: TNode, evt: MouseEvent): void {
88
+ if (!node.children?.length) {
89
+ return;
90
+ }
91
+
92
+ evt.stopPropagation();
93
+ toggleExpand(node.id);
94
+ }
95
+
96
+ function onKeyNavigate(evt: KeyboardEvent, onActivate: (node: TNode) => void): boolean {
97
+ const nodes = unref(params.visibleNodes);
98
+ const current = unref(highlightedIndex);
99
+
100
+ switch (evt.key) {
101
+ case 'ArrowDown':
102
+ evt.preventDefault();
103
+ highlightedIndex.value = current === INITIAL_HIGHLIGHTED_INDEX
104
+ ? 0
105
+ : Math.min(nodes.length - 1, current + 1);
106
+ return true;
107
+
108
+ case 'ArrowUp':
109
+ evt.preventDefault();
110
+ highlightedIndex.value = current === INITIAL_HIGHLIGHTED_INDEX
111
+ ? nodes.length - 1
112
+ : Math.max(0, current - 1);
113
+ return true;
114
+
115
+ case 'ArrowRight':
116
+ evt.preventDefault();
117
+ if (current >= 0) {
118
+ const node = nodes[current];
119
+ if (node.children?.length) {
120
+ if (!unref(params.expandedIds).has(node.id)) {
121
+ toggleExpand(node.id);
122
+ } else if (current + 1 < nodes.length && nodes[current + 1].depth > node.depth) {
123
+ highlightedIndex.value = current + 1;
124
+ }
125
+ }
126
+ }
127
+ return true;
128
+
129
+ case 'ArrowLeft':
130
+ evt.preventDefault();
131
+ if (current >= 0) {
132
+ const node = nodes[current];
133
+ if (node.children?.length && unref(params.expandedIds).has(node.id)) {
134
+ toggleExpand(node.id);
135
+ } else if (node.depth > 0) {
136
+ for (let i = current - 1; i >= 0; i--) {
137
+ if (nodes[i].depth === node.depth - 1) {
138
+ highlightedIndex.value = i;
139
+ break;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ return true;
145
+
146
+ case 'Enter':
147
+ case ' ':
148
+ evt.preventDefault();
149
+ if (current >= 0) {
150
+ onActivate(nodes[current]);
151
+ }
152
+ return true;
153
+
154
+ default:
155
+ if (evt.key.length === 1) {
156
+ const lowerKey = evt.key.toLowerCase();
157
+ let matchIndex = nodes.findIndex((n, i) => i > current && n.label.toLowerCase().startsWith(lowerKey));
158
+ if (matchIndex < 0) {
159
+ matchIndex = nodes.findIndex(n => n.label.toLowerCase().startsWith(lowerKey));
160
+ }
161
+ if (matchIndex >= 0) {
162
+ highlightedIndex.value = matchIndex;
163
+ return true;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+ }
169
+
170
+ watch(highlightedIndex, index => {
171
+ if (index < 0) {
172
+ return;
173
+ }
174
+
175
+ nextTick(() => unref(params.nodeElementRefs)?.[index]?.scrollIntoView({block: 'nearest'}));
176
+ });
177
+
178
+ watch(params.visibleNodes, nodes => {
179
+ const current = unref(highlightedIndex);
180
+ if (current >= nodes.length) {
181
+ highlightedIndex.value = Math.max(INITIAL_HIGHLIGHTED_INDEX, nodes.length - 1);
182
+ }
183
+ });
184
+
185
+ return {highlightedIndex, toggleExpand, onExpandClick, onKeyNavigate};
186
+ }
@@ -0,0 +1,34 @@
1
+ @use '$flux/css/mixin';
2
+
3
+ .treeView {
4
+ display: flex;
5
+ flex-flow: column;
6
+ outline: 0;
7
+ }
8
+
9
+ .treeNode {
10
+ display: flex;
11
+ width: 100%;
12
+ min-height: 36px;
13
+ padding: 0 8px;
14
+ align-items: center;
15
+ border-radius: var(--radius-half);
16
+ color: var(--foreground);
17
+ cursor: pointer;
18
+ font-size: 14px;
19
+ gap: 8px;
20
+ outline: 0;
21
+ transition: 150ms var(--swift-out);
22
+ transition-property: background, color;
23
+ user-select: none;
24
+
25
+ @include mixin.hover {
26
+ background: var(--surface-hover);
27
+ }
28
+
29
+ &.isHighlighted {
30
+ background: var(--surface-hover);
31
+ }
32
+ }
33
+
34
+ @include mixin.tree-node-classes();
@@ -0,0 +1,73 @@
1
+ @use '$flux/css/mixin';
2
+
3
+ .treeViewSelectValue {
4
+ margin-left: 6px;
5
+ overflow: hidden;
6
+ text-overflow: ellipsis;
7
+ white-space: nowrap;
8
+ }
9
+
10
+ .treeViewSelectList {
11
+ display: flex;
12
+ flex-flow: column;
13
+ padding: 9px;
14
+ }
15
+
16
+ .treeViewSelectEmpty {
17
+ padding: 12px 6px;
18
+ color: var(--foreground-secondary);
19
+ font-size: 14px;
20
+ text-align: center;
21
+ }
22
+
23
+ .treeNode {
24
+ display: flex;
25
+ width: 100%;
26
+ min-height: 36px;
27
+ padding: 0 8px;
28
+ align-items: center;
29
+ border-radius: var(--radius-half);
30
+ color: var(--foreground);
31
+ font-size: 14px;
32
+ gap: 8px;
33
+ outline: 0;
34
+ transition: 150ms var(--swift-out);
35
+ transition-property: background, color;
36
+ user-select: none;
37
+
38
+ &.isSelectable,
39
+ &.isExpandable {
40
+ cursor: pointer;
41
+
42
+ @include mixin.hover {
43
+ background: var(--surface-hover);
44
+ }
45
+ }
46
+
47
+ &.isSelected {
48
+ background: var(--primary-50);
49
+ color: var(--primary-700);
50
+ font-weight: 600;
51
+ }
52
+
53
+ &.isSelected.isSelectable {
54
+ @include mixin.hover {
55
+ background: var(--primary-100);
56
+ }
57
+ }
58
+
59
+ &.isHighlighted:not(.isSelected) {
60
+ background: var(--surface-hover);
61
+ }
62
+
63
+ &.isHighlighted.isSelected {
64
+ background: var(--primary-100);
65
+ }
66
+ }
67
+
68
+ .treeNodeCheck {
69
+ color: var(--primary-600);
70
+ flex-shrink: 0;
71
+ }
72
+
73
+ @include mixin.tree-node-classes();
@@ -1,3 +1,4 @@
1
1
  @forward 'breakpoints';
2
2
  @forward 'focus-ring';
3
3
  @forward 'hover';
4
+ @forward 'tree-node';
@@ -0,0 +1,94 @@
1
+ @mixin tree-node-classes() {
2
+ // Wrapper for guide lines + expand button — no internal gap so lines align perfectly
3
+ .treeNodeLineArea {
4
+ display: flex;
5
+ align-self: stretch;
6
+ flex-shrink: 0;
7
+ }
8
+
9
+ // Vertical guide line for each ancestor level
10
+ .treeIndent {
11
+ position: relative;
12
+ width: 20px;
13
+ align-self: stretch;
14
+ flex-shrink: 0;
15
+ pointer-events: none;
16
+
17
+ &.hasLine::before {
18
+ content: '';
19
+ position: absolute;
20
+ top: 0;
21
+ bottom: 0;
22
+ left: 9px;
23
+ width: 1px;
24
+ background: var(--surface-stroke);
25
+ }
26
+ }
27
+
28
+ // Connector from parent to current node, with rounded elbow
29
+ .treeConnector {
30
+ position: relative;
31
+ width: 20px;
32
+ align-self: stretch;
33
+ flex-shrink: 0;
34
+ pointer-events: none;
35
+
36
+ // Non-last siblings: full-height vertical continuation line
37
+ &:not(.isLast)::before {
38
+ content: '';
39
+ position: absolute;
40
+ top: 0;
41
+ bottom: 0;
42
+ left: 9px;
43
+ width: 1px;
44
+ background: var(--surface-stroke);
45
+ }
46
+
47
+ // Rounded elbow: vertical segment from top to midpoint, curves right to expand area
48
+ // Overlaps the ::before vertical line for non-last nodes (same position, same color)
49
+ &::after {
50
+ content: '';
51
+ position: absolute;
52
+ top: 0;
53
+ bottom: 50%;
54
+ left: 9px;
55
+ right: 0;
56
+ border-left: 1px solid var(--surface-stroke);
57
+ border-bottom: 1px solid var(--surface-stroke);
58
+ border-bottom-left-radius: 6px;
59
+ }
60
+ }
61
+
62
+ .treeNodeExpand {
63
+ display: flex;
64
+ width: 16px;
65
+ flex-shrink: 0;
66
+ align-items: center;
67
+ justify-content: center;
68
+ color: var(--foreground-secondary);
69
+
70
+ &:not(:empty) {
71
+ cursor: pointer;
72
+ }
73
+ }
74
+
75
+ .treeNodeColorDot {
76
+ display: block;
77
+ width: 8px;
78
+ height: 8px;
79
+ border-radius: 50%;
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ .treeNodeIcon {
84
+ color: var(--foreground-secondary);
85
+ flex-shrink: 0;
86
+ }
87
+
88
+ .treeNodeLabel {
89
+ flex: 1;
90
+ overflow: hidden;
91
+ text-overflow: ellipsis;
92
+ white-space: nowrap;
93
+ }
94
+ }