@nexdom/uimed-vue 2.0.0-beta.60 → 2.0.0-beta.62
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/AGENTS.md +1 -1
- package/dist/components/data-sets/data-set/types.d.ts +3 -1
- package/dist/components/data-sets/data-set-bar.vue_vue_type_script_setup_true_lang.js +18 -11
- package/dist/components/main/main.vue_vue_type_script_setup_true_lang.js +3 -1
- package/dist/components/navigation-menu/navigation-menu.vue_vue_type_script_setup_true_lang.js +90 -28
- package/dist/components/navigation-menu/types.d.ts +6 -6
- package/dist/composables/navigation/use-navigation-rail.js +163 -0
- package/dist/composables/navigation/use-navigation-search-shortcut.js +11 -12
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -216,7 +216,7 @@ Use an existing composable (e.g. `use-toast`) as the reference, and deliver in t
|
|
|
216
216
|
- Public components/composables are re-exported from `src/components/index.ts` and `src/composables/index.ts`. Prop types stay internal (`types.ts` is not re-exported); consumers derive them with `ComponentProps` (see [Usage](#usage)).
|
|
217
217
|
- Every publicly exported component must use the `U` prefix on its export identifier (e.g., `UButton`, `UMain`), while internal file names and component names remain unprefixed.
|
|
218
218
|
- Use [JSDoc](https://jsdoc.app/about-getting-started) on every method/prop/type intended to be part of the public API — it's the primary documentation surface and supports markdown/code examples.
|
|
219
|
-
- Known workarounds (see CONTRIBUTING.md before touching related config): `stryker-vue-ignorer` patches a Stryker/Vue macro-hoisting issue; `vue-tsc` is used for type-check instead of Vite+'s built-in one due to an oxlint/Vue support gap; the internal `SkeletonLoader` wraps Vuetify's skeleton loader so its loading announcement gets valid ARIA attributes in server rendering. All are meant to be removed once fixed upstream — don't build further on top of them without checking if they're still needed.
|
|
219
|
+
- Known workarounds (see CONTRIBUTING.md before touching related config): `stryker-vue-ignorer` patches a Stryker/Vue macro-hoisting issue; `vue-tsc` is used for type-check instead of Vite+'s built-in one due to an oxlint/Vue support gap; the internal `SkeletonLoader` wraps Vuetify's skeleton loader so its loading announcement gets valid ARIA attributes in server rendering; the navigation menu's rail (`useNavigationRail`) dispatches synthetic `mouseenter`/`mouseleave` events on Vuetify's navigation drawer, which only expands its rail on mouse events, to expand it on keyboard focus and keep it expanded when the mouse leaves with the focus inside. All are meant to be removed once fixed upstream — don't build further on top of them without checking if they're still needed.
|
|
220
220
|
|
|
221
221
|
## Testing conventions
|
|
222
222
|
|
|
@@ -35,7 +35,9 @@ export type DataSetProps<T extends object> = {
|
|
|
35
35
|
/**
|
|
36
36
|
* List of actions about the whole list, such as creating a record, displayed
|
|
37
37
|
* as buttons at the start of the bar above the records, before the search
|
|
38
|
-
* field. Same shape as `USection`'s actions
|
|
38
|
+
* field. Same shape as `USection`'s actions, but an action without `variant`
|
|
39
|
+
* is `secondary`, not `primary`, so it doesn't compete with the actions of the
|
|
40
|
+
* records; an action that sets `variant` keeps it.
|
|
39
41
|
*
|
|
40
42
|
* With the search field, the actions take the first half of the bar,
|
|
41
43
|
* wrapping within it, or a line of their own when the data set isn't wide
|
|
@@ -45,18 +45,25 @@ var data_set_bar_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ def
|
|
|
45
45
|
cols: props.searchable ? props.cols : "12"
|
|
46
46
|
}, {
|
|
47
47
|
default: withCtx(() => [createVNode(row_default, null, {
|
|
48
|
-
default: withCtx(() => [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
default: withCtx(() => [
|
|
49
|
+
createCommentVNode(" Each action takes the full width of its column, which only stretches it below sm "),
|
|
50
|
+
createCommentVNode(" The list's actions are secondary unless they set a variant, so they don't compete with\n the actions of the records and of the sections "),
|
|
51
|
+
(openBlock(true), createElementBlock(Fragment, null, renderList(props.actions, ({ label, onClick, variant = "secondary", ...action }, index) => {
|
|
52
|
+
return openBlock(), createBlock(column_default, {
|
|
53
|
+
key: index,
|
|
54
|
+
cols: "auto"
|
|
55
|
+
}, {
|
|
56
|
+
default: withCtx(() => [createVNode(button_default, mergeProps({
|
|
57
|
+
"full-width": "",
|
|
58
|
+
variant
|
|
59
|
+
}, { ref_for: true }, action, { onClick }), {
|
|
60
|
+
default: withCtx(() => [createTextVNode(toDisplayString(label), 1)]),
|
|
61
|
+
_: 2
|
|
62
|
+
}, 1040, ["variant", "onClick"])]),
|
|
55
63
|
_: 2
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}), 128))]),
|
|
64
|
+
}, 1024);
|
|
65
|
+
}), 128))
|
|
66
|
+
]),
|
|
60
67
|
_: 1
|
|
61
68
|
})]),
|
|
62
69
|
_: 1
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import app_bar_default from "../app-bar/app-bar.js";
|
|
2
|
+
import { useMobileNavigation } from "../../composables/navigation/use-navigation-rail.js";
|
|
2
3
|
import navigation_menu_default from "../navigation-menu/navigation-menu.js";
|
|
3
4
|
import footer_default from "../footer/footer.js";
|
|
4
5
|
import container_default from "../grid/container/container.js";
|
|
@@ -50,10 +51,11 @@ var main_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineCompo
|
|
|
50
51
|
setup(__props) {
|
|
51
52
|
const props = __props;
|
|
52
53
|
const slots = useSlots();
|
|
54
|
+
const mobile = useMobileNavigation();
|
|
53
55
|
const appBarProps = computed(() => props.appBar ? {
|
|
54
56
|
...props.appBar,
|
|
55
57
|
logo: props.logo,
|
|
56
|
-
navigation: props.navigationMenu !== void 0,
|
|
58
|
+
navigation: props.navigationMenu !== void 0 && mobile.value,
|
|
57
59
|
loading: props.loading
|
|
58
60
|
} : void 0);
|
|
59
61
|
const backgroundClass = useVuetifyBackgroundColor(() => props.background);
|
package/dist/components/navigation-menu/navigation-menu.vue_vue_type_script_setup_true_lang.js
CHANGED
|
@@ -3,7 +3,8 @@ import { routeToListItemProps } from "../../composables/navigation/use-route-pro
|
|
|
3
3
|
import { useNavigationSearchShortcut } from "../../composables/navigation/use-navigation-search-shortcut.js";
|
|
4
4
|
import skeleton_loader_default from "../skeleton-loader/skeleton-loader.js";
|
|
5
5
|
import text_field_default from "../inputs/text-field/text-field.js";
|
|
6
|
-
import {
|
|
6
|
+
import { isParentItem, useNavigationRail } from "../../composables/navigation/use-navigation-rail.js";
|
|
7
|
+
import { Fragment, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, isRef, mergeModels, mergeProps, openBlock, ref, renderList, unref, useModel, useTemplateRef, vShow, withCtx, withDirectives, withKeys } from "vue";
|
|
7
8
|
import { VDivider, VList, VListGroup, VListItem, VNavigationDrawer } from "vuetify/components";
|
|
8
9
|
//#region src/components/navigation-menu/navigation-menu.vue?vue&type=script&setup=true&lang.ts
|
|
9
10
|
const _hoisted_1 = { class: "px-4 pt-2" };
|
|
@@ -35,11 +36,22 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
35
36
|
const props = __props;
|
|
36
37
|
const open = useModel(__props, "modelValue");
|
|
37
38
|
const search = ref("");
|
|
38
|
-
const
|
|
39
|
+
const field = useTemplateRef("searchField");
|
|
40
|
+
const { mobile, collapsed, drawerOpen, menuOpen, opened, isActiveGroup, dismiss, isRestoringFocus, onFocusin, onFocusout, onMouseenter, onMouseleave } = useNavigationRail({
|
|
39
41
|
open,
|
|
40
|
-
field
|
|
42
|
+
field,
|
|
43
|
+
searchItem: useTemplateRef("searchItem"),
|
|
44
|
+
items: () => props.items,
|
|
45
|
+
search
|
|
46
|
+
});
|
|
47
|
+
const { label: shortcutLabel, ariaKeyshortcuts, openSearch } = useNavigationSearchShortcut({
|
|
48
|
+
open: menuOpen,
|
|
49
|
+
field
|
|
41
50
|
});
|
|
42
51
|
const filteredItems = computed(() => filterItems());
|
|
52
|
+
function onSearchItemFocus(event) {
|
|
53
|
+
if (!isRestoringFocus()) openSearch(event.relatedTarget);
|
|
54
|
+
}
|
|
43
55
|
const searchDataTestid = computed(() => `${props.dataTestid}-search`);
|
|
44
56
|
const query = computed(() => search.value.toLowerCase());
|
|
45
57
|
function filterItems() {
|
|
@@ -58,49 +70,91 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
58
70
|
function matches(item) {
|
|
59
71
|
return item.description.toLowerCase().includes(query.value);
|
|
60
72
|
}
|
|
61
|
-
function isParentItem(item) {
|
|
62
|
-
return "items" in item;
|
|
63
|
-
}
|
|
64
73
|
return (_ctx, _cache) => {
|
|
65
74
|
return openBlock(), createBlock(unref(VNavigationDrawer), {
|
|
66
|
-
modelValue:
|
|
67
|
-
"onUpdate:modelValue": _cache[
|
|
75
|
+
modelValue: unref(drawerOpen),
|
|
76
|
+
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => isRef(drawerOpen) ? drawerOpen.value = $event : null),
|
|
68
77
|
color: "primary",
|
|
69
78
|
"data-testid": props.dataTestid,
|
|
70
79
|
"aria-busy": props.loading || void 0,
|
|
80
|
+
mobile: unref(mobile),
|
|
81
|
+
permanent: !unref(mobile),
|
|
82
|
+
rail: !unref(mobile),
|
|
83
|
+
"expand-on-hover": "",
|
|
71
84
|
absolute: "",
|
|
72
85
|
temporary: "",
|
|
73
|
-
|
|
86
|
+
onFocusin: unref(onFocusin),
|
|
87
|
+
onFocusout: unref(onFocusout),
|
|
88
|
+
onMouseenter: unref(onMouseenter),
|
|
89
|
+
onMouseleave: unref(onMouseleave),
|
|
90
|
+
onKeydown: withKeys(unref(dismiss), ["esc"])
|
|
74
91
|
}, {
|
|
75
|
-
prepend: withCtx(() => [
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
prepend: withCtx(() => [
|
|
93
|
+
withDirectives(createElementVNode("div", _hoisted_1, [createVNode(text_field_default, {
|
|
94
|
+
ref: "searchField",
|
|
95
|
+
modelValue: search.value,
|
|
96
|
+
"onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => search.value = $event),
|
|
97
|
+
type: "search",
|
|
98
|
+
placeholder: `Buscar (${unref(shortcutLabel)})`,
|
|
99
|
+
"data-testid": searchDataTestid.value,
|
|
100
|
+
disabled: props.loading
|
|
101
|
+
}, null, 8, [
|
|
102
|
+
"modelValue",
|
|
103
|
+
"placeholder",
|
|
104
|
+
"data-testid",
|
|
105
|
+
"disabled"
|
|
106
|
+
])], 512), [[vShow, !unref(collapsed)]]),
|
|
107
|
+
createCommentVNode(" Vuetify's list is focusable and passes the focus on to its first item: out of the tab\n order, the item gets the focus straight from the page, which then gets it back on `Esc` "),
|
|
108
|
+
withDirectives(createVNode(unref(VList), {
|
|
109
|
+
nav: "",
|
|
110
|
+
role: "group",
|
|
111
|
+
tabindex: "-1"
|
|
112
|
+
}, {
|
|
113
|
+
default: withCtx(() => [createCommentVNode(" Rendered as a native button, as the groups' headers (see below). Vuetify only styles\n a disabled item, and its button would still take the focus "), createVNode(unref(VListItem), {
|
|
114
|
+
ref: "searchItem",
|
|
115
|
+
title: "Buscar",
|
|
116
|
+
"prepend-icon": unref(icons).search,
|
|
117
|
+
disabled: props.loading,
|
|
118
|
+
tabindex: props.loading ? -1 : void 0,
|
|
119
|
+
"aria-disabled": props.loading || void 0,
|
|
120
|
+
"aria-keyshortcuts": unref(ariaKeyshortcuts),
|
|
121
|
+
role: void 0,
|
|
122
|
+
tag: "button",
|
|
123
|
+
type: "button",
|
|
124
|
+
class: "w-100 text-start",
|
|
125
|
+
onFocus: onSearchItemFocus
|
|
126
|
+
}, null, 8, [
|
|
127
|
+
"prepend-icon",
|
|
128
|
+
"disabled",
|
|
129
|
+
"tabindex",
|
|
130
|
+
"aria-disabled",
|
|
131
|
+
"aria-keyshortcuts"
|
|
132
|
+
])]),
|
|
133
|
+
_: 1
|
|
134
|
+
}, 512), [[vShow, unref(collapsed)]])
|
|
135
|
+
]),
|
|
89
136
|
default: withCtx(() => [createVNode(unref(VDivider)), createVNode(skeleton_loader_default, {
|
|
90
137
|
loading: props.loading,
|
|
91
138
|
type: "list-item@6",
|
|
92
139
|
color: "primary"
|
|
93
140
|
}, {
|
|
94
141
|
default: withCtx(() => [createCommentVNode(" Vuetify's `list` role only allows list items, but the items are links "), createVNode(unref(VList), {
|
|
142
|
+
opened: unref(opened),
|
|
143
|
+
"onUpdate:opened": _cache[1] || (_cache[1] = ($event) => isRef(opened) ? opened.value = $event : null),
|
|
95
144
|
nav: "",
|
|
96
145
|
role: "group"
|
|
97
146
|
}, {
|
|
98
147
|
default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(filteredItems.value, (item, index) => {
|
|
99
|
-
return openBlock(), createElementBlock(Fragment, { key: index }, [isParentItem(item) ? (openBlock(), createBlock(unref(VListGroup), {
|
|
148
|
+
return openBlock(), createElementBlock(Fragment, { key: index }, [unref(isParentItem)(item) ? (openBlock(), createBlock(unref(VListGroup), {
|
|
149
|
+
key: 0,
|
|
150
|
+
value: item.description
|
|
151
|
+
}, {
|
|
100
152
|
activator: withCtx(({ props: activatorProps, isOpen }) => [createVNode(unref(VListItem), mergeProps({ ref_for: true }, activatorProps, {
|
|
101
153
|
title: item.description,
|
|
102
|
-
"prepend-icon":
|
|
154
|
+
"prepend-icon": unref(icons)[item.icon],
|
|
155
|
+
active: unref(isActiveGroup)(item),
|
|
103
156
|
"aria-expanded": isOpen,
|
|
157
|
+
"aria-selected": void 0,
|
|
104
158
|
role: void 0,
|
|
105
159
|
tag: "button",
|
|
106
160
|
type: "button",
|
|
@@ -108,6 +162,7 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
108
162
|
}), null, 16, [
|
|
109
163
|
"title",
|
|
110
164
|
"prepend-icon",
|
|
165
|
+
"active",
|
|
111
166
|
"aria-expanded"
|
|
112
167
|
])]),
|
|
113
168
|
default: withCtx(() => [(openBlock(true), createElementBlock(Fragment, null, renderList(item.items, (childItem, childIndex) => {
|
|
@@ -117,10 +172,10 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
117
172
|
}), null, 16, ["title", "onClick"]);
|
|
118
173
|
}), 128))]),
|
|
119
174
|
_: 2
|
|
120
|
-
},
|
|
175
|
+
}, 1032, ["value"])) : (openBlock(), createBlock(unref(VListItem), mergeProps({
|
|
121
176
|
key: 1,
|
|
122
177
|
title: item.description,
|
|
123
|
-
"prepend-icon":
|
|
178
|
+
"prepend-icon": unref(icons)[item.icon]
|
|
124
179
|
}, { ref_for: true }, unref(routeToListItemProps)(item.route), { onClick: item.action }), null, 16, [
|
|
125
180
|
"title",
|
|
126
181
|
"prepend-icon",
|
|
@@ -128,7 +183,7 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
128
183
|
]))], 64);
|
|
129
184
|
}), 128))]),
|
|
130
185
|
_: 1
|
|
131
|
-
})]),
|
|
186
|
+
}, 8, ["opened"])]),
|
|
132
187
|
_: 1
|
|
133
188
|
}, 8, ["loading"])]),
|
|
134
189
|
_: 1
|
|
@@ -136,6 +191,13 @@ var navigation_menu_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/
|
|
|
136
191
|
"modelValue",
|
|
137
192
|
"data-testid",
|
|
138
193
|
"aria-busy",
|
|
194
|
+
"mobile",
|
|
195
|
+
"permanent",
|
|
196
|
+
"rail",
|
|
197
|
+
"onFocusin",
|
|
198
|
+
"onFocusout",
|
|
199
|
+
"onMouseenter",
|
|
200
|
+
"onMouseleave",
|
|
139
201
|
"onKeydown"
|
|
140
202
|
]);
|
|
141
203
|
};
|
|
@@ -22,12 +22,12 @@ export type NavigationMenuParentItem = {
|
|
|
22
22
|
*/
|
|
23
23
|
description: string;
|
|
24
24
|
/**
|
|
25
|
-
* Icon shown before the group's description
|
|
26
|
-
* page.
|
|
25
|
+
* Icon shown before the group's description, and alone while the menu is collapsed to a rail.
|
|
26
|
+
* One of the names listed on the "Ícones" guide page.
|
|
27
27
|
*
|
|
28
28
|
* @see {@link https://nexdom-healthtech.github.io/uimed-vue/guide/icons | Icons Guide}
|
|
29
29
|
*/
|
|
30
|
-
icon
|
|
30
|
+
icon: Icon;
|
|
31
31
|
/**
|
|
32
32
|
* Child {@link NavigationMenuChildItem} list. Unlike the first level items, they show no icon.
|
|
33
33
|
*/
|
|
@@ -44,12 +44,12 @@ export interface NavigationMenuItem {
|
|
|
44
44
|
*/
|
|
45
45
|
description: string;
|
|
46
46
|
/**
|
|
47
|
-
* Icon shown before the item's description
|
|
48
|
-
* page.
|
|
47
|
+
* Icon shown before the item's description, and alone while the menu is collapsed to a rail.
|
|
48
|
+
* One of the names listed on the "Ícones" guide page.
|
|
49
49
|
*
|
|
50
50
|
* @see {@link https://nexdom-healthtech.github.io/uimed-vue/guide/icons | Icons Guide}
|
|
51
51
|
*/
|
|
52
|
-
icon
|
|
52
|
+
icon: Icon;
|
|
53
53
|
/**
|
|
54
54
|
* Route to navigate when item is clicked.
|
|
55
55
|
*/
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isExternalRoute } from "./use-route-props.js";
|
|
2
|
+
import { useDisplay } from "vuetify";
|
|
3
|
+
import { computed, inject, nextTick, onMounted, ref, toValue, watch } from "vue";
|
|
4
|
+
import { isDeepEqual } from "@nexdom/shared/utils";
|
|
5
|
+
import { routerKey } from "vue-router";
|
|
6
|
+
//#region src/composables/navigation/use-navigation-rail.ts
|
|
7
|
+
/**
|
|
8
|
+
* Mouse events the rail dispatches on the drawer to expand or collapse it, which its own listeners
|
|
9
|
+
* ignore.
|
|
10
|
+
*/
|
|
11
|
+
const syntheticEvents = /* @__PURE__ */ new WeakSet();
|
|
12
|
+
/**
|
|
13
|
+
* Whether the navigation menu is a temporary drawer, opened by the app bar's button, instead of a
|
|
14
|
+
* rail. Starts as the drawer and switches on mount, so server-rendered pages (the docs), rendered
|
|
15
|
+
* without a window width, hydrate without mismatches.
|
|
16
|
+
*/
|
|
17
|
+
function useMobileNavigation() {
|
|
18
|
+
const { mobile } = useDisplay();
|
|
19
|
+
const mounted = ref(false);
|
|
20
|
+
onMounted(() => mounted.value = true);
|
|
21
|
+
return computed(() => !mounted.value || mobile.value);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* State of the navigation menu as a rail, above the mobile breakpoint: collapsed to its icons, and
|
|
25
|
+
* expanded over the page while the mouse is over it, the keyboard focus is in it or the user is
|
|
26
|
+
* searching. `Esc` collapses it until the mouse enters it, the focus leaves and enters it again
|
|
27
|
+
* or the user searches.
|
|
28
|
+
*
|
|
29
|
+
* The drawer only expands its rail on its own mouse events, so the rail dispatches them itself to
|
|
30
|
+
* expand on focus, and to stay expanded when the mouse leaves with the focus inside.
|
|
31
|
+
*/
|
|
32
|
+
function useNavigationRail(options) {
|
|
33
|
+
const { open, field, searchItem, items, search } = options;
|
|
34
|
+
const mobile = useMobileNavigation();
|
|
35
|
+
const router = inject(routerKey, null);
|
|
36
|
+
const pointerInside = ref(false);
|
|
37
|
+
const focusInside = ref(false);
|
|
38
|
+
const searching = ref(false);
|
|
39
|
+
/** Collapsed by `Esc`: the focus doesn't expand it again until it leaves the rail. */
|
|
40
|
+
const dismissed = ref(false);
|
|
41
|
+
let restoringFocus = false;
|
|
42
|
+
const expandedGroups = ref([]);
|
|
43
|
+
const collapsed = computed(() => !mobile.value && !(pointerInside.value || focusInside.value || searching.value));
|
|
44
|
+
const drawerOpen = computed({
|
|
45
|
+
get: () => !mobile.value || open.value,
|
|
46
|
+
set: (value) => {
|
|
47
|
+
if (mobile.value) open.value = value;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
/** The drawer's model below the mobile breakpoint, and the rail's search state above it. */
|
|
51
|
+
const menuState = computed(() => mobile.value ? open : searching);
|
|
52
|
+
const menuOpen = computed({
|
|
53
|
+
get: () => menuState.value.value,
|
|
54
|
+
set: (value) => {
|
|
55
|
+
menuState.value.value = value;
|
|
56
|
+
dismissed.value = false;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const opened = computed({
|
|
60
|
+
get: () => collapsed.value ? [] : expandedGroups.value,
|
|
61
|
+
set: (value) => expandedGroups.value = value
|
|
62
|
+
});
|
|
63
|
+
watch(collapsed, (isCollapsed) => {
|
|
64
|
+
syncHover();
|
|
65
|
+
if (isCollapsed) search.value = "";
|
|
66
|
+
else expandedGroups.value = currentGroups();
|
|
67
|
+
});
|
|
68
|
+
watch(() => router?.currentRoute.value, () => searching.value = false);
|
|
69
|
+
function onFocusin(event) {
|
|
70
|
+
focusInside.value = !dismissed.value && event.target.matches(":focus-visible");
|
|
71
|
+
}
|
|
72
|
+
function onFocusout(event) {
|
|
73
|
+
if (isInside(event, event.relatedTarget)) return;
|
|
74
|
+
focusInside.value = false;
|
|
75
|
+
searching.value = false;
|
|
76
|
+
dismissed.value = false;
|
|
77
|
+
}
|
|
78
|
+
function onMouseenter(event) {
|
|
79
|
+
if (syntheticEvents.has(event)) return;
|
|
80
|
+
pointerInside.value = true;
|
|
81
|
+
dismissed.value = false;
|
|
82
|
+
}
|
|
83
|
+
function onMouseleave(event) {
|
|
84
|
+
if (syntheticEvents.has(event)) return;
|
|
85
|
+
pointerInside.value = false;
|
|
86
|
+
syncHover();
|
|
87
|
+
}
|
|
88
|
+
async function dismiss() {
|
|
89
|
+
menuOpen.value = false;
|
|
90
|
+
pointerInside.value = false;
|
|
91
|
+
focusInside.value = false;
|
|
92
|
+
dismissed.value = true;
|
|
93
|
+
await nextTick();
|
|
94
|
+
if (collapsed.value && isHidingFocus()) {
|
|
95
|
+
restoringFocus = true;
|
|
96
|
+
toValue(searchItem).$el.focus();
|
|
97
|
+
restoringFocus = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the focus is on an element the collapsed rail hides: the search field or an item of a
|
|
102
|
+
* group, which would leave the focus on the page's body.
|
|
103
|
+
*/
|
|
104
|
+
function isHidingFocus() {
|
|
105
|
+
const { activeElement } = document;
|
|
106
|
+
const groupsItems = [...findDrawer().querySelectorAll(".v-list-group__items")];
|
|
107
|
+
return toValue(field).$el.contains(activeElement) || groupsItems.some((items) => items.contains(activeElement));
|
|
108
|
+
}
|
|
109
|
+
function isRestoringFocus() {
|
|
110
|
+
return restoringFocus;
|
|
111
|
+
}
|
|
112
|
+
/** Expands or collapses the drawer's rail, through the mouse events it listens to. */
|
|
113
|
+
function syncHover() {
|
|
114
|
+
const event = new MouseEvent(collapsed.value ? "mouseleave" : "mouseenter");
|
|
115
|
+
syntheticEvents.add(event);
|
|
116
|
+
findDrawer().dispatchEvent(event);
|
|
117
|
+
}
|
|
118
|
+
function findDrawer() {
|
|
119
|
+
return toValue(field).$el.closest(".v-navigation-drawer");
|
|
120
|
+
}
|
|
121
|
+
function currentGroups() {
|
|
122
|
+
return (toValue(items) ?? []).filter((item) => isParentItem(item) && hasCurrentRoute(item)).map(({ description }) => description);
|
|
123
|
+
}
|
|
124
|
+
function isActiveGroup(item) {
|
|
125
|
+
return collapsed.value && hasCurrentRoute(item);
|
|
126
|
+
}
|
|
127
|
+
function hasCurrentRoute(item) {
|
|
128
|
+
return item.items.some(({ route }) => isCurrentRoute(route));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Whether the route is the current page, or one of its parents, as the links highlight
|
|
132
|
+
* themselves: by the matched route record and the params the route sets.
|
|
133
|
+
*/
|
|
134
|
+
function isCurrentRoute(route) {
|
|
135
|
+
if (!router || route === void 0 || isExternalRoute(route)) return false;
|
|
136
|
+
const { matched, params } = router.resolve(route);
|
|
137
|
+
const current = router.currentRoute.value;
|
|
138
|
+
const record = matched.at(-1);
|
|
139
|
+
return current.matched.includes(record) && Object.entries(params).every(([key, value]) => isDeepEqual(value, current.params[key]));
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
mobile,
|
|
143
|
+
collapsed,
|
|
144
|
+
drawerOpen,
|
|
145
|
+
menuOpen,
|
|
146
|
+
opened,
|
|
147
|
+
isActiveGroup,
|
|
148
|
+
dismiss,
|
|
149
|
+
isRestoringFocus,
|
|
150
|
+
onFocusin,
|
|
151
|
+
onFocusout,
|
|
152
|
+
onMouseenter,
|
|
153
|
+
onMouseleave
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function isParentItem(item) {
|
|
157
|
+
return "items" in item;
|
|
158
|
+
}
|
|
159
|
+
function isInside(event, target) {
|
|
160
|
+
return target instanceof Node && event.currentTarget.contains(target);
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
export { isParentItem, useMobileNavigation, useNavigationRail };
|
|
@@ -34,9 +34,10 @@ function useNavigationShortcutKeys() {
|
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
36
|
* Listens to Ctrl+K (⌘K on macOS) on `window` while mounted: opens the menu, waits for it to
|
|
37
|
-
* render (the drawer is `inert` while closed
|
|
38
|
-
* while loading, can't take the focus). When
|
|
39
|
-
* inside it, the focus goes back to where it
|
|
37
|
+
* render (the drawer is `inert` while closed, and the rail only shows the field while expanded)
|
|
38
|
+
* and focuses the search field (a disabled field, as while loading, can't take the focus). When
|
|
39
|
+
* the menu opened by the shortcut closes with the focus inside it, the focus goes back to where it
|
|
40
|
+
* was.
|
|
40
41
|
*/
|
|
41
42
|
function useNavigationSearchShortcut(options) {
|
|
42
43
|
const { open, field } = options;
|
|
@@ -61,12 +62,13 @@ function useNavigationSearchShortcut(options) {
|
|
|
61
62
|
});
|
|
62
63
|
function onKeydown(event) {
|
|
63
64
|
if (owners.value[0] !== owner || isIgnoredTarget(event)) return;
|
|
64
|
-
if (isShortcut(event))
|
|
65
|
-
|
|
65
|
+
if (isShortcut(event)) {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
openSearch(document.activeElement);
|
|
68
|
+
} else if (event.key === "Escape" && origin) open.value = false;
|
|
66
69
|
}
|
|
67
|
-
async function
|
|
68
|
-
|
|
69
|
-
if (!open.value) origin = document.activeElement;
|
|
70
|
+
async function openSearch(from) {
|
|
71
|
+
if (!open.value) origin = from;
|
|
70
72
|
open.value = true;
|
|
71
73
|
await nextTick();
|
|
72
74
|
const input = findInput();
|
|
@@ -76,12 +78,9 @@ function useNavigationSearchShortcut(options) {
|
|
|
76
78
|
function findInput() {
|
|
77
79
|
return toValue(field).$el.querySelector("input");
|
|
78
80
|
}
|
|
79
|
-
function close() {
|
|
80
|
-
open.value = false;
|
|
81
|
-
}
|
|
82
81
|
return {
|
|
83
82
|
...useNavigationShortcutKeys(),
|
|
84
|
-
|
|
83
|
+
openSearch
|
|
85
84
|
};
|
|
86
85
|
}
|
|
87
86
|
function isMacOS() {
|