@flux-ui/components 3.1.2 → 3.1.4

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.
Files changed (72) hide show
  1. package/dist/component/FluxAvatarGroup.vue.d.ts +17 -0
  2. package/dist/component/FluxButton.vue.d.ts +2 -0
  3. package/dist/component/FluxContextMenu.vue.d.ts +26 -0
  4. package/dist/component/FluxDataTable.vue.d.ts +20 -10
  5. package/dist/component/FluxDescriptionItem.vue.d.ts +19 -0
  6. package/dist/component/FluxDescriptionList.vue.d.ts +17 -0
  7. package/dist/component/FluxFlyout.vue.d.ts +9 -2
  8. package/dist/component/FluxFormCombobox.vue.d.ts +20 -0
  9. package/dist/component/FluxFormRating.vue.d.ts +21 -0
  10. package/dist/component/FluxFormTagsInput.vue.d.ts +27 -0
  11. package/dist/component/FluxFormTextArea.vue.d.ts +6 -1
  12. package/dist/component/FluxInlineEdit.vue.d.ts +41 -0
  13. package/dist/component/FluxMenu.vue.d.ts +1 -0
  14. package/dist/component/FluxMenuFlyout.vue.d.ts +22 -0
  15. package/dist/component/FluxTableCell.vue.d.ts +1 -0
  16. package/dist/component/FluxTour.vue.d.ts +35 -0
  17. package/dist/component/FluxTourItem.vue.d.ts +18 -0
  18. package/dist/component/FluxVirtualScroller.vue.d.ts +27 -0
  19. package/dist/component/index.d.ts +12 -0
  20. package/dist/component/primitive/AnchorPopup.vue.d.ts +7 -1
  21. package/dist/component/primitive/SelectBase.vue.d.ts +3 -0
  22. package/dist/composable/private/index.d.ts +1 -0
  23. package/dist/composable/private/useMenuFlyout.d.ts +42 -0
  24. package/dist/data/di.d.ts +35 -0
  25. package/dist/data/i18n.d.ts +7 -0
  26. package/dist/index.css +449 -5
  27. package/dist/index.js +2156 -408
  28. package/dist/index.js.map +1 -1
  29. package/package.json +7 -7
  30. package/src/component/FluxAvatarGroup.vue +52 -0
  31. package/src/component/FluxButton.vue +3 -0
  32. package/src/component/FluxContextMenu.vue +134 -0
  33. package/src/component/FluxDataTable.vue +113 -32
  34. package/src/component/FluxDescriptionItem.vue +43 -0
  35. package/src/component/FluxDescriptionList.vue +37 -0
  36. package/src/component/FluxDestructiveButton.vue +2 -1
  37. package/src/component/FluxFlyout.vue +16 -3
  38. package/src/component/FluxFormCombobox.vue +98 -0
  39. package/src/component/FluxFormRating.vue +172 -0
  40. package/src/component/FluxFormTagsInput.vue +249 -0
  41. package/src/component/FluxFormTextArea.vue +16 -1
  42. package/src/component/FluxInlineEdit.vue +176 -0
  43. package/src/component/FluxMenu.vue +13 -3
  44. package/src/component/FluxMenuFlyout.vue +118 -0
  45. package/src/component/FluxPrimaryButton.vue +2 -1
  46. package/src/component/FluxPrimaryLinkButton.vue +2 -1
  47. package/src/component/FluxPublishButton.vue +2 -1
  48. package/src/component/FluxSecondaryButton.vue +2 -1
  49. package/src/component/FluxSecondaryLinkButton.vue +2 -1
  50. package/src/component/FluxTableCell.vue +2 -0
  51. package/src/component/FluxTour.vue +332 -0
  52. package/src/component/FluxTourItem.vue +27 -0
  53. package/src/component/FluxVirtualScroller.vue +96 -0
  54. package/src/component/index.ts +12 -0
  55. package/src/component/primitive/AnchorPopup.vue +27 -0
  56. package/src/component/primitive/SelectBase.vue +37 -2
  57. package/src/composable/private/index.ts +1 -0
  58. package/src/composable/private/useMenuFlyout.ts +417 -0
  59. package/src/css/component/AvatarGroup.module.scss +22 -0
  60. package/src/css/component/ContextMenu.module.scss +17 -0
  61. package/src/css/component/DescriptionList.module.scss +98 -0
  62. package/src/css/component/Form.module.scss +51 -0
  63. package/src/css/component/FormRating.module.scss +47 -0
  64. package/src/css/component/InlineEdit.module.scss +45 -0
  65. package/src/css/component/Menu.module.scss +4 -1
  66. package/src/css/component/MenuFlyout.module.scss +38 -0
  67. package/src/css/component/Table.module.scss +16 -0
  68. package/src/css/component/Tour.module.scss +108 -0
  69. package/src/css/component/VirtualScroller.module.scss +17 -0
  70. package/src/css/mixin/button-active.scss +3 -1
  71. package/src/data/di.ts +40 -0
  72. package/src/data/i18n.ts +7 -0
