Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/common/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { playAlarm } from './play-alarm'
import { translateError } from '@/common/utils/translate-error'
import { Icon } from '../icons'
import { cn } from '@/common/utils/cn'
import { raiseToTopLayer } from '@/components/ui/portal/portal'

export const TOAST_TOP_LAYER_ID = 'widgetify-toast-top-layer'

function raiseToastLayer() {
if (typeof document === 'undefined') return
raiseToTopLayer(document.getElementById(TOAST_TOP_LAYER_ID))
}

export type ToastType = 'success' | 'error' | 'info' | 'warning'

Expand Down Expand Up @@ -169,6 +177,8 @@ export function showToast(
type: ToastType = 'info',
options?: ToastOptions
) {
raiseToastLayer()

const theme = TOAST_THEMES[type] || TOAST_THEMES.info
const title = options?.title ?? theme.defaultTitle
const actionText = options?.actionText ?? theme.defaultActionText
Expand Down Expand Up @@ -239,6 +249,8 @@ export function showCustomToast(
message: React.ReactNode | string,
options?: ToastOptions
) {
raiseToastLayer()

if (options?.sound !== false) {
playNativeToastSound('info')
}
Expand All @@ -253,6 +265,8 @@ export function showCustomToast(
}

export function showPreviewToast(itemName: string, onCancel: () => void): string {
raiseToastLayer()

const id = `preview-${Date.now()}`

playNativeToastSound('info')
Expand Down
84 changes: 41 additions & 43 deletions src/components/ui/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,51 +208,49 @@ export function Dropdown({
{trigger}
</div>

<Portal>
<Presence>
{isOpen && !disabled && (
<div
key="dropdown-layer"
id={id}
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 99999 }}
<Presence>
{isOpen && !disabled && (
<Portal
topLayer
key="dropdown-layer"
id={id}
style={{ zIndex: 99999 }}
>
<Motion.div
ref={dropdownContentRef}
className={`fixed shadow-xl overflow-hidden rounded-2xl bg-base-200 backdrop-blur-xl ${isReady ? 'pointer-events-auto' : 'pointer-events-none'} ${dropdownClassName}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isReady ? 1 : 0,
scale: isReady ? 1 : 0.95,
}}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{
maxHeight,
width:
width === 'auto'
? 'auto'
: width === 'full'
? '100%'
: width,
minWidth: width === 'auto' ? '150px' : undefined,
top: dropdownPosition.top,
left: dropdownPosition.left,
visibility: isReady ? 'visible' : 'hidden',
zIndex: 99999,
}}
>
<Motion.div
ref={dropdownContentRef}
className={`fixed shadow-xl overflow-hidden rounded-2xl bg-base-200 backdrop-blur-xl ${isReady ? 'pointer-events-auto' : 'pointer-events-none'} ${dropdownClassName}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isReady ? 1 : 0,
scale: isReady ? 1 : 0.95,
}}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{
maxHeight,
width:
width === 'auto'
? 'auto'
: width === 'full'
? '100%'
: width,
minWidth: width === 'auto' ? '150px' : undefined,
top: dropdownPosition.top,
left: dropdownPosition.left,
visibility: isReady ? 'visible' : 'hidden',
zIndex: 99999,
}}
<div
className="overflow-y-auto overscroll-contain scrollbar-none"
style={{ maxHeight }}
>
<div
className="overflow-y-auto overscroll-contain scrollbar-none"
style={{ maxHeight }}
>
{dropdownContent}
</div>
</Motion.div>
</div>
)}
</Presence>
</Portal>
{dropdownContent}
</div>
</Motion.div>
</Portal>
)}
</Presence>
</div>
)
}
16 changes: 11 additions & 5 deletions src/components/ui/modal/modal.variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ export const modalBoxVariants = cva(
'transition-discrete',
'opacity-0',
'scale-95',
'group-open:opacity-100',
'group-open:scale-100',
'starting:group-open:opacity-0',
'starting:group-open:scale-95',
'group-open/modal:opacity-100',
'group-open/modal:scale-100',
'starting:group-open/modal:opacity-0',
'starting:group-open/modal:scale-95',
],
{
variants: {
Expand Down Expand Up @@ -64,6 +64,12 @@ export const modalScrollVariants = cva(

export type ModalScrollVariant = typeof modalScrollVariants

export const modalDialogVariants = cva(['modal', 'modal-middle', 'group', 'p-2', 'md:p-4'])
export const modalDialogVariants = cva([
'modal',
'modal-middle',
'group',
'p-2',
'md:p-4',
])

export type ModalDialogVariant = typeof modalDialogVariants
102 changes: 100 additions & 2 deletions src/components/ui/portal/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,110 @@
import type { ReactNode } from 'react'
import {
useEffect,
useLayoutEffect,
useState,
type CSSProperties,
type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'

export interface PortalProps {
children: ReactNode
container?: Element | null
topLayer?: boolean
id?: string
className?: string
style?: CSSProperties
}

export function Portal({ children, container = document.body }: PortalProps) {
const TOP_LAYER_HOST_CSS =
'position:fixed;inset:0;width:auto;height:auto;max-width:none;max-height:none;margin:0;padding:0;border:none;background:transparent;overflow:visible;color:inherit;pointer-events:none;'

function topmostModalDialog(): HTMLElement | null {
let topmost: HTMLElement | null = null

try {
for (const dialog of document.querySelectorAll('dialog[open]')) {
if (dialog.matches(':modal')) topmost = dialog as HTMLElement
}
} catch {
return null
}

return topmost
}

export function raiseToTopLayer(host: HTMLElement | null) {
if (!host) return

const parent = topmostModalDialog() ?? document.body

if (host.parentElement !== parent) {
parent.appendChild(host)

if (parent !== document.body) {
parent.addEventListener('close', () => raiseToTopLayer(host), {
once: true,
})
}
}

if (typeof host.showPopover !== 'function') return

try {
host.hidePopover()
} catch {}

try {
host.showPopover()
} catch {}
}

type TopLayerHostProps = Pick<PortalProps, 'children' | 'id' | 'className' | 'style'>

function TopLayerHost({ children, id, className, style }: TopLayerHostProps) {
const [host] = useState(() => {
const el = document.createElement('div')
el.setAttribute('popover', 'manual')
return el
})

useLayoutEffect(() => {
if (id) host.id = id
host.className = className ?? ''
host.style.cssText = TOP_LAYER_HOST_CSS
if (style) Object.assign(host.style, style)
})

useEffect(() => {
raiseToTopLayer(host)

return () => {
try {
host.hidePopover()
} catch {}
host.remove()
}
}, [host])

return createPortal(children, host)
}

export function Portal({
children,
container = document.body,
topLayer,
id,
className,
style,
}: PortalProps) {
if (topLayer) {
return (
<TopLayerHost id={id} className={className} style={style}>
{children}
</TopLayerHost>
)
}

if (!container) return null

return createPortal(children, container)
Expand Down
11 changes: 5 additions & 6 deletions src/components/ui/tooltip/clickable-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Motion as motion, Presence } from '@/common/motion'

import { type ReactNode, useEffect, useRef, useState, type RefObject } from 'react'
import ReactDOM from 'react-dom'
import { Portal } from '../portal/portal'

type Position =
| 'top'
Expand Down Expand Up @@ -208,12 +208,12 @@ const ClickableTooltip = ({

return (
<>
{ReactDOM.createPortal(
<Portal topLayer>
<Presence mode="wait">
{isOpen && (
<motion.div
ref={tooltipRef}
className={`fixed text-xs max-w-xs bg-transparent! shadow-md bg-glass rounded-2xl ${contentClassName}`}
className={`fixed text-xs pointer-events-auto max-w-xs bg-transparent! shadow-md bg-glass rounded-2xl ${contentClassName}`}
style={{
left: tooltipCoords.x,
top: tooltipCoords.y,
Expand All @@ -228,9 +228,8 @@ const ClickableTooltip = ({
{content}
</motion.div>
)}
</Presence>,
document.body
)}
</Presence>
</Portal>
</>
)
}
Expand Down
14 changes: 7 additions & 7 deletions src/components/ui/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { twMerge } from 'tailwind-merge'
import { Motion as motion, Presence } from '@/common/motion'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Portal } from '../portal/portal'

type Position =
| 'top'
Expand Down Expand Up @@ -217,12 +217,12 @@ export const Tooltip = ({
{children}
</div>

{(alwaysShow || isVisible) &&
ReactDOM.createPortal(
{(alwaysShow || isVisible) && (
<Portal topLayer>
<Presence>
<motion.div
ref={tooltipRef}
className={`tooltip fixed rounded-lg py-1.5 px-3 text-xs max-w-xs bg-content shadow-lg z-popover ${contentClassName}`}
className={`tooltip fixed pointer-events-auto rounded-lg py-1.5 px-3 text-xs max-w-xs bg-content shadow-lg z-popover ${contentClassName}`}
style={{
left: tooltipCoords.x,
top: tooltipCoords.y,
Expand All @@ -238,9 +238,9 @@ export const Tooltip = ({
{content}
<div className={getArrowClasses(calculatedPosition)} />
</motion.div>
</Presence>,
document.body
)}
</Presence>
</Portal>
)}
</>
)
}
Loading
Loading