Extract menu styling with tailwind-variants

This commit is contained in:
Danila Poyarkov 2026-03-06 19:01:14 +03:00
parent a334a7f63c
commit 35dc5bd2a5
8 changed files with 83 additions and 34 deletions

View file

@ -28,6 +28,8 @@
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"trystero": "^0.22.0",
"valibot": "^1.2.0",
@ -2163,6 +2165,10 @@
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],

View file

@ -53,6 +53,8 @@
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1",
"trystero": "^0.22.0",
"valibot": "^1.2.0",

View file

@ -18,6 +18,7 @@ import IconChevronRight from '~icons/lucide/chevron-right'
import { ref } from 'vue'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
@ -214,13 +215,13 @@ const topMenus = [
<MenubarContent
:side-offset="4"
align="start"
class="min-w-52 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-52' })"
>
<template v-for="(item, i) in menu.items" :key="i">
<MenubarSeparator v-if="item.separator" class="mx-1 my-1 h-px bg-border" />
<MenubarSeparator v-if="item.separator" :class="menuSeparator()" />
<MenubarSub v-else-if="item.sub">
<MenubarSubTrigger
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
:class="menuItem()"
>
<span class="flex-1">{{ item.label }}</span>
<IconChevronRight class="size-3 text-muted" />
@ -228,14 +229,13 @@ const topMenus = [
<MenubarPortal>
<MenubarSubContent
:side-offset="4"
class="min-w-44 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-44' })"
>
<template v-for="(sub, j) in item.sub" :key="j">
<MenubarSeparator v-if="sub.separator" class="mx-1 my-1 h-px bg-border" />
<MenubarSeparator v-if="sub.separator" :class="menuSeparator()" />
<MenubarItem
v-else
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
:class="sub.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
:class="menuItem()"
:disabled="sub.disabled"
@select="sub.action?.()"
>
@ -251,7 +251,7 @@ const topMenus = [
<MenubarCheckboxItem
v-else-if="item.onCheckedChange"
:model-value="item.checked"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs text-surface outline-none select-none hover:bg-hover"
:class="menuItem()"
@update:model-value="item.onCheckedChange?.($event as boolean)"
>
<span class="flex-1">{{ item.label }}</span>
@ -261,8 +261,7 @@ const topMenus = [
</MenubarCheckboxItem>
<MenubarItem
v-else
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none"
:class="item.disabled ? 'text-muted/50' : 'text-surface hover:bg-hover'"
:class="menuItem()"
:disabled="item.disabled"
@select="item.action?.()"
>

View file

@ -18,6 +18,7 @@ import IconImageDown from '~icons/lucide/image-down'
import IconSave from '~icons/lucide/save'
import IconZoomIn from '~icons/lucide/zoom-in'
import { menuContent, menuItem } from '@/components/ui/menu'
import { openFileDialog } from '@/composables/use-menu'
import { useEditorStore } from '@/stores/editor'
import { colorToCSS } from '@open-pencil/core'
@ -216,12 +217,12 @@ const onlineCount = computed(() => props.collabPeers.length + 1)
:side-offset="8"
side="bottom"
align="end"
class="z-50 w-48 rounded-xl border border-border bg-panel p-1.5 shadow-xl"
:class="menuContent({ class: 'w-48 rounded-xl p-1.5 shadow-xl' })"
>
<DropdownMenuItem
v-for="item in menuItems"
:key="item.label"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-none bg-transparent px-2.5 py-2 text-xs text-surface outline-none select-none active:bg-hover data-[highlighted]:bg-hover"
:class="menuItem({ justify: 'start', class: 'w-full gap-2.5 rounded-lg border-none bg-transparent px-2.5 py-2 active:bg-hover' })"
@click="item.action()"
>
<component :is="item.icon" class="size-4 text-muted" />

View file

@ -12,6 +12,7 @@ import {
import { selectionToJSX, renderNodesToSVG } from '@open-pencil/core'
import { useEditorStore } from '@/stores/editor'
import { menuContent, menuItem, menuSeparator } from '@/components/ui/menu'
import { toast } from '@/composables/use-toast'
const store = useEditorStore()
@ -99,12 +100,10 @@ function copyAsJSX() {
toast.show('Copied as JSX')
}
const itemClass =
'flex w-full cursor-pointer select-none items-center justify-between gap-6 rounded px-2 py-1.5 text-xs text-surface outline-none data-[highlighted]:bg-hover data-[disabled]:cursor-default data-[disabled]:text-muted'
const componentItemClass =
'flex w-full cursor-pointer select-none items-center justify-between gap-6 rounded px-2 py-1.5 text-xs text-[#9747ff] outline-none data-[highlighted]:bg-[#9747ff]/12 data-[disabled]:cursor-default data-[disabled]:text-[#9747ff]/40'
const menuClass =
'z-50 min-w-56 rounded-lg border border-border bg-panel p-1 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95'
const itemClass = menuItem()
const componentItemClass = menuItem({ tone: 'component' })
const menuClass = menuContent({ class: 'min-w-56 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95' })
const separatorClass = menuSeparator({ class: 'my-1' })
</script>
<template>
@ -154,7 +153,7 @@ const menuClass =
<span class="text-[11px] text-muted"></span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub v-if="otherPages.length > 0 && hasSelection">
<ContextMenuSubTrigger data-test-id="context-move-to-page" :class="itemClass">
@ -194,7 +193,7 @@ const menuClass =
<span class="text-[11px] text-muted">[</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-group"
@ -224,7 +223,7 @@ const menuClass =
<span class="text-[11px] text-muted">A</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-create-component"
@ -271,7 +270,7 @@ const menuClass =
</ContextMenuItem>
<template v-if="hasSelection">
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuItem
data-test-id="context-toggle-visibility"
@ -290,7 +289,7 @@ const menuClass =
<span class="text-[11px] text-muted">L</span>
</ContextMenuItem>
<ContextMenuSeparator class="my-1 h-px bg-border" />
<ContextMenuSeparator :class="separatorClass" />
<ContextMenuSub>
<ContextMenuSubTrigger data-test-id="context-copy-paste-as" :class="itemClass">

View file

@ -24,6 +24,7 @@ import IconGroup from '~icons/lucide/group'
import IconUngroup from '~icons/lucide/ungroup'
import IconLock from '~icons/lucide/lock'
import { menuContent, menuItem } from '@/components/ui/menu'
import { ACTION_TOAST_DURATION } from '@/constants'
import { TOOLS, useEditorStore } from '@/stores/editor'
import { toolIcons } from '@/utils/tools'
@ -174,18 +175,14 @@ function goNext() {
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-32' })"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
"
:class="menuItem()"
:class="store.state.activeTool === sub ? 'bg-accent text-white' : undefined"
@select="store.setTool(sub)"
>
<component :is="toolIcons[sub]" class="size-3.5" />
@ -291,13 +288,13 @@ function goNext() {
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
:class="menuContent({ class: 'min-w-32' })"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`mobile-toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="menuItem()"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'

View file

@ -2,6 +2,7 @@
import { computed, ref } from 'vue'
import AppSelect from '@/components/AppSelect.vue'
import { menuContent, menuItem } from '@/components/ui/menu'
import ColorInput from '@/components/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@/composables/use-node-props'
@ -322,12 +323,12 @@ const borderWeights = computed(() => {
<DropdownMenuContent
:side-offset="4"
align="end"
class="z-50 min-w-[140px] rounded-md border border-border bg-panel p-0.5 shadow-lg"
:class="menuContent({ class: 'min-w-[140px] rounded-md p-0.5' })"
>
<DropdownMenuItem
v-for="opt in SIDE_OPTIONS"
:key="opt.value"
class="relative flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-surface outline-none data-[highlighted]:bg-hover"
:class="menuItem({ justify: 'start', class: 'relative px-2' })"
@click="selectSide(opt.value)"
>
<icon-lucide-check

44
src/components/ui/menu.ts Normal file
View file

@ -0,0 +1,44 @@
import { tv } from 'tailwind-variants'
import { twMerge } from 'tailwind-merge'
const content = tv({
base: 'z-50 rounded-lg border border-border bg-panel p-1 shadow-lg'
})
const item = tv({
base: 'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none select-none data-[highlighted]:bg-hover data-[disabled]:cursor-default data-[disabled]:text-muted/50',
variants: {
tone: {
default: 'text-surface',
component: 'text-[#9747ff] data-[highlighted]:bg-[#9747ff]/12 data-[disabled]:text-[#9747ff]/40'
},
justify: {
between: 'justify-between gap-6',
start: 'justify-start'
}
},
defaultVariants: {
tone: 'default',
justify: 'between'
}
})
const separator = tv({
base: 'mx-1 my-1 h-px bg-border'
})
export function menuContent(options?: { class?: string }) {
return twMerge(content(), options?.class)
}
export function menuItem(options?: {
tone?: 'default' | 'component'
justify?: 'between' | 'start'
class?: string
}) {
return twMerge(item(options), options?.class)
}
export function menuSeparator(options?: { class?: string }) {
return twMerge(separator(), options?.class)
}