@@ -0,0 +1,332 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <FluxFadeTransition>
4
+ <div
5
+ v-if="active"
6
+ :class="$style.tour">
7
+ <div
8
+ v-if="targetRect"
9
+ :class="$style.tourSpotlight"
10
+ :style="{
11
+ '--x': `${targetRect.x - maskPadding}px`,
12
+ '--y': `${targetRect.y - maskPadding}px`,
13
+ '--w': `${targetRect.width + maskPadding * 2}px`,
14
+ '--h': `${targetRect.height + maskPadding * 2}px`
15
+ }"/>
16
+
17
+ <AnchorPopup
18
+ v-if="targetRect && currentItem"
19
+ ref="popup"
20
+ :anchor="virtualAnchor"
21
+ :class="clsx($style.tourPopover, isStepping && $style.isStepping)"
22
+ :position="currentItem.position ?? 'bottom'"
23
+ aria-modal="true"
24
+ role="dialog">
25
+ <FluxPane :class="$style.tourPane">
26
+ <div
27
+ ref="bodyViewport"
28
+ :class="$style.tourBodyViewport">
29
+ <Transition
30
+ @after-enter="onBodyAfterEnter"
31
+ @enter="onBodyEnter">
32
+ <div
33
+ :key="step"
34
+ :class="$style.tourBody">
35
+ <strong
36
+ v-if="currentItem.title"
37
+ :class="$style.tourTitle">
38
+ {{ currentItem.title }}
39
+ </strong>
40
+
41
+ <div :class="$style.tourContent">
42
+ <VNodeRenderer :vnode="currentContent"/>
43
+ </div>
44
+ </div>
45
+ </Transition>
46
+ </div>
47
+
48
+ <div :class="$style.tourFooter">
49
+ <span :class="$style.tourProgress">
50
+ {{ step + 1 }} / {{ total }}
51
+ </span>
52
+
53
+ <FluxSpacer/>
54
+
55
+ <button
56
+ :class="$style.tourSkip"
57
+ type="button"
58
+ @click="skip">
59
+ {{ translate('flux.skip') }}
60
+ </button>
61
+
62
+ <FluxSecondaryButton
63
+ v-if="step > 0"
64
+ :aria-label="translate('flux.previous')"
65
+ icon-leading="angle-left"
66
+ size="small"
67
+ @click="previous"/>
68
+
69
+ <FluxPrimaryButton
70
+ v-if="step < total - 1"
71
+ :aria-label="translate('flux.next')"
72
+ icon-leading="angle-right"
73
+ size="small"
74
+ @click="next"/>
75
+
76
+ <FluxPrimaryButton
77
+ v-else
78
+ :aria-label="translate('flux.done')"
79
+ icon-leading="check"
80
+ size="small"
81
+ @click="next"/>
82
+ </div>
83
+ </FluxPane>
84
+ </AnchorPopup>
85
+ </div>
86
+ </FluxFadeTransition>
87
+ </Teleport>
88
+ </template>
89
+
90
+ <script
91
+ lang="ts"
92
+ setup>
93
+ import { isHtmlElement } from '@basmilius/utils';
94
+ import { flattenVNodeTree, isSSR, useEventListener } from '@flux-ui/internals';
95
+ import { clsx } from 'clsx';
96
+ import { type ComponentPublicInstance, computed, Fragment, h, nextTick, ref, useTemplateRef, type VNode, watch } from 'vue';
97
+ import { AnchorPopup, VNodeRenderer } from '~flux/components/component/primitive';
98
+ import { useTranslate } from '~flux/components/composable/private';
99
+ import { FluxFadeTransition } from '~flux/components/transition';
100
+ import FluxPane from './FluxPane.vue';
101
+ import FluxPrimaryButton from './FluxPrimaryButton.vue';
102
+ import FluxSecondaryButton from './FluxSecondaryButton.vue';
103
+ import FluxSpacer from './FluxSpacer.vue';
104
+ import FluxTourItem from './FluxTourItem.vue';
105
+ import $style from '~flux/components/css/component/Tour.module.scss';
106
+
107
+ type FluxTourPosition =
108
+ | 'top' | 'top-left' | 'top-right'
109
+ | 'left' | 'left-top' | 'left-bottom'
110
+ | 'right' | 'right-top' | 'right-bottom'
111
+ | 'bottom' | 'bottom-left' | 'bottom-right';
112
+
113
+ type TourItem = {
114
+ readonly target: string | (() => HTMLElement | null);
115
+ readonly title?: string;
116
+ readonly position?: FluxTourPosition;
117
+ readonly content?: () => VNode[];
118
+ };
119
+
120
+ const active = defineModel<boolean>('active', {
121
+ required: true
122
+ });
123
+
124
+ const step = defineModel<number>('step', {
125
+ default: 0
126
+ });
127
+
128
+ const {
129
+ maskPadding = 8,
130
+ root
131
+ } = defineProps<{
132
+ readonly maskPadding?: number;
133
+ readonly root?: string | HTMLElement | (() => HTMLElement | null);
134
+ }>();
135
+
136
+ const emit = defineEmits<{
137
+ finish: [];
138
+ skip: [];
139
+ next: [number];
140
+ prev: [number];
141
+ }>();
142
+
143
+ const slots = defineSlots<{
144
+ default(): VNode[];
145
+ }>();
146
+
147
+ const translate = useTranslate();
148
+
149
+ const popup = useTemplateRef<{ reposition(): void; resize(): void }>('popup');
150
+ const bodyViewport = useTemplateRef<HTMLElement>('bodyViewport');
151
+ const targetRect = ref<DOMRect | null>(null);
152
+ const isStepping = ref(false);
153
+
154
+ let steppingTimer: ReturnType<typeof setTimeout> | undefined;
155
+
156
+ const items = computed<readonly TourItem[]>(() => {
157
+ const vnodes = flattenVNodeTree(slots.default?.() ?? []);
158
+ const out: TourItem[] = [];
159
+
160
+ for (const vnode of vnodes) {
161
+ if (vnode.type !== FluxTourItem) {
162
+ continue;
163
+ }
164
+
165
+ const props = vnode.props ?? {};
166
+ const children = vnode.children as { default?: () => VNode[] } | null;
167
+
168
+ out.push({
169
+ target: props.target,
170
+ title: props.title,
171
+ position: props.position,
172
+ content: children?.default
173
+ });
174
+ }
175
+
176
+ return out;
177
+ });
178
+
179
+ const total = computed(() => items.value.length);
180
+ const currentItem = computed(() => items.value[step.value]);
181
+ const currentContent = computed(() => {
182
+ const content = currentItem.value?.content?.();
183
+ return content ? h(Fragment, content) : null;
184
+ });
185
+
186
+ const virtualAnchor = {
187
+ $el: {
188
+ getBoundingClientRect: () => {
189
+ const rect = targetRect.value;
190
+
191
+ if (!rect) {
192
+ return new DOMRect(0, 0, 0, 0);
193
+ }
194
+
195
+ return new DOMRect(rect.x - maskPadding, rect.y - maskPadding, rect.width + maskPadding * 2, rect.height + maskPadding * 2);
196
+ }
197
+ }
198
+ } as unknown as ComponentPublicInstance;
199
+
200
+ function resolveScope(): ParentNode {
201
+ if (!root) {
202
+ return document;
203
+ }
204
+
205
+ if (typeof root === 'string') {
206
+ return document.querySelector(root) ?? document;
207
+ }
208
+
209
+ if (typeof root === 'function') {
210
+ return root() ?? document;
211
+ }
212
+
213
+ return root;
214
+ }
215
+
216
+ function resolveTarget(): HTMLElement | null {
217
+ const current = items.value[step.value];
218
+
219
+ if (!current) {
220
+ return null;
221
+ }
222
+
223
+ return typeof current.target === 'function' ? current.target() : resolveScope().querySelector<HTMLElement>(current.target);
224
+ }
225
+
226
+ function measure(): void {
227
+ const element = resolveTarget();
228
+
229
+ if (!element) {
230
+ targetRect.value = null;
231
+ return;
232
+ }
233
+
234
+ element.scrollIntoView({block: 'center', inline: 'center'});
235
+
236
+ requestAnimationFrame(() => {
237
+ const resolved = resolveTarget();
238
+ targetRect.value = resolved ? resolved.getBoundingClientRect() : null;
239
+ popup.value?.reposition();
240
+ });
241
+ }
242
+
243
+ function next(): void {
244
+ if (step.value < total.value - 1) {
245
+ step.value++;
246
+ emit('next', step.value);
247
+ return;
248
+ }
249
+
250
+ finish();
251
+ }
252
+
253
+ function previous(): void {
254
+ if (step.value > 0) {
255
+ step.value--;
256
+ emit('prev', step.value);
257
+ }
258
+ }
259
+
260
+ function skip(): void {
261
+ active.value = false;
262
+ emit('skip');
263
+ }
264
+
265
+ function finish(): void {
266
+ active.value = false;
267
+ emit('finish');
268
+ }
269
+
270
+ function onBodyEnter(el: Element): void {
271
+ if (!isHtmlElement(el) || !bodyViewport.value) {
272
+ return;
273
+ }
274
+
275
+ const height = el.offsetHeight;
276
+
277
+ requestAnimationFrame(() => {
278
+ if (bodyViewport.value) {
279
+ bodyViewport.value.style.height = `${height}px`;
280
+ }
281
+ });
282
+ }
283
+
284
+ function onBodyAfterEnter(): void {
285
+ if (bodyViewport.value) {
286
+ bodyViewport.value.style.height = 'auto';
287
+ }
288
+ }
289
+
290
+ watch(step, (newStep, oldStep) => {
291
+ if (active.value && newStep !== oldStep) {
292
+ if (bodyViewport.value) {
293
+ bodyViewport.value.style.height = `${bodyViewport.value.offsetHeight}px`;
294
+ }
295
+
296
+ isStepping.value = true;
297
+ clearTimeout(steppingTimer);
298
+ steppingTimer = setTimeout(() => {
299
+ isStepping.value = false;
300
+ }, 300);
301
+ }
302
+ });
303
+
304
+ watch([active, step], async () => {
305
+ if (!active.value) {
306
+ targetRect.value = null;
307
+ isStepping.value = false;
308
+ clearTimeout(steppingTimer);
309
+ return;
310
+ }
311
+
312
+ await nextTick();
313
+ measure();
314
+ }, {immediate: true});
315
+
316
+ if (!isSSR) {
317
+ const onRemeasure = () => {
318
+ if (active.value && targetRect.value) {
319
+ const resolved = resolveTarget();
320
+ targetRect.value = resolved ? resolved.getBoundingClientRect() : null;
321
+ }
322
+ };
323
+
324
+ useEventListener(ref(window), 'resize', onRemeasure);
325
+ useEventListener(ref(window), 'scroll', onRemeasure, {capture: true, passive: true});
326
+ useEventListener(ref(window), 'keydown', (evt: KeyboardEvent) => {
327
+ if (active.value && evt.key === 'Escape') {
328
+ skip();
329
+ }
330
+ });
331
+ }
332
+ </script>
@@ -0,0 +1,27 @@
1
+ <script
2
+ lang="ts"
3
+ setup>
4
+ import type { VNode } from 'vue';
5
+
6
+ type FluxTourPosition =
7
+ | 'top' | 'top-left' | 'top-right'
8
+ | 'left' | 'left-top' | 'left-bottom'
9
+ | 'right' | 'right-top' | 'right-bottom'
10
+ | 'bottom' | 'bottom-left' | 'bottom-right';
11
+
12
+ defineProps<{
13
+ readonly target: string | (() => HTMLElement | null);
14
+ readonly title?: string;
15
+ readonly position?: FluxTourPosition;
16
+ }>();
17
+
18
+ defineSlots<{
19
+ default(): VNode[];
20
+ }>();
21
+ </script>
22
+
23
+ <template>
24
+ <span
25
+ aria-hidden="true"
26
+ style="display: none"/>
27
+ </template>
@@ -0,0 +1,96 @@
1
+ <template>
2
+ <div
3
+ ref="viewport"
4
+ :class="$style.virtualScroller">
5
+ <div
6
+ :class="$style.virtualScrollerSpacer"
7
+ :style="{
8
+ height: `${totalHeight}px`
9
+ }">
10
+ <div
11
+ :class="$style.virtualScrollerWindow"
12
+ :style="{
13
+ transform: `translateY(${offsetY}px)`
14
+ }">
15
+ <div
16
+ v-for="entry of visibleEntries"
17
+ :key="entry.index"
18
+ :style="{
19
+ height: `${estimatedItemHeight}px`
20
+ }">
21
+ <slot
22
+ :index="entry.index"
23
+ :item="entry.item"/>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </template>
29
+
30
+ <script
31
+ lang="ts"
32
+ setup
33
+ generic="T">
34
+ import { computed, onMounted, onUnmounted, ref, useTemplateRef, type VNode } from 'vue';
35
+ import { useScrollPosition } from '@flux-ui/internals';
36
+ import $style from '~flux/components/css/component/VirtualScroller.module.scss';
37
+
38
+ const {
39
+ estimatedItemHeight = 40,
40
+ items,
41
+ overscan = 4
42
+ } = defineProps<{
43
+ readonly estimatedItemHeight?: number;
44
+ readonly items: T[];
45
+ readonly overscan?: number;
46
+ }>();
47
+
48
+ defineSlots<{
49
+ default(props: {readonly index: number; readonly item: T}): VNode[];
50
+ }>();
51
+
52
+ const viewport = useTemplateRef<HTMLElement>('viewport');
53
+ const {y} = useScrollPosition(viewport);
54
+
55
+ const viewportHeight = ref(0);
56
+
57
+ const totalHeight = computed(() => items.length * estimatedItemHeight);
58
+ const startIndex = computed(() => Math.max(0, Math.floor(y.value / estimatedItemHeight) - overscan));
59
+ const endIndex = computed(() => Math.min(items.length, Math.ceil((y.value + viewportHeight.value) / estimatedItemHeight) + overscan));
60
+ const offsetY = computed(() => startIndex.value * estimatedItemHeight);
61
+
62
+ const visibleEntries = computed(() => {
63
+ const entries: {index: number; item: T}[] = [];
64
+
65
+ for (let index = startIndex.value; index < endIndex.value; ++index) {
66
+ entries.push({index, item: items[index]});
67
+ }
68
+
69
+ return entries;
70
+ });
71
+
72
+ let observer: ResizeObserver | null = null;
73
+
74
+ onMounted(() => {
75
+ const element = viewport.value;
76
+
77
+ if (!element) {
78
+ return;
79
+ }
80
+
81
+ viewportHeight.value = element.clientHeight;
82
+
83
+ observer = new ResizeObserver(() => {
84
+ if (viewport.value) {
85
+ viewportHeight.value = viewport.value.clientHeight;
86
+ }
87
+ });
88
+
89
+ observer.observe(element);
90
+ });
91
+
92
+ onUnmounted(() => {
93
+ observer?.disconnect();
94
+ observer = null;
95
+ });
96
+ </script>
@@ -7,6 +7,7 @@ export { default as FluxAdaptiveSlot } from './FluxAdaptiveSlot.vue';
7
7
  export { default as FluxAnimatedColors } from './FluxAnimatedColors.vue';
