mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(smart-scope): make icon picker responsive to viewport bounds
The icon picker panel was overflowing the viewport bottom boundary when there wasn't enough space, cutting off the search bar. Add dynamic max height calculation and visual viewport resize listeners. Also includes a style fix for the sidebar section header kebab buttons. Closes #125
This commit is contained in:
@@ -31,6 +31,13 @@ const selectedIconComponent = computed(() => (props.modelValue ? ((LucideIcons a
|
||||
|
||||
const COLS = 10
|
||||
const ROW_HEIGHT = 44
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const PANEL_OFFSET = 4
|
||||
const PANEL_MIN_WIDTH = 440
|
||||
const PANEL_MAX_HEIGHT = 400
|
||||
const PANEL_SEARCH_ROW_HEIGHT = 44
|
||||
const PANEL_DEFAULT_GRID_HEIGHT = 320
|
||||
const PANEL_MIN_GRID_HEIGHT = 140
|
||||
|
||||
const query = ref('')
|
||||
|
||||
@@ -60,27 +67,46 @@ const triggerRef = ref<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const searchRef = ref<HTMLInputElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
const gridHeight = ref(PANEL_DEFAULT_GRID_HEIGHT)
|
||||
|
||||
function positionPanel() {
|
||||
const rect = triggerRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const width = Math.max(rect.width, 440)
|
||||
const width = Math.max(rect.width, PANEL_MIN_WIDTH)
|
||||
let left = rect.left
|
||||
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8
|
||||
if (left < 8) left = 8
|
||||
let top = rect.bottom + 4
|
||||
// Flip upward if panel would overflow viewport bottom
|
||||
if (top + 400 > window.innerHeight - 8) top = rect.top - 400 - 4
|
||||
if (left + width > window.innerWidth - VIEWPORT_MARGIN) left = window.innerWidth - width - VIEWPORT_MARGIN
|
||||
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN
|
||||
|
||||
const maxViewportHeight = Math.max(PANEL_SEARCH_ROW_HEIGHT + PANEL_MIN_GRID_HEIGHT, window.innerHeight - VIEWPORT_MARGIN * 2)
|
||||
const desiredPanelHeight = PANEL_SEARCH_ROW_HEIGHT + PANEL_DEFAULT_GRID_HEIGHT
|
||||
const panelHeight = Math.min(PANEL_MAX_HEIGHT, Math.min(desiredPanelHeight, maxViewportHeight))
|
||||
const availableGridHeight = Math.max(PANEL_MIN_GRID_HEIGHT, panelHeight - PANEL_SEARCH_ROW_HEIGHT)
|
||||
gridHeight.value = Math.min(PANEL_DEFAULT_GRID_HEIGHT, availableGridHeight)
|
||||
|
||||
const spaceBelow = window.innerHeight - rect.bottom - PANEL_OFFSET - VIEWPORT_MARGIN
|
||||
const spaceAbove = rect.top - PANEL_OFFSET - VIEWPORT_MARGIN
|
||||
const shouldOpenUpward = spaceBelow < panelHeight && spaceAbove > spaceBelow
|
||||
|
||||
let top = shouldOpenUpward ? rect.top - panelHeight - PANEL_OFFSET : rect.bottom + PANEL_OFFSET
|
||||
if (top < VIEWPORT_MARGIN) top = VIEWPORT_MARGIN
|
||||
if (top + panelHeight > window.innerHeight - VIEWPORT_MARGIN) top = window.innerHeight - VIEWPORT_MARGIN - panelHeight
|
||||
|
||||
panelStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
maxHeight: `${panelHeight}px`,
|
||||
zIndex: '200',
|
||||
pointerEvents: 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (!open.value) return
|
||||
positionPanel()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open.value) positionPanel()
|
||||
open.value = !open.value
|
||||
@@ -89,8 +115,15 @@ function toggle() {
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
query.value = ''
|
||||
window.removeEventListener('resize', handleViewportChange)
|
||||
window.visualViewport?.removeEventListener('resize', handleViewportChange)
|
||||
} else {
|
||||
nextTick(() => searchRef.value?.focus())
|
||||
nextTick(() => {
|
||||
positionPanel()
|
||||
searchRef.value?.focus()
|
||||
window.addEventListener('resize', handleViewportChange)
|
||||
window.visualViewport?.addEventListener('resize', handleViewportChange)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,6 +150,8 @@ watch(open, (isOpen) => {
|
||||
})
|
||||
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => window.removeEventListener('resize', handleViewportChange))
|
||||
onUnmounted(() => window.visualViewport?.removeEventListener('resize', handleViewportChange))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -193,7 +228,14 @@ onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</div>
|
||||
|
||||
<!-- Virtual icon grid -->
|
||||
<RecycleScroller v-else class="overflow-y-auto px-2 py-1.5" style="height: 320px" :items="rows" :item-size="ROW_HEIGHT" key-field="id">
|
||||
<RecycleScroller
|
||||
v-else
|
||||
class="overflow-y-auto px-2 py-1.5"
|
||||
:style="{ height: `${gridHeight}px` }"
|
||||
:items="rows"
|
||||
:item-size="ROW_HEIGHT"
|
||||
key-field="id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex gap-0.5">
|
||||
<Tooltip v-for="name in item.icons" :key="name">
|
||||
|
||||
@@ -46,7 +46,7 @@ const hasMenu = () => props.canAdd || props.canReorder
|
||||
<DropdownMenu v-if="hasMenu()">
|
||||
<DropdownMenuTrigger
|
||||
class="ml-1 flex h-6 w-6 shrink-0 items-center justify-center transition-colors hover:bg-sidebar-accent"
|
||||
:class="[isReordering ? 'text-primary' : 'text-sidebar-foreground/45 hover:text-sidebar-foreground/90', iconRadiusClass]"
|
||||
:class="[isReordering ? 'text-primary' : 'text-primary/90 hover:text-primary', iconRadiusClass]"
|
||||
@click.stop
|
||||
>
|
||||
<MoreVertical :size="15" :stroke-width="2.2" />
|
||||
|
||||
Reference in New Issue
Block a user