Skip to content

Commit

Permalink
Refactor <menu> to use composition API
Browse files Browse the repository at this point in the history
This also cleans up some unused options, improves type-checking for
users of `<menu>`, and removes unneeded `<div>`s.
  • Loading branch information
josh-berry committed Aug 19, 2024
1 parent 0036c4c commit 557e389
Show file tree
Hide file tree
Showing 2 changed files with 150 additions and 184 deletions.
326 changes: 146 additions & 180 deletions src/components/menu.vue
Original file line number Diff line number Diff line change
@@ -1,229 +1,195 @@
<template>
<details
class="menu"
ref="details"
ref="$details"
:open="isOpen"
:class="{
[$style.details]: true,
above: vertical === 'above',
below: vertical === 'below',
left: horizontal === 'left',
right: horizontal === 'right',
}"
@toggle="onToggle"
@click.prevent.stop="open"
@focusout="onFocusOut"
>
<summary
ref="summary"
:class="{[$style.summary]: true, [summaryClass ?? '']: true}"
tabindex="0"
>
<summary ref="$summary" :class="summaryClass" tabindex="0">
<slot name="summary">{{ name }}</slot>
</summary>

<component
v-if="isOpen || persist"
:is="inPlace ? 'div' : 'teleport'"
to="body"
<div
v-if="isOpen"
:style="bounds"
:class="{
'menu-modal': true,
[$style.modal]: true,
[modalClass ?? '']: true,
above: vertical === 'above',
below: vertical === 'below',
left: horizontal === 'left',
right: horizontal === 'right',
}"
tabindex="-1"
@keydown.esc.prevent.stop="close"
@click.prevent.stop="close"
>
<div
:style="isOpen ? '' : 'display: none'"
:class="{
[$style.modal]: true,
'menu-modal': true,
[modalClass ?? '']: true,
}"
tabindex="-1"
@keydown.esc.prevent.stop="close"
@click.prevent.stop="close"
<nav
ref="$menu"
:class="{menu: true, [$style.menu]: true}"
tabIndex="0"
@click.stop="close"
>
<div
ref="bounds"
:style="bounds"
:class="{
'menu-bounds': true,
[$style.bounds]: true,
above: vertical === 'above',
below: vertical === 'below',
left: horizontal === 'left',
right: horizontal === 'right',
}"
>
<nav
ref="menu"
:class="$style.menu"
tabIndex="0"
@click.stop="close"
@focusout="onFocusOut"
>
<slot></slot>
</nav>
</div>
</div>
</component>
<slot></slot>
</nav>
</div>
</details>
</template>

<script lang="ts">
import {Teleport, defineComponent, type Component, type PropType} from "vue";
export default defineComponent({
components: {
// Cast: work around https://github.com/vuejs/vue-next/issues/2855
Teleport: Teleport as unknown as Component,
},
props: {
name: String,
summaryClass: String,
modalClass: String,
inPlace: Boolean,
persist: Boolean,
hPosition: String as PropType<"left" | "right">,
vPosition: String as PropType<"above" | "below">,
},
data: () => ({
viewport: undefined as DOMRect | undefined,
origin: undefined as DOMRect | undefined,
vertical: undefined as "above" | "below" | undefined,
horizontal: undefined as "left" | "right" | undefined,
isOpen: false,
}),
computed: {
vertical_bound(): string {
if (this.vertical === "below") {
return `padding-top: ${this.origin?.bottom || 0}px;`;
} else {
return `padding-bottom: ${this.viewport!.height - this.origin!.top}px;`;
}
},
horizontal_bound(): string {
if (this.horizontal === "left") {
return `padding-left: ${this.origin?.left}px;`;
} else {
return `padding-right: ${this.viewport!.width - this.origin!.right}px;`;
}
},
bounds(): string {
return `${this.horizontal_bound} ${this.vertical_bound}`;
},
},
methods: {
updatePosition() {
const summary = this.$refs.summary as HTMLElement;
this.viewport = document.body.getBoundingClientRect();
const pos = summary.getBoundingClientRect();
const focus = {
x: this.viewport.width / 2,
y: (2 * this.viewport.height) / 3,
};
this.origin = pos;
this.vertical =
this.vPosition ?? (pos.bottom < focus.y ? "below" : "above");
this.horizontal =
this.hPosition ?? (pos.right < focus.x ? "left" : "right");
},
open() {
// Do this first to avoid flickering
this.updatePosition();
this.isOpen = true;
this.$nextTick(() => {
// Make sure the focus is within the menu so we can detect when
// focus leaves the menu, and close it automatically.
(<HTMLElement>this.$refs.menu).focus();
this.$emit("open");
});
},
close() {
// Move the focus out of the menu before we try to close it,
// otherwise Firefox gets confused about where the focus is and will
// forget to turn off :focus-within attributes on some parent
// elements... (this seems to be a Firefox bug)
(<HTMLElement>this.$refs.summary).focus();
this.$nextTick(() => {
this.isOpen = false;
this.vertical = undefined;
this.horizontal = undefined;
this.$emit("close");
(<HTMLElement>this.$refs.summary).blur();
});
},
onToggle() {
const details = this.$refs.details as HTMLDetailsElement;
if (details.open) {
this.open();
} else {
this.close();
}
},
onFocusOut() {
if ((<any>globalThis).close_menu_on_blur === false) return;
const menu = this.$refs.menu as HTMLElement;
const bounds = this.$refs.bounds as HTMLElement;
if (menu.closest(".menu-bounds:focus-within") !== bounds) {
this.close();
}
},
},
});
import {computed, nextTick, ref} from "vue";
import {onceRefHasValue} from "../util/index.js";
import {trace_fn} from "../util/debug.js";
const trace = trace_fn("menus");
</script>