8
8
  export { default as FluxAspectRatio } from './FluxAspectRatio.vue';
9
9
  export { default as FluxAvatar } from './FluxAvatar.vue';
10
+ export { default as FluxAvatarGroup } from './FluxAvatarGroup.vue';
10
11
  export { default as FluxBadge } from './FluxBadge.vue';
11
12
  export { default as FluxBadgeStack } from './FluxBadgeStack.vue';
12
13
  export { default as FluxBorderBeam } from './FluxBorderBeam.vue';
@@ -28,8 +29,11 @@ export { default as FluxComment } from './FluxComment.vue';
28
29
  export { default as FluxColorPicker } from './FluxColorPicker.vue';
29
30
  export { default as FluxColorSelect } from './FluxColorSelect.vue';
30
31
  export { default as FluxContainer } from './FluxContainer.vue';
32
+ export { default as FluxContextMenu } from './FluxContextMenu.vue';
31
33
  export { default as FluxDataTable } from './FluxDataTable.vue';
32
34
  export { default as FluxDatePicker } from './FluxDatePicker.vue';
35
+ export { default as FluxDescriptionItem } from './FluxDescriptionItem.vue';
36
+ export { default as FluxDescriptionList } from './FluxDescriptionList.vue';
33
37
  export { default as FluxDestructiveButton } from './FluxDestructiveButton.vue';
