diff --git a/packages/webkit/src/components/navigation/menu-item/menu-item.vue b/packages/webkit/src/components/navigation/menu-item/menu-item.vue
index 2e522fc31..34330c062 100644
--- a/packages/webkit/src/components/navigation/menu-item/menu-item.vue
+++ b/packages/webkit/src/components/navigation/menu-item/menu-item.vue
@@ -64,6 +64,13 @@
const attrs = useAttrs()
const slots = useSlots()
+ const forwardedAttrs = computed(() => {
+ const rest = { ...attrs }
+ delete rest.class
+ delete rest['data-testid']
+ return rest
+ })
+
const testId = computed(
() => (attrs['data-testid'] as string | undefined) ?? 'navigation-menu-item'
)
@@ -75,33 +82,49 @@
() => isOption.value && (Boolean(props.tagValue) || Boolean(slots['tag']))
)
- const sharedRowClasses = [
- 'relative flex w-full shrink-0 items-center',
- 'rounded-[var(--shape-button)] transition-colors motion-reduce:transition-none',
- 'pl-[var(--spacing-xxs)] pr-[var(--spacing-xs)] py-[var(--spacing-xxs)]',
+ const listItemClasses = computed(() => cn('relative w-full shrink-0', attrs.class))
+
+ const focusRingClasses = [
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring-color)]',
- 'focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]'
+ 'focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--menu-item-ring-offset,var(--bg-canvas))]'
]
- const optionRowClasses = computed(() =>
+ const interactiveClasses = computed(() =>
cn(
- sharedRowClasses,
- 'h-9 gap-[var(--spacing-xs)]',
+ 'group relative flex h-8 w-full shrink-0 items-center',
+ 'gap-[var(--spacing-xs)] rounded-[var(--shape-elements)]',
+ 'pl-[var(--spacing-xxs)] pr-[var(--spacing-xs)] py-[var(--spacing-xxs)]',
+ 'transition-colors motion-reduce:transition-none',
+ focusRingClasses,
props.selected
- ? 'bg-[var(--bg-surface-raised)] text-[var(--text-default)]'
- : 'text-[var(--text-default)] hover:bg-[var(--bg-hover)]',
- props.disabled && 'pointer-events-none text-[var(--text-disabled)] hover:bg-transparent',
- attrs.class
+ ? 'bg-[var(--bg-selected)] text-[var(--text-default)]'
+ : 'text-[var(--text-default)] hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-hover)]',
+ props.disabled &&
+ 'pointer-events-none text-[var(--text-disabled)] hover:bg-transparent focus-visible:bg-transparent'
)
)
const groupRowClasses = computed(() =>
- cn(sharedRowClasses, 'h-7 text-[var(--text-muted)]', attrs.class)
+ cn(
+ 'relative flex h-7 w-full shrink-0 items-center',
+ 'rounded-[var(--shape-elements)] pl-[var(--spacing-xxs)] pr-[var(--spacing-xs)] py-[var(--spacing-xxs)]',
+ 'text-[var(--text-muted)]',
+ attrs.class
+ )
)
const iconBoxClasses = 'flex size-8 shrink-0 items-center justify-center overflow-hidden'
- const iconClasses = 'size-4 shrink-0 leading-none text-[length:inherit]'
+ const iconClasses = computed(() =>
+ cn(
+ 'size-4 shrink-0 leading-none text-[length:inherit]',
+ props.disabled
+ ? 'text-[var(--text-disabled)]'
+ : props.selected
+ ? 'text-[var(--text-default)]'
+ : 'text-[var(--text-muted)] group-hover:text-[var(--text-default)] group-focus-visible:text-[var(--text-default)]'
+ )
+ )
const handleClick = (event: MouseEvent) => {
if (props.disabled || isGroup.value) {
@@ -116,7 +139,8 @@
{{ label }}
@@ -167,7 +191,7 @@
{{ label }}
@@ -206,8 +230,9 @@
-
{{ label }}
-
+
diff --git a/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu-content.vue b/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu-content.vue
index bdc878967..ee5d5a417 100644
--- a/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu-content.vue
+++ b/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu-content.vue
@@ -3,6 +3,7 @@
import { cn } from '../../../utils/cn'
import { DropdownMenuInjectionKey } from './injection-key'
+ import { computeDropdownMenuPosition, type DropdownMenuResolvedSide } from './position-panel'
import { DROPDOWN_MENU_PANEL_TRANSITION } from './presets/animations'
import { dropdownMenuContentClasses } from './presets/styles'
@@ -19,34 +20,62 @@
const ctx = inject(DropdownMenuInjectionKey)
const contentRef = ref
(null)
const panelStyle = ref>({})
+ const resolvedSide = ref('bottom')
const isOpen = computed(() => ctx?.isOpen.value ?? false)
const panelId = computed(() => ctx?.menuId ?? undefined)
+ const panelMotionClassBySide: Record = {
+ bottom: false,
+ top: 'webkit-dropdown-menu-panel-motion--top',
+ left: 'webkit-dropdown-menu-panel-motion--left',
+ right: 'webkit-dropdown-menu-panel-motion--right'
+ }
+
const panelClasses = computed(() =>
cn(
dropdownMenuContentClasses,
'webkit-dropdown-menu-panel-motion',
+ panelMotionClassBySide[resolvedSide.value],
attrs.class as string | undefined
)
)
const updatePanelPosition = () => {
const trigger = ctx?.triggerRef.value
+ const panel = contentRef.value
- if (!trigger) {
+ if (!trigger || !panel) {
panelStyle.value = {}
return
}
- const rect = trigger.getBoundingClientRect()
- const sideOffset = ctx?.sideOffset ?? 4
- const alignOffset = ctx?.alignOffset ?? 0
+ const anchorRect = trigger.getBoundingClientRect()
+ const measuredRect = panel.getBoundingClientRect()
+ const floatingRect = {
+ ...measuredRect,
+ width: measuredRect.width || panel.offsetWidth,
+ height: measuredRect.height || panel.offsetHeight
+ }
+
+ if (!floatingRect.width || !floatingRect.height) {
+ return
+ }
+
+ const position = computeDropdownMenuPosition({
+ anchorRect,
+ floatingRect,
+ preferredSide: ctx?.side ?? 'auto',
+ align: 'start',
+ sideOffset: ctx?.sideOffset ?? 4,
+ alignOffset: ctx?.alignOffset ?? 0
+ })
+ resolvedSide.value = position.resolvedSide
panelStyle.value = {
position: 'fixed',
- top: `${rect.bottom + sideOffset}px`,
- left: `${rect.left + alignOffset}px`,
+ top: `${position.top}px`,
+ left: `${position.left}px`,
zIndex: '1100'
}
}
@@ -142,6 +171,8 @@
window.addEventListener('scroll', updatePanelPosition, true)
await nextTick()
updatePanelPosition()
+ await nextTick()
+ updatePanelPosition()
focusFirstMenuItem()
return
}
@@ -183,6 +214,7 @@
aria-orientation="vertical"
:class="panelClasses"
:style="panelStyle"
+ :data-side="resolvedSide"
:data-state="isOpen ? 'open' : 'closed'"
:data-testid="`${ctx?.testId}__content`"
tabindex="-1"
@@ -197,6 +229,18 @@
transform-origin: top center;
}
+ .webkit-dropdown-menu-panel-motion--top {
+ transform-origin: bottom center;
+ }
+
+ .webkit-dropdown-menu-panel-motion--left {
+ transform-origin: center right;
+ }
+
+ .webkit-dropdown-menu-panel-motion--right {
+ transform-origin: center left;
+ }
+
.webkit-dropdown-menu-panel-enter-active,
.webkit-dropdown-menu-panel-leave-active {
transition:
diff --git a/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu.vue b/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu.vue
index 4f7cab2b9..2fe63c079 100644
--- a/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu.vue
+++ b/packages/webkit/src/components/overlay/dropdown-menu/dropdown-menu.vue
@@ -3,6 +3,7 @@
import { useControllable } from '../../../composables/use-controllable'
import { DropdownMenuInjectionKey } from './injection-key'
+ import type { DropdownMenuSide } from './position-panel'
defineOptions({
name: 'DropdownMenu',
@@ -23,6 +24,8 @@
closeable?: boolean
/** When true, selecting an item closes the menu. */
closeOnSelect?: boolean
+ /** Preferred panel placement; `auto` picks the side with the most viewport space. */
+ side?: DropdownMenuSide
/** Gap between trigger and panel (px). */
sideOffset?: number
/** Horizontal offset from trigger left edge (px). */
@@ -33,6 +36,7 @@
defaultOpen: false,
closeable: true,
closeOnSelect: true,
+ side: 'auto',
sideOffset: 4,
alignOffset: 0
}
@@ -77,6 +81,7 @@
isOpen: computed(() => isOpen.value),
closeable: props.closeable,
closeOnSelect: props.closeOnSelect,
+ side: props.side,
sideOffset: props.sideOffset,
alignOffset: props.alignOffset,
open: openMenu,
diff --git a/packages/webkit/src/components/overlay/dropdown-menu/injection-key.ts b/packages/webkit/src/components/overlay/dropdown-menu/injection-key.ts
index 723510871..b6f370bb7 100644
--- a/packages/webkit/src/components/overlay/dropdown-menu/injection-key.ts
+++ b/packages/webkit/src/components/overlay/dropdown-menu/injection-key.ts
@@ -1,11 +1,15 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
+import type { DropdownMenuSide } from './position-panel'
+
export interface DropdownMenuContext {
testId: string
isOpen: ComputedRef
closeable: boolean
closeOnSelect: boolean
- /** Gap between trigger bottom and panel top (px). */
+ /** Preferred panel placement relative to the trigger. */
+ side: DropdownMenuSide
+ /** Gap between trigger and panel (px). */
sideOffset: number
/** Horizontal offset from trigger left (px). */
alignOffset: number
diff --git a/packages/webkit/src/components/overlay/dropdown-menu/position-panel.ts b/packages/webkit/src/components/overlay/dropdown-menu/position-panel.ts
new file mode 100644
index 000000000..90b2ea41d
--- /dev/null
+++ b/packages/webkit/src/components/overlay/dropdown-menu/position-panel.ts
@@ -0,0 +1,195 @@
+export type DropdownMenuSide = 'top' | 'bottom' | 'left' | 'right' | 'auto'
+export type DropdownMenuResolvedSide = 'top' | 'bottom' | 'left' | 'right'
+export type DropdownMenuAlign = 'start' | 'center' | 'end'
+
+export interface DropdownMenuRect {
+ top: number
+ right: number
+ bottom: number
+ left: number
+ width: number
+ height: number
+}
+
+const OPPOSITE_SIDE: Record = {
+ top: 'bottom',
+ bottom: 'top',
+ left: 'right',
+ right: 'left'
+}
+
+const SIDE_ORDER: DropdownMenuResolvedSide[] = ['bottom', 'top', 'right', 'left']
+
+export interface DropdownMenuPositionInput {
+ anchorRect: DropdownMenuRect
+ floatingRect: DropdownMenuRect
+ preferredSide: DropdownMenuSide
+ align?: DropdownMenuAlign
+ sideOffset?: number
+ alignOffset?: number
+ collisionPadding?: number
+}
+
+export interface DropdownMenuPositionResult {
+ top: number
+ left: number
+ resolvedSide: DropdownMenuResolvedSide
+ resolvedAlign: DropdownMenuAlign
+}
+
+function getViewport() {
+ return {
+ width: typeof window !== 'undefined' ? window.innerWidth : 0,
+ height: typeof window !== 'undefined' ? window.innerHeight : 0
+ }
+}
+
+function sideFits(
+ side: DropdownMenuResolvedSide,
+ anchorRect: DropdownMenuRect,
+ floatingRect: DropdownMenuRect,
+ sideOffset: number,
+ collisionPadding: number
+): boolean {
+ const viewport = getViewport()
+
+ switch (side) {
+ case 'bottom':
+ return (
+ anchorRect.bottom + sideOffset + floatingRect.height + collisionPadding <= viewport.height
+ )
+ case 'top':
+ return anchorRect.top - sideOffset - floatingRect.height - collisionPadding >= 0
+ case 'right':
+ return anchorRect.right + sideOffset + floatingRect.width + collisionPadding <= viewport.width
+ case 'left':
+ return anchorRect.left - sideOffset - floatingRect.width - collisionPadding >= 0
+ default:
+ return true
+ }
+}
+
+function availableSpace(
+ side: DropdownMenuResolvedSide,
+ anchorRect: DropdownMenuRect,
+ sideOffset: number,
+ collisionPadding: number
+): number {
+ const viewport = getViewport()
+
+ switch (side) {
+ case 'bottom':
+ return viewport.height - anchorRect.bottom - sideOffset - collisionPadding
+ case 'top':
+ return anchorRect.top - sideOffset - collisionPadding
+ case 'right':
+ return viewport.width - anchorRect.right - sideOffset - collisionPadding
+ case 'left':
+ return anchorRect.left - sideOffset - collisionPadding
+ default:
+ return 0
+ }
+}
+
+function resolvePreferredSide(
+ preferredSide: DropdownMenuSide,
+ anchorRect: DropdownMenuRect,
+ floatingRect: DropdownMenuRect,
+ sideOffset: number,
+ collisionPadding: number
+): DropdownMenuResolvedSide {
+ if (preferredSide !== 'auto') {
+ return preferredSide
+ }
+
+ const fitting = SIDE_ORDER.filter((side) =>
+ sideFits(side, anchorRect, floatingRect, sideOffset, collisionPadding)
+ )
+
+ if (fitting.length > 0) {
+ return fitting.reduce((best, side) => {
+ const bestSpace = availableSpace(best, anchorRect, sideOffset, collisionPadding)
+ const sideSpace = availableSpace(side, anchorRect, sideOffset, collisionPadding)
+ return sideSpace > bestSpace ? side : best
+ })
+ }
+
+ return SIDE_ORDER.reduce((best, side) => {
+ const bestSpace = availableSpace(best, anchorRect, sideOffset, collisionPadding)
+ const sideSpace = availableSpace(side, anchorRect, sideOffset, collisionPadding)
+ return sideSpace > bestSpace ? side : best
+ })
+}
+
+export function computeDropdownMenuPosition({
+ anchorRect,
+ floatingRect,
+ preferredSide,
+ align = 'start',
+ sideOffset = 4,
+ alignOffset = 0,
+ collisionPadding = 8
+}: DropdownMenuPositionInput): DropdownMenuPositionResult {
+ let resolvedSide = resolvePreferredSide(
+ preferredSide,
+ anchorRect,
+ floatingRect,
+ sideOffset,
+ collisionPadding
+ )
+
+ if (
+ preferredSide !== 'auto' &&
+ !sideFits(resolvedSide, anchorRect, floatingRect, sideOffset, collisionPadding) &&
+ sideFits(OPPOSITE_SIDE[resolvedSide], anchorRect, floatingRect, sideOffset, collisionPadding)
+ ) {
+ resolvedSide = OPPOSITE_SIDE[resolvedSide]
+ }
+
+ const viewport = getViewport()
+ let x = 0
+ let y = 0
+
+ if (resolvedSide === 'top' || resolvedSide === 'bottom') {
+ y =
+ resolvedSide === 'bottom'
+ ? anchorRect.bottom + sideOffset
+ : anchorRect.top - sideOffset - floatingRect.height
+
+ if (align === 'start') {
+ x = anchorRect.left + alignOffset
+ } else if (align === 'end') {
+ x = anchorRect.right - floatingRect.width - alignOffset
+ } else {
+ x = anchorRect.left + anchorRect.width / 2 - floatingRect.width / 2 + alignOffset
+ }
+
+ const maxX = viewport.width - floatingRect.width - collisionPadding
+ if (x < collisionPadding) x = collisionPadding
+ if (x > maxX) x = Math.max(collisionPadding, maxX)
+ } else {
+ x =
+ resolvedSide === 'right'
+ ? anchorRect.right + sideOffset
+ : anchorRect.left - sideOffset - floatingRect.width
+
+ if (align === 'start') {
+ y = anchorRect.top + alignOffset
+ } else if (align === 'end') {
+ y = anchorRect.bottom - floatingRect.height - alignOffset
+ } else {
+ y = anchorRect.top + anchorRect.height / 2 - floatingRect.height / 2 + alignOffset
+ }
+
+ const maxY = viewport.height - floatingRect.height - collisionPadding
+ if (y < collisionPadding) y = collisionPadding
+ if (y > maxY) y = Math.max(collisionPadding, maxY)
+ }
+
+ return {
+ top: y,
+ left: x,
+ resolvedSide,
+ resolvedAlign: align
+ }
+}