Menubar
A row of menus like File, Edit, View. Fill each with the dropdown menu's items.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add menubarpnpm dlx ermineui add menubaryarn dlx ermineui add menubarbunx ermineui add menubarThat copies 6 files into src/components/ui/menubar/, 1 helper into src/lib/ermine/, and the 1 component it is built from. Files you already have are kept.
Use
import { Menubar, MenubarContent, MenubarMenu, MenubarTrigger } from '@/components/ui/menubar'<NavigationMenu>
<NavigationMenuItem>
<NavigationMenuTrigger>Product</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink href="/charts" description="Line, bar and more.">Charts</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem><NavigationMenuLink href="/pricing" active>Pricing</NavigationMenuLink></NavigationMenuItem>
</NavigationMenu>
<Menubar label="Editor">
<MenubarMenu>
<MenubarTrigger>File</MenubarTrigger>
<MenubarContent>
<DropdownMenuItem @select="save">Save</DropdownMenuItem>
</MenubarContent>
</MenubarMenu>
</Menubar>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Menubar.vue | src/components/ui/menubar/Menubar.vue |
MenubarContent.vue | src/components/ui/menubar/MenubarContent.vue |
MenubarMenu.vue | src/components/ui/menubar/MenubarMenu.vue |
MenubarTrigger.vue | src/components/ui/menubar/MenubarTrigger.vue |
context.ts | src/components/ui/menubar/context.ts |
index.ts | src/components/ui/menubar/index.ts |
context.ts helper | src/lib/ermine/context.ts |
It is built from: Dropdown menu. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Menubar.vue 92 lines
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { provideMenubar } from './context'
defineProps<{
/** Accessible name of the bar. */
label?: string
}>()
const root = ref<HTMLElement | null>(null)
const openId = ref<string>()
const tabStop = ref<string>()
const triggers = () => Array.from(root.value?.querySelectorAll<HTMLElement>('.er-menubar-trigger:not([disabled])') ?? [])
provideMenubar({
openId,
tabStop,
move: (from, direction, open) => {
const list = triggers()
const index = list.findIndex((trigger) => trigger.dataset.menu === from)
const next = list[(index + direction + list.length) % list.length]
if (!next) return
tabStop.value = next.dataset.menu
next.focus()
// With a menu open, moving sideways opens the neighbouring menu, as desktop menu bars do.
if (open) next.dispatchEvent(new CustomEvent('er-open-menu'))
else openId.value = undefined
},
})
onMounted(() => {
tabStop.value = triggers()[0]?.dataset.menu
})
</script>
<template>
<div ref="root" role="menubar" class="er-menubar" :aria-label="label">
<slot />
</div>
</template>
<style>
@layer components {
.er-menubar {
display: inline-flex;
align-items: center;
gap: 0.125rem;
max-width: 100%;
padding: 0.25rem;
border: 1px solid var(--er-border);
border-radius: var(--er-radius-lg);
background: var(--er-background);
box-shadow: var(--er-shadow-xs);
}
.er-menubar-trigger {
display: inline-flex;
align-items: center;
height: 1.75rem;
padding: 0 0.625rem;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-foreground);
font-family: inherit;
font-size: 0.875rem;
font-weight: var(--er-weight-medium);
white-space: nowrap;
cursor: default;
outline: none;
}
.er-menubar-trigger:hover,
.er-menubar-trigger:focus-visible,
.er-menubar-trigger[data-state='open'] {
background: var(--er-accent);
color: var(--er-accent-foreground);
}
.er-menubar-trigger:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 1px;
}
.er-menubar-trigger:disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>MenubarContent.vue 22 lines
<script setup lang="ts">
import { DropdownMenuContent } from '../dropdown-menu'
import { useMenubar, useMenubarMenu } from './context'
const bar = useMenubar()
const { id } = useMenubarMenu()
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
// Right on a submenu's trigger opens that submenu; anywhere else it moves to the next menu in the bar.
if (event.key === 'ArrowRight' && (event.target as HTMLElement).getAttribute('aria-haspopup') === 'menu') return
event.preventDefault()
bar.move(id, event.key === 'ArrowRight' ? 1 : -1, true)
}
</script>
<template>
<DropdownMenuContent side="bottom" align="start" :side-offset="6" @keydown="onKeydown">
<slot />
</DropdownMenuContent>
</template>MenubarMenu.vue 34 lines
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
import { provideDropdownMenu } from '../dropdown-menu/context'
import { provideMenubarMenu, useMenubar } from './context'
const bar = useMenubar()
const id = useId()
const open = computed({
get: () => bar.openId.value === id,
set: (next: boolean) => {
if (next) bar.openId.value = id
else if (bar.openId.value === id) bar.openId.value = undefined
},
})
// Each menu is a dropdown menu underneath, so the dropdown's items, groups and submenus all work inside it.
provideDropdownMenu({
open,
setOpen: (next) => {
open.value = next
},
anchor: ref(null),
focusFirst: ref(false),
triggerId: `${id}-trigger`,
contentId: `${id}-content`,
})
provideMenubarMenu({ id })
</script>
<template>
<slot :open="open" />
</template>MenubarTrigger.vue 64 lines
<script setup lang="ts">
import { useDropdownMenu } from '../dropdown-menu/context'
import { useMenubar, useMenubarMenu } from './context'
defineProps<{
disabled?: boolean
}>()
const bar = useMenubar()
const { id } = useMenubarMenu()
const menu = useDropdownMenu()
function openMenu(focusFirst: boolean) {
menu.focusFirst.value = focusFirst
menu.setOpen(true)
}
function onClick(event: MouseEvent) {
bar.tabStop.value = id
if (menu.open.value) menu.setOpen(false)
// detail is 0 when the click came from Enter or Space rather than a pointer.
else openMenu(event.detail === 0)
}
function onPointerEnter() {
// Once one menu is open, sliding along the bar opens whichever menu the pointer is over.
if (bar.openId.value !== undefined && bar.openId.value !== id) openMenu(false)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault()
openMenu(true)
} else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault()
bar.move(id, event.key === 'ArrowRight' ? 1 : -1, menu.open.value)
}
}
</script>
<template>
<button
:id="menu.triggerId"
:ref="(el) => (menu.anchor.value = el as HTMLElement | null)"
type="button"
role="menuitem"
class="er-menubar-trigger"
aria-haspopup="menu"
:aria-expanded="menu.open.value"
:aria-controls="menu.open.value ? menu.contentId : undefined"
:tabindex="bar.tabStop.value === id ? 0 : -1"
:disabled="disabled"
:data-state="menu.open.value ? 'open' : 'closed'"
:data-menu="id"
@click="onClick"
@pointerenter="onPointerEnter"
@keydown="onKeydown"
@focus="bar.tabStop.value = id"
@er-open-menu="openMenu(true)"
>
<slot />
</button>
</template>context.ts 15 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface MenubarContext {
/** id of the menu that is open, if any. */
openId: Ref<string | undefined>
/** id of the trigger Tab stops on: the bar is one stop, and the arrow keys move within it. */
tabStop: Ref<string | undefined>
/** Move to the menu before (-1) or after (1) the given one, opening it if a menu was open. */
move: (from: string, direction: 1 | -1, open: boolean) => void
}
export const [provideMenubar, useMenubar] = createContext<MenubarContext>('Menubar')
export const [provideMenubarMenu, useMenubarMenu] = createContext<{ id: string }>('MenubarMenu')index.ts 6 lines
/** Menubar — a row of menus like File, Edit, View. Fill each with the dropdown menu's items. */
export { default as Menubar } from './Menubar.vue'
export { default as MenubarContent } from './MenubarContent.vue'
export { default as MenubarMenu } from './MenubarMenu.vue'
export { default as MenubarTrigger } from './MenubarTrigger.vue'