34
38
  export { default as FluxDisabled } from './FluxDisabled.vue';
35
39
  export { default as FluxDivider } from './FluxDivider.vue';
@@ -59,6 +63,7 @@ export { default as FluxForm } from './FluxForm.vue';
59
63
  export { default as FluxFormCheckbox } from './FluxFormCheckbox.vue';
60
64
  export { default as FluxFormCheckboxGroup } from './FluxFormCheckboxGroup.vue';
61
65
  export { default as FluxFormColumn } from './FluxFormColumn.vue';
66
+ export { default as FluxFormCombobox } from './FluxFormCombobox.vue';
62
67
  export { default as FluxFormDateInput } from './FluxFormDateInput.vue';
63
68
  export { default as FluxFormDateRangeInput } from './FluxFormDateRangeInput.vue';
64
69
  export { default as FluxFormDateTimeInput } from './FluxFormDateTimeInput.vue';
@@ -73,11 +78,13 @@ export { default as FluxFormPinInput } from './FluxFormPinInput.vue';
73
78
  export { default as FluxFormRadio } from './FluxFormRadio.vue';
74
79
  export { default as FluxFormRadioGroup } from './FluxFormRadioGroup.vue';
75
80
  export { default as FluxFormRangeSlider } from './FluxFormRangeSlider.vue';
