@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.
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.31",
4
+ "version": "3.0.0-next.33",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "funding": "https://github.com/sponsors/basmilius",
@@ -51,10 +51,10 @@
51
51
  "typings": "./dist/index.d.ts",
52
52
  "sideEffects": false,
53
53
  "dependencies": {
54
- "@basmilius/common": "^3.11.0",
55
- "@basmilius/utils": "^3.11.0",
56
- "@flux-ui/internals": "3.0.0-next.31",
57
- "@flux-ui/types": "3.0.0-next.31",
54
+ "@basmilius/common": "^3.11.1",
55
+ "@basmilius/utils": "^3.11.1",
56
+ "@flux-ui/internals": "3.0.0-next.33",
57
+ "@flux-ui/types": "3.0.0-next.33",
58
58
  "@fortawesome/fontawesome-common-types": "^7.2.0",
59
59
  "clsx": "^2.1.1",
60
60
  "imask": "^7.6.1",
@@ -63,16 +63,16 @@
63
63
  "vue": "^3.6.0-beta.7"
64
64
  },
65
65
  "devDependencies": {
66
- "@basmilius/vite-preset": "^3.11.0",
66
+ "@basmilius/vite-preset": "^3.11.1",
67
67
  "@types/lodash-es": "^4.17.12",
68
68
  "@types/luxon": "^3.7.1",
69
- "@types/node": "^25.3.3",
69
+ "@types/node": "^25.3.5",
70
70
  "@vitejs/plugin-vue": "^6.0.4",
71
71
  "@vue/tsconfig": "^0.9.0",
72
72
  "pinia": "^3.0.4",
73
73
  "sass-embedded": "^1.97.3",
74
74
  "typescript": "^5.9.3",
75
- "vite": "^8.0.0-beta.16",
75
+ "vite": "^8.0.0-beta.18",
76
76
  "vue-tsc": "^3.2.5"
77
77
  }
78
78
  }
@@ -10,9 +10,9 @@
10
10
  :id="id"/>
11
11
 
12
12
  <button
13
+ aria-hidden="true"
13
14
  :class="$style.checkboxElement"
14
- role="checkbox"
15
- :aria-checked="modelValue ?? false">
15
+ tabindex="-1">
16
16
  <FluxIcon
17
17
  v-if="isIndeterminate"
18
18
  name="minus"
@@ -10,6 +10,7 @@
10
10
  <template #default="{ buttons, filters, menuItems }">
11
11
  <div :class="$style.filterBar">
12
12
  <FluxFormInput
13
+ v-if="isSearchable"
13
14
  v-model="modelSearch"
14
15
  :class="$style.filterBarSearch"
15
16
  icon-leading="magnifying-glass"
@@ -115,7 +115,7 @@
115
115
  localValue.value = (unref(modelValue) - min) / (max - min);
116
116
  });
117
117
 
118
- watch(([() => max, () => min, localValue, () => step]), () => {
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
- @click="onClick($event)"
30
- @mouseenter="$emit('mouseenter', $event)"
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
- @click="onClick"
39
- @mouseenter="$emit('mouseenter', $event)"
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 { onMounted, onUpdated, ref, unref, useTemplateRef } from 'vue';
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
- onMounted(() => activate(unref(modelValue)));
66
- onUpdated(() => activate(unref(modelValue)));
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
- const itemRef = itemRefs.value![index];
70
- const {left: controlX} = controlRef.value!.getBoundingClientRect();
71
- const {width, left: x} = itemRef.getBoundingClientRect();
76
+ modelValue.value = index;
77
+ }
78
+
79
+ function updateHighlight(index: number): void {
80
+ if (!isAlive.value) {
81
+ return;
82
+ }
72
83
 
73
- activeItemX.value = x - controlX - 1;
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>