<style module>
.details {
display: inline-block;
<script setup lang="ts">
const props = defineProps<{
name?: string;
summaryClass?: string;
modalClass?: string;
hPosition?: "left" | "right";
vPosition?: "above" | "below";
}>();
const emit = defineEmits<{
(e: "open"): void;
(e: "close"): void;
}>();
defineExpose({open, close});
const viewport = ref<DOMRect>();
const origin = ref<DOMRect>();
const vertical = ref<"above" | "below">();
const horizontal = ref<"left" | "right">();
const isOpen = ref(false);
const $details = ref<HTMLDetailsElement>();
const $summary = ref<HTMLElement>();
const $menu = ref<HTMLElement>();
const vertical_bound = computed(() => {
if (vertical.value === "below") {
return `padding-top: ${origin.value?.bottom || 0}px;`;
} else {
return `padding-bottom: ${viewport.value!.height - origin.value!.top}px;`;
}
});
const horizontal_bound = computed(() => {
if (horizontal.value === "left") {
return `padding-left: ${origin.value?.left}px;`;
} else {
return `padding-right: ${viewport.value!.width - origin.value!.right}px;`;
}
});
const bounds = computed(
() => `${horizontal_bound.value} ${vertical_bound.value}`,
);
function open() {
trace("open");
isOpen.value = true;
viewport.value = document.body.getBoundingClientRect();
const pos = $summary.value!.getBoundingClientRect();
const focus = {
x: viewport.value!.width / 2,
y: (2 * viewport.value!.height) / 3,
};
origin.value = pos;
vertical.value =
props.vPosition ?? (pos.bottom < focus.y ? "below" : "above");
horizontal.value =
props.hPosition ?? (pos.right < focus.x ? "left" : "right");
onceRefHasValue($menu, m => {
// Make sure the focus is within the menu so we can detect when
// focus leaves the menu, and close it automatically.
m.focus();
emit("open");
});
}
.summary {
display: inline-block;
function close() {
trace("close");
// Move the focus out of the menu before we try to close it,
// otherwise Firefox gets confused about where the focus is and will
// forget to turn off :focus-within attributes on some parent
// elements... (this seems to be a Firefox bug)
if ($details.value) $details.value.focus();
isOpen.value = false;
nextTick(() => {
vertical.value = undefined;
horizontal.value = undefined;
emit("close");
if ($summary.value) $summary.value.blur();
});
}
.modal {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 99;
function onToggle() {
trace("toggle");
if ($details.value!.open) {
open();
} else {
close();
}
}
display: block;
cursor: default;
content: " ";
background: transparent;
overflow: hidden;
function onFocusOut() {
if ((<any>globalThis).menu_close_on_blur === false) return;
if (!$menu.value) return;
if ($menu.value!.closest("details.menu:focus-within") !== $details.value!) {
trace("lost focus");
close();
}
}
</script>

.bounds {
<style module>
.modal {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 100;
overflow: hidden;
box-sizing: border-box;
cursor: default;
overflow: hidden;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.bounds:global(.above) {
.modal:global(.above) {
align-items: end;
}
.bounds:global(.below) {
.modal:global(.below) {
align-items: start;
}
.bounds:global(.left) {
.modal:global(.left) {
justify-items: start;
}
.bounds:global(.right) {
.modal:global(.right) {
justify-items: end;
}
Expand Down
8 changes: 4 additions & 4 deletions styles/modal.less
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ details.menu {
}

// The bounding box holding the menu. We add padding here so the menu never
// outgrows the viewport. (The padding will be overridden by the Vue menu
// component to position the menu properly.)
.menu-bounds {
// outgrows the viewport. (The padding on some dimensions will be overridden by
// the Vue menu component to position the menu properly.)
.menu-modal {
padding: var(--menu-mh) var(--menu-mw);
}

// The pop-up menu itself, showing a list of items
.menu-bounds > nav {
nav.menu {
padding: var(--menu-mh) 0;
background-color: var(--group-bg);
box-shadow: var(--shadow);
Expand Down

0 comments on commit 557e389

Please sign in to comment.