81
+ export { default as FluxFormRating } from './FluxFormRating.vue';
76
82
  export { default as FluxFormRow } from './FluxFormRow.vue';
77
83
  export { default as FluxFormSection } from './FluxFormSection.vue';
78
84
  export { default as FluxFormSelect } from './FluxFormSelect.vue';
79
85
  export { default as FluxFormSelectAsync } from './FluxFormSelectAsync.vue';
80
86
  export { default as FluxFormSlider } from './FluxFormSlider.vue';
87
+ export { default as FluxFormTagsInput } from './FluxFormTagsInput.vue';
81
88
  export { default as FluxFormTextArea } from './FluxFormTextArea.vue';
82
89
  export { default as FluxFormTimeZonePicker } from './FluxFormTimeZonePicker.vue';
83
90
  export { default as FluxFormTreeViewSelect } from './FluxFormTreeViewSelect.vue';
@@ -88,6 +95,7 @@ export { default as FluxGridColumn } from './FluxGridColumn.vue';
88
95
  export { default as FluxGridPattern } from './FluxGridPattern.vue';
89
96
  export { default as FluxIcon } from './FluxIcon.vue';
90
97
  export { default as FluxInfo } from './FluxInfo.vue';
98
+ export { default as FluxInlineEdit } from './FluxInlineEdit.vue';
91
99
  export { default as FluxKanban } from './FluxKanban.vue';
