openpencil/src/components/Shell/AppMenu.vue
Danila Poyarkov 60bb484ee4 refactor(ui): remove test-id prop APIs
- Forward data-test-id attrs through rendered controls instead of exposing test-id props
- Move shell chrome components into the Shell namespace
- Remove internal planning docs from published VitePress docs
- Add guardrails and contributor guidance for test hooks and component placement
2026-07-01 09:05:27 +03:00

169 lines
6.6 KiB
Vue

<script setup lang="ts">
import { watch } from 'vue'
import { templateRef } from '@vueuse/core'
import {
MenubarCheckboxItem,
MenubarContent,
MenubarItem,
MenubarItemIndicator,
MenubarMenu,
MenubarPortal,
MenubarRoot,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger
} from 'reka-ui'
import IconChevronRight from '~icons/lucide/chevron-right'
import { vTestId, useI18n } from '@open-pencil/vue'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { useMenuUI } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { useAppMenu } from '@/app/shell/menu/app-menu'
import { useDocumentNameRename } from '@/app/shell/menu/document-name'
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
import {
hasMenuSubItems,
isMenuCheckbox,
isMenuSeparator,
menuChecked,
menuDisabled,
menuLabel,
menuShortcut,
menuSubItems,
runMenuAction,
updateMenuChecked
} from '@/app/shell/menu/entry'
import { useEditorStore } from '@/app/editor/active-store'
const store = useEditorStore()
const { rename, editingName, startRename, commitRename } = useDocumentNameRename(store)
const nameInput = templateRef<HTMLInputElement>('nameInput')
watch(nameInput, (input) => {
if (input) void rename.focusInput(input)
})
const { menu: t } = useI18n()
const { topMenus } = useAppMenu()
const menuCls = useMenuUI()
const mainMenuCls = useMenuUI({ content: 'min-w-52' })
const subMenuCls = useMenuUI({ content: 'min-w-44' })
</script>
<template>
<div class="shrink-0 border-b border-border">
<div class="flex items-center gap-2 px-2 py-1.5">
<img data-test-id="app-logo" src="/favicon-32.png" class="size-4" alt="OpenPencil" />
<input
v-if="editingName"
ref="nameInput"
data-test-id="app-document-name-input"
class="min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0.5 text-xs text-surface outline-none"
:value="store.state.documentName"
@blur="commitRename($event)"
@keydown="rename.onKeydown"
/>
<span
v-else
data-test-id="app-document-name"
class="min-w-0 flex-1 cursor-default truncate rounded px-1 py-0.5 text-xs text-surface hover:bg-hover"
@dblclick="startRename"
>{{ store.state.documentName }}</span
>
<Tip :label="`${t.toggleUI} (${appMenuShortcutLabel('toggle-ui')})`">
<button
data-test-id="app-toggle-ui"
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"
@click="store.state.showUI = !store.state.showUI"
>
<icon-lucide-sidebar class="size-3.5" />
</button>
</Tip>
</div>
<div v-if="!IS_TAURI" class="flex items-center px-1 pb-1">
<MenubarRoot class="scrollbar-none flex items-center gap-0.5 overflow-x-auto">
<MenubarMenu v-for="menu in topMenus" :key="menu.label">
<MenubarTrigger
v-test-id="`menubar-${menu.label.toLowerCase()}`"
class="flex cursor-pointer items-center rounded px-2 py-1 text-xs text-muted transition-colors select-none hover:bg-hover hover:text-surface data-[state=open]:bg-hover data-[state=open]:text-surface"
>
{{ menu.label }}
</MenubarTrigger>
<MenubarPortal>
<MenubarContent :side-offset="4" align="start" :class="mainMenuCls.content">
<template v-for="(item, i) in menu.items" :key="i">
<MenubarSeparator v-if="isMenuSeparator(item)" :class="menuCls.separator" />
<MenubarSub v-else-if="hasMenuSubItems(item)">
<MenubarSubTrigger :class="menuCls.item">
<span class="flex-1">{{ menuLabel(item) }}</span>
<IconChevronRight class="size-3 text-muted" />
</MenubarSubTrigger>
<MenubarPortal>
<MenubarSubContent :side-offset="4" :class="subMenuCls.content">
<template v-for="(sub, j) in menuSubItems(item)" :key="j">
<MenubarSeparator v-if="isMenuSeparator(sub)" :class="menuCls.separator" />
<MenubarCheckboxItem
v-else-if="isMenuCheckbox(sub)"
:model-value="menuChecked(sub)"
:class="menuCls.item"
@update:model-value="updateMenuChecked(sub, $event as boolean)"
>
<span class="flex-1">{{ menuLabel(sub) }}</span>
<MenubarItemIndicator class="text-surface">
<icon-lucide-check class="size-3.5" />
</MenubarItemIndicator>
</MenubarCheckboxItem>
<MenubarItem
v-else
:class="menuCls.item"
:disabled="menuDisabled(sub)"
@select="runMenuAction(sub)"
>
<span class="flex-1">{{ menuLabel(sub) }}</span>
<AppShortcutText v-if="menuShortcut(sub)">{{
menuShortcut(sub)
}}</AppShortcutText>
</MenubarItem>
</template>
</MenubarSubContent>
</MenubarPortal>
</MenubarSub>
<MenubarCheckboxItem
v-else-if="isMenuCheckbox(item)"
:model-value="menuChecked(item)"
:class="menuCls.item"
@update:model-value="updateMenuChecked(item, $event as boolean)"
>
<span class="flex-1">{{ menuLabel(item) }}</span>
<MenubarItemIndicator class="text-surface">
<icon-lucide-check class="size-3.5" />
</MenubarItemIndicator>
</MenubarCheckboxItem>
<MenubarItem
v-else
:class="menuCls.item"
:disabled="menuDisabled(item)"
@select="runMenuAction(item)"
>
<span class="flex-1">{{ menuLabel(item) }}</span>
<AppShortcutText v-if="menuShortcut(item)">{{
menuShortcut(item)
}}</AppShortcutText>
</MenubarItem>
</template>
</MenubarContent>
</MenubarPortal>
</MenubarMenu>
</MenubarRoot>
</div>
</div>
</template>