Fix store item variant defaults and listing sorting

Auto-select first option in variation modals and sort store items by availability, then update time.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-18 11:26:20 +03:30
co-authored by Cursor
parent b861ab3dc1
commit 5f4b0b4f92
20 changed files with 409 additions and 64 deletions
@@ -72,6 +72,34 @@ export function CreateStoreItemsModal({
[variations],
)
// When modal loads/variations change, pre-select the first option for every variation.
// This matches the expected UX and avoids empty selects on initial open.
useEffect(() => {
if (!mounted || closing) return
if (formVariations.length === 0) return
setRows((prev) => {
// Avoid re-setting state if everything is already selected.
let changed = false
const next = prev.map((row) => {
const nextSelections = { ...row.selections }
for (const variation of formVariations) {
const current = nextSelections[variation.id]
if (current) continue
const options = getVariationOptions(variation)
const first = options[0]
if (first?.id) {
nextSelections[variation.id] = first.id
changed = true
}
}
return changed ? { ...row, selections: nextSelections } : row
})
return changed ? next : prev
})
}, [formVariations, mounted, closing])
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
@@ -83,6 +83,32 @@ export function EditStoreItemsModal({
[variations],
)
// When modal loads, ensure each variation select has a valid default selection.
// This fixes cases where existing store-item variants don't return selections for all variations.
useEffect(() => {
if (!mounted || closing) return
if (formVariations.length === 0) return
setRows((prev) => {
let changed = false
const next = prev.map((row) => {
const nextSelections = { ...row.selections }
for (const variation of formVariations) {
const current = nextSelections[variation.id]
if (current) continue
const options = getVariationOptions(variation)
const first = options[0]
if (first?.id) {
nextSelections[variation.id] = first.id
changed = true
}
}
return changed ? { ...row, selections: nextSelections } : row
})
return changed ? next : prev
})
}, [formVariations, mounted, closing])
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '72px', '32px']
@@ -41,6 +41,7 @@ export function PickStoreSpecialItemsModal({
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [selectOpen, setSelectOpen] = useState(false)
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
@@ -85,15 +86,28 @@ export function PickStoreSpecialItemsModal({
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
const nextOptions = listings
.map((listing) => {
.map(
(listing): StoreItemSearchOption | null => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const firstPricedVariant = listing.variants.find((v) => v.price !== null) ?? null
const price = firstPricedVariant?.price ?? null
const hasPrice = listing.variants.some((v) => v.price !== null)
const hasInStock = listing.variants.some(
(v) => v.stockQuantity === null || v.stockQuantity > 0,
)
if (!hasPrice && !hasInStock) return null
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
price,
}
})
},
)
.filter((option): option is StoreItemSearchOption => option !== null)
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
@@ -133,7 +147,11 @@ export function PickStoreSpecialItemsModal({
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
onClick={(e) => {
if (e.target !== e.currentTarget) return
if (selectOpen) return
onClose()
}}
role="presentation"
>
<div
@@ -175,6 +193,7 @@ export function PickStoreSpecialItemsModal({
onToggle={toggleSelection}
placeholder={t('website.specialItems.pickSearch')}
disabled={isSubmitting}
onOpenChange={setSelectOpen}
/>
)}
@@ -107,6 +107,16 @@
color: var(--text-secondary);
}
.optionPrice {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
}
.selectedList {
display: flex;
flex-direction: column;
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { formatIrtPrice } from '../utils/irtPrice'
import selectStyles from './SearchableSelect.module.css'
import styles from './StoreItemMultiSearchSelect.module.css'
@@ -10,6 +11,11 @@ export interface StoreItemSearchOption {
title: string
nameFa: string
image: string | null
/**
* Optional because this shared component is also used for brands/categories.
* When provided, we render it as a single price line.
*/
price?: number | null
}
interface StoreItemMultiSearchSelectProps {
@@ -19,6 +25,11 @@ interface StoreItemMultiSearchSelectProps {
placeholder?: string
disabled?: boolean
minSearchLength?: number
/**
* Optional hook for parents that need to know when the dropdown is open
* (e.g. to prevent modal outside-click closing).
*/
onOpenChange?: (open: boolean) => void
}
export function StoreItemMultiSearchSelect({
@@ -28,6 +39,7 @@ export function StoreItemMultiSearchSelect({
placeholder,
disabled = false,
minSearchLength = 3,
onOpenChange,
}: StoreItemMultiSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -40,6 +52,10 @@ export function StoreItemMultiSearchSelect({
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
useEffect(() => {
onOpenChange?.(open)
}, [open, onOpenChange])
const filtered = useMemo(() => {
const trimmed = query.trim()
if (trimmed.length < minSearchLength) return []
@@ -79,6 +95,9 @@ export function StoreItemMultiSearchSelect({
: ''
: option.nameFa
const priceLine =
option.price === undefined ? null : option.price === null ? ' - ' : formatIrtPrice(option.price)
return (
<span className={styles.optionMeta}>
<span
@@ -97,6 +116,7 @@ export function StoreItemMultiSearchSelect({
{secondary}
</span>
) : null}
{priceLine ? <span className={styles.optionPrice}>{priceLine}</span> : null}
</span>
)
}
@@ -16,6 +16,31 @@
color: var(--text-primary);
}
.titleRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.keyChip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.18);
border-radius: var(--radius-sm);
user-select: text;
white-space: nowrap;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.headerActions {
display: flex;
align-items: center;
@@ -83,7 +83,12 @@ export function StoreSpecialCarousel({
return (
<section className={styles.section}>
<div className={styles.header}>
<h3 className={styles.title}>{special.title}</h3>
<div className={styles.titleRow}>
<span className={styles.keyChip} dir="ltr" title="Slug (key) — قابل کپی کردن">
{special.key}
</span>
<h3 className={styles.title}>{special.title}</h3>
</div>
<div className={styles.headerActions}>
<Tooltip label={t('website.specialItems.editCategory')}>
<button
@@ -6,7 +6,6 @@
.tip {
position: fixed;
z-index: 10000;
transform: translate(-50%, calc(-100% + 4px));
padding: 6px 10px;
font-family: var(--font-ui);
font-size: 12px;
@@ -21,7 +20,7 @@
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
transition: opacity 0.15s ease, visibility 0.15s;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -33,15 +32,22 @@
.tipVisible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -100%);
}
.tip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
left: var(--tooltip-arrow-left, 50%);
transform: translateX(-50%);
border: 5px solid transparent;
}
.tipTop::after {
top: 100%;
border-top-color: rgba(255, 255, 255, 0.95);
}
.tipBottom::after {
bottom: 100%;
border-bottom-color: rgba(255, 255, 255, 0.95);
}
+73 -12
View File
@@ -1,36 +1,88 @@
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactElement,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import styles from './Tooltip.module.css'
const GAP = 8
const PAD = 8
const ARROW = 5
interface TooltipProps {
label: ReactNode
children: ReactElement
}
type TipPlacement = 'top' | 'bottom'
export function Tooltip({ label, children }: TooltipProps) {
const wrapRef = useRef<HTMLSpanElement>(null)
const tipRef = useRef<HTMLSpanElement>(null)
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [ready, setReady] = useState(false)
const [coords, setCoords] = useState<{
top: number
left: number
placement: TipPlacement
arrowLeft: number
}>({ top: 0, left: 0, placement: 'top', arrowLeft: 50 })
function updatePosition() {
const el = wrapRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setCoords({
top: rect.top - 8,
left: rect.left + rect.width / 2,
})
const trigger = wrapRef.current
const tip = tipRef.current
if (!trigger || !tip) return
const rect = trigger.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const tipW = tip.offsetWidth
const tipH = tip.offsetHeight
const spaceAbove = rect.top - PAD
const spaceBelow = vh - rect.bottom - PAD
const needH = tipH + GAP
const fitsAbove = spaceAbove >= needH
const fitsBelow = spaceBelow >= needH
const placement: TipPlacement =
fitsAbove || (!fitsBelow && spaceAbove >= spaceBelow) ? 'top' : 'bottom'
const top = placement === 'top' ? rect.top - GAP - tipH : rect.bottom + GAP
let left = rect.left + rect.width / 2 - tipW / 2
left = Math.min(Math.max(left, PAD), Math.max(PAD, vw - PAD - tipW))
const triggerCenter = rect.left + rect.width / 2
const arrowLeft = Math.min(Math.max(triggerCenter - left, ARROW + 4), Math.max(ARROW + 4, tipW - ARROW - 4))
setCoords({ top, left, placement, arrowLeft })
setReady(true)
}
function show() {
updatePosition()
setVisible(true)
}
function hide() {
setVisible(false)
setReady(false)
}
useLayoutEffect(() => {
if (!visible) return
updatePosition()
}, [visible, label])
useEffect(() => {
if (!visible || ready) return
updatePosition()
}, [visible, ready, label])
useEffect(() => {
if (!visible) return
@@ -46,6 +98,12 @@ export function Tooltip({ label, children }: TooltipProps) {
}
}, [visible])
const style = {
top: coords.top,
left: coords.left,
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
} as CSSProperties
return (
<span
ref={wrapRef}
@@ -63,9 +121,12 @@ export function Tooltip({ label, children }: TooltipProps) {
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
ref={tipRef}
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
style={style}
>
{label}
</span>,
@@ -28,6 +28,7 @@ export interface StoreItem {
isFestival: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
export interface StoreItemsListResponse {
+30 -7
View File
@@ -53,6 +53,16 @@ function pickDisplayPrice(variants: StoreItem[]) {
return { displayPrice, displayDiscountedPrice }
}
function parseDateMs(value: string | undefined) {
const ms = Date.parse(value ?? '')
return Number.isFinite(ms) ? ms : 0
}
function isVariantInStock(variant: StoreItem) {
// `stockQuantity = null` means unlimited stock (in stock).
return variant.stockQuantity === null || variant.stockQuantity > 0
}
export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListing[] {
const byProduct = new Map<string, StoreItem[]>()
@@ -64,10 +74,17 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
return [...byProduct.values()]
.map((variants) => {
const sorted = [...variants].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
const representative = sorted[0]
const sorted = [...variants].sort((a, b) => {
const aInStock = isVariantInStock(a)
const bInStock = isVariantInStock(b)
if (aInStock !== bInStock) return aInStock ? -1 : 1
const updatedDiff = parseDateMs(b.updatedAt) - parseDateMs(a.updatedAt)
if (updatedDiff !== 0) return updatedDiff
return parseDateMs(b.createdAt) - parseDateMs(a.createdAt)
})
const representative = sorted[0]!
const { displayPrice, displayDiscountedPrice } = pickDisplayPrice(sorted)
return {
@@ -87,9 +104,15 @@ export function groupStoreItemsByProduct(items: StoreItem[]): StoreProductListin
}
})
.sort(
(a, b) =>
new Date(b.representative.createdAt).getTime() -
new Date(a.representative.createdAt).getTime(),
(a, b) => {
const aInStock = a.variants.some(isVariantInStock)
const bInStock = b.variants.some(isVariantInStock)
if (aInStock !== bInStock) return aInStock ? -1 : 1
return (
parseDateMs(b.representative.updatedAt) - parseDateMs(a.representative.updatedAt)
)
},
)
}
@@ -32,6 +32,7 @@ function specialStoreItemToVariants(item: StoreSpecialStoreItem): StoreItem[] {
isFestival: variant.isFestival,
sortOrder: variant.sortOrder,
createdAt: '',
updatedAt: '',
}))
}
@@ -6,7 +6,6 @@
.tip {
position: fixed;
z-index: 10000;
transform: translate(-50%, calc(-100% + 4px));
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
@@ -16,7 +15,7 @@
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
transition: opacity 0.15s ease, visibility 0.15s;
background: var(--elevated-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -28,15 +27,22 @@
.tipVisible {
opacity: 1;
visibility: visible;
transform: translate(-50%, -100%);
}
.tip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
left: var(--tooltip-arrow-left, 50%);
transform: translateX(-50%);
border: 5px solid transparent;
}
.tipTop::after {
top: 100%;
border-top-color: var(--elevated-surface);
}
.tipBottom::after {
bottom: 100%;
border-bottom-color: var(--elevated-surface);
}
+72 -12
View File
@@ -1,36 +1,87 @@
import { useEffect, useRef, useState, type ReactElement } from 'react'
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type ReactElement,
} from 'react'
import { createPortal } from 'react-dom'
import styles from './Tooltip.module.css'
const GAP = 8
const PAD = 8
const ARROW = 5
interface TooltipProps {
label: string
children: ReactElement
}
type TipPlacement = 'top' | 'bottom'
export function Tooltip({ label, children }: TooltipProps) {
const wrapRef = useRef<HTMLSpanElement>(null)
const tipRef = useRef<HTMLSpanElement>(null)
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [ready, setReady] = useState(false)
const [coords, setCoords] = useState<{
top: number
left: number
placement: TipPlacement
arrowLeft: number
}>({ top: 0, left: 0, placement: 'top', arrowLeft: 50 })
function updatePosition() {
const el = wrapRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setCoords({
top: rect.top - 8,
left: rect.left + rect.width / 2,
})
const trigger = wrapRef.current
const tip = tipRef.current
if (!trigger || !tip) return
const rect = trigger.getBoundingClientRect()
const vw = window.innerWidth
const vh = window.innerHeight
const tipW = tip.offsetWidth
const tipH = tip.offsetHeight
const spaceAbove = rect.top - PAD
const spaceBelow = vh - rect.bottom - PAD
const needH = tipH + GAP
const fitsAbove = spaceAbove >= needH
const fitsBelow = spaceBelow >= needH
const placement: TipPlacement =
fitsAbove || (!fitsBelow && spaceAbove >= spaceBelow) ? 'top' : 'bottom'
const top = placement === 'top' ? rect.top - GAP - tipH : rect.bottom + GAP
let left = rect.left + rect.width / 2 - tipW / 2
left = Math.min(Math.max(left, PAD), Math.max(PAD, vw - PAD - tipW))
const triggerCenter = rect.left + rect.width / 2
const arrowLeft = Math.min(Math.max(triggerCenter - left, ARROW + 4), Math.max(ARROW + 4, tipW - ARROW - 4))
setCoords({ top, left, placement, arrowLeft })
setReady(true)
}
function show() {
updatePosition()
setVisible(true)
}
function hide() {
setVisible(false)
setReady(false)
}
useLayoutEffect(() => {
if (!visible) return
updatePosition()
}, [visible, label])
useEffect(() => {
if (!visible || ready) return
updatePosition()
}, [visible, ready, label])
useEffect(() => {
if (!visible) return
@@ -46,6 +97,12 @@ export function Tooltip({ label, children }: TooltipProps) {
}
}, [visible])
const style = {
top: coords.top,
left: coords.left,
['--tooltip-arrow-left' as string]: `${coords.arrowLeft}px`,
} as CSSProperties
return (
<span
ref={wrapRef}
@@ -63,9 +120,12 @@ export function Tooltip({ label, children }: TooltipProps) {
{visible
? createPortal(
<span
className={`${styles.tip} ${styles.tipVisible}`}
ref={tipRef}
className={`${styles.tip} ${ready ? styles.tipVisible : ''} ${
coords.placement === 'bottom' ? styles.tipBottom : styles.tipTop
}`}
role="tooltip"
style={{ top: coords.top, left: coords.left }}
style={style}
>
{label}
</span>,
@@ -27,7 +27,6 @@
.menu {
position: fixed;
z-index: 1200;
transform: translateX(-100%);
min-width: 220px;
padding: 6px;
border-radius: 10px;
@@ -98,10 +97,10 @@
@keyframes menuIn {
from {
opacity: 0;
transform: translateX(-100%) translateY(-4px);
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateX(-100%) translateY(0);
transform: translateY(0);
}
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Eraser, MoreHorizontal, Trash2, Users } from 'lucide-react'
import { fitFixedPopover } from '../utils/fitFixedPopover'
import styles from './BusinessActionsOverflowMenu.module.css'
type PopoverPosition = {
@@ -31,10 +32,11 @@ export function BusinessActionsOverflowMenu({
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPopoverPos({
top: rect.bottom + 8,
left: rect.right,
})
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 160 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
function toggleOpen() {
@@ -47,6 +49,11 @@ export function BusinessActionsOverflowMenu({
setOpen(true)
}
useLayoutEffect(() => {
if (!open) return
updatePopoverPosition()
}, [open])
useEffect(() => {
if (!open) return
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import {
Building2,
@@ -8,6 +8,7 @@ import {
Trash2,
UserRound,
} from 'lucide-react'
import { fitFixedPopover } from '../utils/fitFixedPopover'
import styles from './BusinessActionsOverflowMenu.module.css'
type PopoverPosition = {
@@ -52,10 +53,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPopoverPos({
top: rect.bottom + 8,
left: rect.right,
})
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 220 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
function toggleOpen() {
@@ -68,6 +70,11 @@ export function DomainLinksOverflowMenu({ host, onRemove }: DomainLinksOverflowM
setOpen(true)
}
useLayoutEffect(() => {
if (!open) return
updatePopoverPosition()
}, [open])
useEffect(() => {
if (!open) return
@@ -41,7 +41,6 @@
.popover {
position: fixed;
z-index: 1200;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Moon, Sun } from 'lucide-react'
import {
@@ -7,6 +7,7 @@ import {
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
import type { DashboardThemeMode } from '../services/businessSettingsService'
import { fitFixedPopover } from '../utils/fitFixedPopover'
import styles from './PrimaryColorSwatchControl.module.css'
interface PrimaryColorSwatchControlProps {
@@ -41,10 +42,11 @@ export function PrimaryColorSwatchControl({
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPopoverPos({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
})
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
: { width: 220, height: 160 }
setPopoverPos(fitFixedPopover(rect, size, 'center'))
}
function toggleOpen() {
@@ -72,6 +74,11 @@ export function PrimaryColorSwatchControl({
}
}
useLayoutEffect(() => {
if (!open) return
updatePopoverPosition()
}, [open])
useEffect(() => {
if (!open) return
@@ -0,0 +1,35 @@
const GAP = 8
const VIEWPORT_PAD = 8
export type FixedPopoverCoords = {
top: number
left: number
}
export function fitFixedPopover(
trigger: DOMRect,
size: { width: number; height: number },
align: 'end' | 'center' = 'end',
): FixedPopoverCoords {
const vw = window.innerWidth
const vh = window.innerHeight
const width = Math.max(1, size.width)
const height = Math.max(1, size.height)
const spaceBelow = vh - trigger.bottom - VIEWPORT_PAD
const spaceAbove = trigger.top - VIEWPORT_PAD
const placeBelow = spaceBelow >= height + GAP || spaceBelow >= spaceAbove
let top = placeBelow ? trigger.bottom + GAP : trigger.top - GAP - height
top = Math.min(Math.max(top, VIEWPORT_PAD), Math.max(VIEWPORT_PAD, vh - VIEWPORT_PAD - height))
let left =
align === 'center'
? trigger.left + trigger.width / 2 - width / 2
: trigger.right - width
const maxLeft = Math.max(VIEWPORT_PAD, vw - VIEWPORT_PAD - width)
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft)
return { top, left }
}