92
100
  export { default as FluxKanbanColumn } from './FluxKanbanColumn.vue';
93
101
  export { default as FluxKanbanItem } from './FluxKanbanItem.vue';
@@ -102,6 +110,7 @@ export { default as FluxLayerPaneSecondary } from './FluxLayerPaneSecondary.vue'
102
110
  export { default as FluxLink } from './FluxLink.vue';
103
111
  export { default as FluxMenu } from './FluxMenu.vue';
104
112
  export { default as FluxMenuCollapsible } from './FluxMenuCollapsible.vue';
113
+ export { default as FluxMenuFlyout } from './FluxMenuFlyout.vue';
105
114
  export { default as FluxMenuGroup } from './FluxMenuGroup.vue';
106
115
  export { default as FluxMenuItem } from './FluxMenuItem.vue';
107
116
  export { default as FluxMenuOptions } from './FluxMenuOptions.vue';
@@ -171,5 +180,8 @@ export { default as FluxToolbar } from './FluxToolbar.vue';
171
180
  export { default as FluxToolbarGroup } from './FluxToolbarGroup.vue';
172
181
  export { default as FluxTooltip } from './FluxTooltip.vue';
173
182
  export { default as FluxTooltipProvider } from './FluxTooltipProvider.vue';
183
+ export { default as FluxTour } from './FluxTour.vue';
184
+ export { default as FluxTourItem } from './FluxTourItem.vue';
174
185
  export { default as FluxTreeView } from './FluxTreeView.vue';
