@flux-ui/components 3.1.0 → 3.1.3
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/FluxAvatarGroup.vue.d.ts +17 -0
- package/dist/component/FluxContextMenu.vue.d.ts +26 -0
- package/dist/component/FluxDataTable.vue.d.ts +20 -10
- package/dist/component/FluxDescriptionItem.vue.d.ts +19 -0
- package/dist/component/FluxDescriptionList.vue.d.ts +17 -0
- package/dist/component/FluxFormCombobox.vue.d.ts +20 -0
- package/dist/component/FluxFormRating.vue.d.ts +21 -0
- package/dist/component/FluxFormTagsInput.vue.d.ts +27 -0
- package/dist/component/FluxFormTextArea.vue.d.ts +6 -1
- package/dist/component/FluxInlineEdit.vue.d.ts +41 -0
- package/dist/component/FluxMenu.vue.d.ts +1 -0
- package/dist/component/FluxMenuFlyout.vue.d.ts +22 -0
- package/dist/component/FluxTableCell.vue.d.ts +1 -0
- package/dist/component/FluxTour.vue.d.ts +35 -0
- package/dist/component/FluxTourItem.vue.d.ts +18 -0
- package/dist/component/FluxVirtualScroller.vue.d.ts +27 -0
- package/dist/component/index.d.ts +12 -0
- package/dist/component/primitive/AnchorPopup.vue.d.ts +7 -1
- package/dist/component/primitive/SelectBase.vue.d.ts +3 -0
- package/dist/composable/private/index.d.ts +1 -0
- package/dist/composable/private/useMenuFlyout.d.ts +42 -0
- package/dist/data/di.d.ts +35 -0
- package/dist/data/i18n.d.ts +7 -0
- package/dist/index.css +442 -1
- package/dist/index.js +2031 -420
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/component/FluxAvatarGroup.vue +52 -0
- package/src/component/FluxContextMenu.vue +134 -0
- package/src/component/FluxDataTable.vue +113 -32
- package/src/component/FluxDescriptionItem.vue +43 -0
- package/src/component/FluxDescriptionList.vue +37 -0
- package/src/component/FluxFormCombobox.vue +98 -0
- package/src/component/FluxFormRating.vue +172 -0
- package/src/component/FluxFormTagsInput.vue +249 -0
- package/src/component/FluxFormTextArea.vue +16 -1
- package/src/component/FluxInlineEdit.vue +176 -0
- package/src/component/FluxMenu.vue +13 -3
- package/src/component/FluxMenuFlyout.vue +118 -0
- package/src/component/FluxTableCell.vue +2 -0
- package/src/component/FluxTour.vue +332 -0
- package/src/component/FluxTourItem.vue +27 -0
- package/src/component/FluxVirtualScroller.vue +96 -0
- package/src/component/index.ts +12 -0
- package/src/component/primitive/AnchorPopup.vue +27 -0
- package/src/component/primitive/SelectBase.vue +37 -2
- package/src/composable/private/index.ts +1 -0
- package/src/composable/private/useMenuFlyout.ts +417 -0
- package/src/css/component/AvatarGroup.module.scss +22 -0
- package/src/css/component/ContextMenu.module.scss +17 -0
- package/src/css/component/DescriptionList.module.scss +98 -0
- package/src/css/component/Divider.module.scss +1 -0
- package/src/css/component/Form.module.scss +51 -0
- package/src/css/component/FormRating.module.scss +47 -0
- package/src/css/component/InlineEdit.module.scss +45 -0
- package/src/css/component/Menu.module.scss +4 -1
- package/src/css/component/MenuFlyout.module.scss +38 -0
- package/src/css/component/Table.module.scss +16 -0
- package/src/css/component/Tour.module.scss +108 -0
- package/src/css/component/VirtualScroller.module.scss +17 -0
- package/src/data/di.ts +40 -0
- package/src/data/i18n.ts +7 -0
|
@@ -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="
|
|
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';
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { animationFrameDebounce, isSSR } from '@flux-ui/internals';
|
|
2
|
+
import { inject, nextTick, onMounted, onUnmounted, provide, ref, type ComponentPublicInstance, type Ref, watch } from 'vue';
|
|
3
|
+
import { FluxMenuFlyoutInjectionKey, type FluxMenuFlyoutCone, type FluxMenuFlyoutEntry, type FluxMenuFlyoutInjection, type FluxMenuFlyoutPointer } from '~flux/components/data';
|
|
4
|
+
|
|
5
|
+
let flyoutId = 0;
|
|
6
|
+
|
|
7
|
+
export type UseMenuFlyoutProviderOptions = {
|
|
8
|
+
readonly debugCone: Ref<boolean>;
|
|
9
|
+
readonly onCloseAll?: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type UseMenuFlyoutOptions = {
|
|
13
|
+
readonly triggerRef: Ref<ComponentPublicInstance | HTMLElement | null>;
|
|
14
|
+
readonly popupRef: Ref<ComponentPublicInstance | HTMLElement | null>;
|
|
15
|
+
readonly disabled?: Ref<boolean>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type UseMenuFlyoutReturn = {
|
|
19
|
+
readonly context: FluxMenuFlyoutInjection | null;
|
|
20
|
+
readonly cone: Ref<FluxMenuFlyoutCone | null>;
|
|
21
|
+
readonly isOpen: Ref<boolean>;
|
|
22
|
+
closeAll(): void;
|
|
23
|
+
focusTrigger(): void;
|
|
24
|
+
onPopupKeydown(evt: KeyboardEvent): void;
|
|
25
|
+
onTriggerClick(evt: MouseEvent): void;
|
|
26
|
+
onTriggerKeydown(evt: KeyboardEvent): void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates and provides the shared menu-flyout context for an entire open menu tree. Used by the
|
|
31
|
+
* outermost menu surface (FluxContextMenu, or a standalone root FluxMenu). Tracks the pointer, the
|
|
32
|
+
* active prediction cone, the keyboard-trap stack and the set of teleported flyout popups so that
|
|
33
|
+
* click-outside and close-all can reason about the whole tree.
|
|
34
|
+
*/
|
|
35
|
+
export function useMenuFlyoutProvider(options: UseMenuFlyoutProviderOptions): FluxMenuFlyoutInjection {
|
|
36
|
+
const {debugCone, onCloseAll} = options;
|
|
37
|
+
const entries = new Set<FluxMenuFlyoutEntry>();
|
|
38
|
+
|
|
39
|
+
const pointer = ref<FluxMenuFlyoutPointer>({x: 0, y: 0, px: 0, py: 0});
|
|
40
|
+
const activeCone = ref<FluxMenuFlyoutCone | null>(null);
|
|
41
|
+
const keyboardStack = ref<number[]>([]);
|
|
42
|
+
|
|
43
|
+
let pointerX = 0;
|
|
44
|
+
let pointerY = 0;
|
|
45
|
+
|
|
46
|
+
// pointermove can fire several times per frame (high-poll-rate mice), and every flyout entry
|
|
47
|
+
// reacts to the pointer ref with synchronous getBoundingClientRect reads. Coalesce updates to
|
|
48
|
+
// one per animation frame so those layout reads happen at most once per frame instead of per event.
|
|
49
|
+
const flushPointer = animationFrameDebounce(() => {
|
|
50
|
+
const previous = pointer.value;
|
|
51
|
+
pointer.value = {x: pointerX, y: pointerY, px: previous.x, py: previous.y};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function onPointerMove(evt: PointerEvent): void {
|
|
55
|
+
pointerX = evt.clientX;
|
|
56
|
+
pointerY = evt.clientY;
|
|
57
|
+
flushPointer();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startTracking(): void {
|
|
61
|
+
if (isSSR) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
window.addEventListener('pointermove', onPointerMove, {capture: true, passive: true});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stopTracking(): void {
|
|
69
|
+
if (isSSR) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
window.removeEventListener('pointermove', onPointerMove, {capture: true});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const context: FluxMenuFlyoutInjection = {
|
|
77
|
+
debugCone,
|
|
78
|
+
pointer,
|
|
79
|
+
activeCone,
|
|
80
|
+
keyboardStack,
|
|
81
|
+
|
|
82
|
+
register(entry: FluxMenuFlyoutEntry): void {
|
|
83
|
+
if (entries.size === 0) {
|
|
84
|
+
startTracking();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
entries.add(entry);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
unregister(entry: FluxMenuFlyoutEntry): void {
|
|
91
|
+
entries.delete(entry);
|
|
92
|
+
|
|
93
|
+
if (entries.size === 0) {
|
|
94
|
+
stopTracking();
|
|
95
|
+
activeCone.value = null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
closeOthers(self: FluxMenuFlyoutEntry): void {
|
|
100
|
+
const trigger = self.getTrigger();
|
|
101
|
+
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (entry === self || !entry.isOpen.value) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const popup = entry.getPopup();
|
|
108
|
+
|
|
109
|
+
if (popup && trigger && popup.contains(trigger)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
entry.close();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
hasOpenDescendant(self: FluxMenuFlyoutEntry): boolean {
|
|
118
|
+
const popup = self.getPopup();
|
|
119
|
+
|
|
120
|
+
if (!popup) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (entry === self || !entry.isOpen.value) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const trigger = entry.getTrigger();
|
|
130
|
+
|
|
131
|
+
if (trigger && popup.contains(trigger)) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
isAimingAtOpenSubmenu(): boolean {
|
|
140
|
+
const {x, y, px, py} = pointer.value;
|
|
141
|
+
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
if (!entry.isOpen.value) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const trigger = entry.getTrigger();
|
|
148
|
+
const popup = entry.getPopup();
|
|
149
|
+
|
|
150
|
+
if (!trigger || !popup) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const t = trigger.getBoundingClientRect();
|
|
155
|
+
const r = popup.getBoundingClientRect();
|
|
156
|
+
const edgeX = r.left >= t.right ? r.left : (r.right <= t.left ? r.right : r.left);
|
|
157
|
+
|
|
158
|
+
if (pointInTriangle(x, y, px, py, edgeX, r.top, edgeX, r.bottom)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
isInsidePopups(target: Node | null): boolean {
|
|
167
|
+
if (!target) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const popup = entry.getPopup();
|
|
173
|
+
|
|
174
|
+
if (popup && popup.contains(target)) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return false;
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
closeAll(): void {
|
|
183
|
+
if (onCloseAll) {
|
|
184
|
+
onCloseAll();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
entry.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
onUnmounted(stopTracking);
|
|
195
|
+
|
|
196
|
+
provide(FluxMenuFlyoutInjectionKey, context);
|
|
197
|
+
|
|
198
|
+
return context;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns the shared menu-flyout context, creating (and providing) a new root context when none
|
|
203
|
+
* exists higher up the tree. Used by FluxMenu so that a standalone menu becomes the root while a
|
|
204
|
+
* menu nested inside a FluxContextMenu or another flyout inherits the existing context.
|
|
205
|
+
*/
|
|
206
|
+
export function useMenuFlyoutContext(options: UseMenuFlyoutProviderOptions): FluxMenuFlyoutInjection {
|
|
207
|
+
const parent = inject(FluxMenuFlyoutInjectionKey, null);
|
|
208
|
+
|
|
209
|
+
if (parent) {
|
|
210
|
+
return parent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return useMenuFlyoutProvider(options);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Per-flyout open/close and prediction-cone behaviour for FluxMenuFlyout. Submenus open instantly
|
|
218
|
+
* on hover and close instantly once the pointer is neither over the trigger/popup (or an open
|
|
219
|
+
* descendant) nor aiming at the submenu through the prediction cone (the safe triangle). There are
|
|
220
|
+
* no open/close delays — the cone is the only thing that keeps a submenu open during a diagonal
|
|
221
|
+
* move, which is also why the debug cone only shows while it actually applies.
|
|
222
|
+
*/
|
|
223
|
+
export default function useMenuFlyout(options: UseMenuFlyoutOptions): UseMenuFlyoutReturn {
|
|
224
|
+
const {triggerRef, popupRef, disabled} = options;
|
|
225
|
+
const context = inject(FluxMenuFlyoutInjectionKey, null);
|
|
226
|
+
|
|
227
|
+
const id = ++flyoutId;
|
|
228
|
+
const isOpen = ref(false);
|
|
229
|
+
const openSource = ref<'pointer' | 'keyboard'>('pointer');
|
|
230
|
+
const cone = ref<FluxMenuFlyoutCone | null>(null);
|
|
231
|
+
|
|
232
|
+
const entry: FluxMenuFlyoutEntry = {
|
|
233
|
+
getTrigger: () => elementOf(triggerRef.value),
|
|
234
|
+
getPopup: () => elementOf(popupRef.value),
|
|
235
|
+
isOpen,
|
|
236
|
+
close: () => doClose()
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
function open(source: 'pointer' | 'keyboard'): void {
|
|
240
|
+
if (disabled?.value) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
openSource.value = source;
|
|
245
|
+
|
|
246
|
+
if (context && source === 'keyboard' && !context.keyboardStack.value.includes(id)) {
|
|
247
|
+
context.keyboardStack.value = [...context.keyboardStack.value, id];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!isOpen.value) {
|
|
251
|
+
isOpen.value = true;
|
|
252
|
+
context?.closeOthers(entry);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (source === 'keyboard') {
|
|
256
|
+
nextTick(focusFirstItem);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function doClose(): void {
|
|
261
|
+
if (!isOpen.value) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
isOpen.value = false;
|
|
266
|
+
cone.value = null;
|
|
267
|
+
|
|
268
|
+
if (context) {
|
|
269
|
+
if (context.activeCone.value?.id === id) {
|
|
270
|
+
context.activeCone.value = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
context.keyboardStack.value = context.keyboardStack.value.filter(value => value !== id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function focusTrigger(): void {
|
|
278
|
+
elementOf(triggerRef.value)?.focus();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function focusFirstItem(): void {
|
|
282
|
+
const popup = elementOf(popupRef.value);
|
|
283
|
+
|
|
284
|
+
if (!popup) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const focusable = popup.querySelector<HTMLElement>('[tabindex="0"]') ?? popup.querySelector<HTMLElement>('a[href], button:not([disabled])');
|
|
289
|
+
focusable?.focus();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function onTriggerClick(evt: MouseEvent): void {
|
|
293
|
+
evt.stopPropagation();
|
|
294
|
+
|
|
295
|
+
// evt.detail === 0 means the click came from the keyboard (Enter/Space), in which case we
|
|
296
|
+
// move focus into the submenu. A real pointer click keeps focus where it is.
|
|
297
|
+
open(evt.detail === 0 ? 'keyboard' : 'pointer');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function onTriggerKeydown(evt: KeyboardEvent): void {
|
|
301
|
+
if (evt.key === 'ArrowRight') {
|
|
302
|
+
evt.preventDefault();
|
|
303
|
+
evt.stopPropagation();
|
|
304
|
+
open('keyboard');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function onPopupKeydown(evt: KeyboardEvent): void {
|
|
309
|
+
if (evt.key === 'ArrowLeft' || evt.key === 'Escape') {
|
|
310
|
+
evt.preventDefault();
|
|
311
|
+
evt.stopPropagation();
|
|
312
|
+
doClose();
|
|
313
|
+
focusTrigger();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (context && !isSSR) {
|
|
318
|
+
watch(context.pointer, () => {
|
|
319
|
+
const trigger = elementOf(triggerRef.value);
|
|
320
|
+
|
|
321
|
+
if (!trigger) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const {x, y, px, py} = context.pointer.value;
|
|
326
|
+
const t = trigger.getBoundingClientRect();
|
|
327
|
+
const overTrigger = x >= t.left && x <= t.right && y >= t.top && y <= t.bottom;
|
|
328
|
+
|
|
329
|
+
if (!isOpen.value) {
|
|
330
|
+
if (overTrigger && !context.isAimingAtOpenSubmenu()) {
|
|
331
|
+
open('pointer');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const popup = elementOf(popupRef.value);
|
|
338
|
+
|
|
339
|
+
if (!popup) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const r = popup.getBoundingClientRect();
|
|
344
|
+
const overPopup = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
345
|
+
|
|
346
|
+
if (overTrigger || overPopup || context.hasOpenDescendant(entry)) {
|
|
347
|
+
cone.value = null;
|
|
348
|
+
|
|
349
|
+
if (context.activeCone.value?.id === id) {
|
|
350
|
+
context.activeCone.value = null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const edgeX = r.left >= t.right ? r.left : (r.right <= t.left ? r.right : r.left);
|
|
357
|
+
const candidate: FluxMenuFlyoutCone = {id, ax: x, ay: y, bx: edgeX, by: r.top, cx: edgeX, cy: r.bottom};
|
|
358
|
+
|
|
359
|
+
if (pointInTriangle(x, y, px, py, edgeX, r.top, edgeX, r.bottom)) {
|
|
360
|
+
cone.value = candidate;
|
|
361
|
+
context.activeCone.value = candidate;
|
|
362
|
+
} else {
|
|
363
|
+
cone.value = null;
|
|
364
|
+
|
|
365
|
+
if (context.activeCone.value?.id === id) {
|
|
366
|
+
context.activeCone.value = null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
doClose();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
onMounted(() => context?.register(entry));
|
|
375
|
+
|
|
376
|
+
onUnmounted(() => {
|
|
377
|
+
doClose();
|
|
378
|
+
context?.unregister(entry);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
context,
|
|
383
|
+
cone,
|
|
384
|
+
isOpen,
|
|
385
|
+
closeAll: () => context?.closeAll(),
|
|
386
|
+
focusTrigger,
|
|
387
|
+
onPopupKeydown,
|
|
388
|
+
onTriggerClick,
|
|
389
|
+
onTriggerKeydown
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function elementOf(value: ComponentPublicInstance | HTMLElement | null | undefined): HTMLElement | null {
|
|
394
|
+
if (!value) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (value instanceof HTMLElement) {
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return (value.$el as HTMLElement | null) ?? null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function pointInTriangle(px: number, py: number, ax: number, ay: number, bx: number, by: number, cx: number, cy: number): boolean {
|
|
406
|
+
const d1 = sign(px, py, ax, ay, bx, by);
|
|
407
|
+
const d2 = sign(px, py, bx, by, cx, cy);
|
|
408
|
+
const d3 = sign(px, py, cx, cy, ax, ay);
|
|
409
|
+
const hasNegative = d1 < 0 || d2 < 0 || d3 < 0;
|
|
410
|
+
const hasPositive = d1 > 0 || d2 > 0 || d3 > 0;
|
|
411
|
+
|
|
412
|
+
return !(hasNegative && hasPositive);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sign(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
|
|
416
|
+
return (px - bx) * (ay - by) - (ax - bx) * (py - by);
|
|
417
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
.avatarGroup {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
flex-flow: row nowrap;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.avatarGroupItem {
|
|
8
|
+
position: relative;
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
margin-left: calc(var(--overlap, .3) * -1em);
|
|
11
|
+
border-radius: .3em;
|
|
12
|
+
box-shadow: 0 0 0 .125em var(--gray-25);
|
|
13
|
+
transition: translate .2s var(--swift-out);
|
|
14
|
+
|
|
15
|
+
&:first-child {
|
|
16
|
+
margin-left: 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&:hover {
|
|
20
|
+
z-index: 1;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.contextMenu {
|
|
2
|
+
display: contents;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.contextMenuPopup {
|
|
6
|
+
composes: basePane from './base/Pane.module.scss';
|
|
7
|
+
|
|
8
|
+
position: fixed;
|
|
9
|
+
top: 0;
|
|
10
|
+
left: 0;
|
|
11
|
+
min-width: 270px;
|
|
12
|
+
max-height: max(330px, 50dvh);
|
|
13
|
+
overflow: auto;
|
|
14
|
+
box-shadow: var(--shadow-md);
|
|
15
|
+
translate: var(--x) var(--y);
|
|
16
|
+
z-index: 10000;
|
|
17
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
.descriptionList {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-flow: column;
|
|
4
|
+
gap: 12px;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.descriptionListHeader {
|
|
8
|
+
color: var(--foreground-secondary);
|
|
9
|
+
font-size: 14px;
|
|
10
|
+
font-weight: 500;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.descriptionListItems {
|
|
14
|
+
display: flex;
|
|
15
|
+
margin: 0;
|
|
16
|
+
flex-flow: column;
|
|
17
|
+
gap: 12px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.descriptionItem {
|
|
21
|
+
display: flex;
|
|
22
|
+
gap: 12px;
|
|
23
|
+
align-items: center;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.descriptionItem.isStacked {
|
|
27
|
+
gap: 2px;
|
|
28
|
+
align-items: stretch;
|
|
29
|
+
flex-flow: column;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.descriptionItem.isStacked .descriptionItemValue {
|
|
33
|
+
margin-left: 0;
|
|
34
|
+
justify-content: flex-start;
|
|
35
|
+
text-align: left;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.descriptionItemTerm {
|
|
39
|
+
display: flex;
|
|
40
|
+
gap: 9px;
|
|
41
|
+
align-items: center;
|
|
42
|
+
color: var(--foreground);
|
|
43
|
+
font-size: 15px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.descriptionItemIcon {
|
|
47
|
+
flex-shrink: 0;
|
|
48
|
+
color: var(--foreground);
|
|
49
|
+
font-size: 16px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.descriptionItemLabel {
|
|
53
|
+
overflow-wrap: anywhere;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.descriptionItemValue {
|
|
57
|
+
display: flex;
|
|
58
|
+
margin: 0;
|
|
59
|
+
margin-left: auto;
|
|
60
|
+
gap: 6px;
|
|
61
|
+
align-items: center;
|
|
62
|
+
color: var(--foreground-prominent);
|
|
63
|
+
font-size: 15px;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
text-align: right;
|
|
66
|
+
overflow-wrap: anywhere;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.descriptionListItems.isHorizontal {
|
|
70
|
+
gap: 0;
|
|
71
|
+
flex-flow: row;
|
|
72
|
+
|
|
73
|
+
.descriptionItem {
|
|
74
|
+
gap: 6px;
|
|
75
|
+
align-items: stretch;
|
|
76
|
+
flex: 1 1 0;
|
|
77
|
+
flex-flow: column;
|
|
78
|
+
padding-right: 24px;
|
|
79
|
+
padding-left: 24px;
|
|
80
|
+
border-left: 1px solid var(--gray-100);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.descriptionItem:first-child {
|
|
84
|
+
padding-left: 0;
|
|
85
|
+
border-left: 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.descriptionItemTerm {
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.descriptionItemValue {
|
|
93
|
+
margin-left: 0;
|
|
94
|
+
justify-content: flex-start;
|
|
95
|
+
font-weight: 500;
|
|
96
|
+
text-align: left;
|
|
97
|
+
}
|
|
98
|
+
}
|