186
+ export { default as FluxVirtualScroller } from './FluxVirtualScroller.vue';
175
187
  export { default as FluxWindow } from './FluxWindow.vue';
@@ -20,12 +20,14 @@
20
20
 
21
21
  const {
22
22
  anchor,
23
+ clampToViewport,
23
24
  direction = 'vertical',
24
25
  margin = 12,
25
26
  position,
26
27
  useAnchorWidth
27
28
  } = defineProps<{
28
29
  readonly anchor?: ComponentPublicInstance | HTMLElement | null;
30
+ readonly clampToViewport?: boolean;
29
31
  readonly direction?: FluxDirection;
30
32
  readonly margin?: number;
31
33
  readonly position?:
@@ -52,6 +54,9 @@
52
54
  onMounted(() => {
53
55
  window.addEventListener('resize', onResize, {passive: true});
54
56
  window.addEventListener('scroll', onScroll, {capture: true, passive: true});
57
+
58
+ resize();
59
+ reposition();
55
60
  });
56
61
 
57
62
  onUnmounted(() => {
@@ -161,6 +166,23 @@
161
166
  break;
162
167
  }
163
168
 
169
+ if (clampToViewport) {
170
+ if (position?.startsWith('right') && px + popupWidth > innerWidth - margin) {
171
+ px = x - popupWidth - margin;
172
+ } else if (position?.startsWith('left') && px < margin) {
173
+ px = x + width + margin;
174
+ }
175
+
176
+ if (position?.startsWith('bottom') && py + popupHeight > innerHeight - margin) {
177
+ py = y - popupHeight - margin;
178
+ } else if (position?.startsWith('top') && py < margin) {
179
+ py = y + height + margin;
180
+ }
181
+
182
+ px = Math.min(Math.max(px, margin), Math.max(margin, innerWidth - popupWidth - margin));
183
+ py = Math.min(Math.max(py, margin), Math.max(margin, innerHeight - popupHeight - margin));
184
+ }
185
+
164
186
  state.x = px;
165
187
  state.y = py;
166
188
  }
@@ -185,6 +207,11 @@
185
207
  reposition();
186
208
  }
187
209
 
210
+ defineExpose({
211
+ reposition,
212
+ resize
213
+ });
214
+
188
215
  watchEffect(() => {
189
216
  if (!anchor || (!isHtmlElement(anchor) && !anchor.$el)) {
190
217
  return;
@@ -80,11 +80,19 @@
80
80
  :placeholder="translate('flux.search')"
81
81
  @keydown="onKeyDown"/>
82
82
 
83
- <FluxMenu v-if="!isLoading && options.length === 0">
83
+ <FluxMenu v-if="canCreate">
84
+ <FluxMenuItem
85
+ icon-leading="plus"
86
+ :label="translate('flux.createOption', {value: trimmedSearch})"
87
+ type="button"
88
+ @click="create()"/>
89
+ </FluxMenu>
90
+
91
+ <FluxMenu v-if="!isLoading && options.length === 0 && !canCreate">
84
92
  <FluxMenuSubHeader :label="translate('flux.noItems')"/>
85
93
  </FluxMenu>
86
94
 
87
- <FluxMenu v-else>
95
+ <FluxMenu v-else-if="options.length > 0">
88
96
  <template
89
97
  v-for="([item, subItems], index) of options"
90
98
  :key="`group-${index}`">
@@ -162,6 +170,7 @@
162
170
  keyDown: [KeyboardEvent];
163
171
  deselect: [string | number | null];
164
172
  select: [string | number | null];
173
+ create: [string];
165
174
  search: [string];
166
175
  close: [];
167
176
  open: [];
@@ -177,11 +186,13 @@
177
186
 
178
187
  const {
179
188
  disabled: componentDisabled,
189
+ isCreatable,
180
190
  isMultiple,
181
191
  options,
182
192
  selected
183
193
  } = defineProps<{
184
194
  readonly disabled?: boolean;
195
+ readonly isCreatable?: boolean;
185
196
  readonly isLoading?: boolean;
186
197
  readonly isMultiple?: boolean;
187
198
  readonly isSearchable?: boolean;
@@ -205,6 +216,15 @@
205
216
  const focusElement = computed(() => unrefTemplateElement(searchInputElementRef) ?? unrefTemplateElement(anchorRef));
206
217
  const highlightedId = computed(() => unref(rawOptions)[unref(highlightedIndex)]?.value);
207
218
  const rawOptions = computed(() => options.map(group => group[1]).flat());
219
+ const trimmedSearch = computed(() => unref(modelSearch).trim());
220
+ const canCreate = computed(() => {
221
+ if (!isCreatable || trimmedSearch.value === '') {
222
+ return false;
223
+ }
224
+
225
+ const query = trimmedSearch.value.toLowerCase();
226
+ return !unref(rawOptions).some(o => o.label.toLowerCase() === query);
227
+ });
208
228
 
209
229
  const {
210
230
  isOpen: isPopupOpen,
@@ -236,6 +256,19 @@
236
256
  nextTick(() => unref(focusElement)?.focus());
237
257
  }
238
258
 
259
+ function create(): void {
260
+ emit('create', trimmedSearch.value);
261
+
262
+ if (!isMultiple) {
263
+ isPopupOpen.value = false;
264
+ }
265
+
266
+ highlightedIndex.value = INITIAL_HIGHLIGHTED_INDEX;
267
+ modelSearch.value = '';
268
+
269
+ nextTick(() => unref(focusElement)?.focus());
270
+ }
271
+
239
272
  function onKeyDown(evt: KeyboardEvent): void {
240
273
  emit('keyDown', evt);
241
274
 
@@ -284,6 +317,8 @@
284
317
 
285
318
  if (id !== undefined) {
286
319
  select(id);
320
+ } else if (unref(canCreate)) {
321
+ create();
287
322
  }
288
323
  break;
289
324
 
@@ -4,6 +4,7 @@ export { default as useDateFlyout } from './useDateFlyout';
4
4
  export { default as useDropdownPopup, type UseDropdownPopupOptions, type UseDropdownPopupReturn } from './useDropdownPopup';
5
5
  export { default as useFormSelect } from './useFormSelect';
6
6
  export { useKanban } from './useKanban';
7
+ export { default as useMenuFlyout, useMenuFlyoutContext, useMenuFlyoutProvider, type UseMenuFlyoutOptions, type UseMenuFlyoutProviderOptions, type UseMenuFlyoutReturn } from './useMenuFlyout';
7
8
  export { useSplitView, type SplitViewPane, type UseSplitViewOptions, type UseSplitViewReturn } from './useSplitView';
8
9
  export { default as useTranslate } from './useTranslate';
9
10
  export { useCommandPalette, type CommandPaletteGroup, type CommandPaletteResultItem } from './useCommandPalette';