Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/core/src/client/webcomponents/.generated/css.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { MaybeElementRef } from '@vueuse/core'
import type { PropType, VNode } from 'vue'
import type { FloatingPopoverProps } from '../../state/floating-tooltip'
import { onClickOutside, useDebounceFn, useEventListener } from '@vueuse/core'
import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, useTemplateRef, watch } from 'vue'
import { resolveFloatingPosition } from './floating-position'
import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, Teleport, useTemplateRef, watch } from 'vue'
import { resolveFixedEscapeTarget, resolveFloatingPosition } from './floating-position'

// @unocss-include

Expand All @@ -28,12 +28,23 @@ const FloatingPopoverComponent = defineComponent({
type: Array as PropType<MaybeElementRef[]>,
required: false,
},
/** `menu` trades the tooltip's padding and heavy glass for a flush, fainter surface — a floating panel stacks over an already-tinted one, where tooltip-strength glass composites to near-black. */
surface: {
type: String as PropType<'tooltip' | 'menu'>,
default: 'tooltip',
},
},
emits: ['dismiss'],
setup(props, { emit }) {
const panel = useTemplateRef<HTMLDivElement>('panel')
const el = ref(props.item?.el)
const renderCounter = ref(0)
/** Resolved from the anchor rather than the panel, which may not be in the document yet. */
const escapeTarget = ref<HTMLElement | undefined>()

function refreshEscapeTarget(anchor: Element | undefined) {
escapeTarget.value = anchor ? resolveFixedEscapeTarget(anchor) : undefined
}

const panelSize = reactive({ width: 0, height: 0 })
// Before the first measurement, `resolveFloatingPosition` centers the panel
Expand All @@ -59,7 +70,10 @@ const FloatingPopoverComponent = defineComponent({
})
}

onMounted(measurePanel)
onMounted(() => {
refreshEscapeTarget(props.item?.el)
measurePanel()
})
onUpdated(measurePanel)

useEventListener(window, 'resize', () => {
Expand Down Expand Up @@ -97,6 +111,7 @@ const FloatingPopoverComponent = defineComponent({
el.value = value.el
else
renderCounter.value++
refreshEscapeTarget(value.el)
}
else {
clearThrottled()
Expand All @@ -107,6 +122,10 @@ const FloatingPopoverComponent = defineComponent({
let previousContent: VNode | undefined
let previousStyle: Record<string, string> = {}

/** Escapes the anchor's containing block when there is one, otherwise renders in place. */
const withEscape = (panel: VNode) =>
escapeTarget.value ? h(Teleport, { to: escapeTarget.value }, [panel]) : panel

return () => {
// Force re-render to update the position
// eslint-disable-next-line ts/no-unused-expressions
Expand All @@ -116,23 +135,25 @@ const FloatingPopoverComponent = defineComponent({
return null

const transitionClass = measured.value ? 'transition-all duration-300' : 'transition-opacity duration-300'
// Written out per variant rather than interpolated, so UnoCSS can extract both.
const surfaceClass = props.surface === 'menu' ? 'bg-glass:25 border-#8883 p0' : 'bg-glass:80 border-base px2 p1'

// When dismissing (item is null), keep the last known position
// so the popover fades out in place instead of jumping
if (!props.item) {
return h(
return withEscape(h(
'div',
{
ref: 'panel',
class: [
`fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass:80 color-base border border-base rounded px2 p1`,
`fixed z-floating-tooltip text-xs ${transitionClass} w-max color-base border rounded ${surfaceClass}`,
'op0 pointer-events-none',
props.panelClass,
],
style: previousStyle,
},
previousContent,
)
))
}

const rect = el.value.getBoundingClientRect()
Expand All @@ -157,19 +178,19 @@ const FloatingPopoverComponent = defineComponent({

previousContent = content

return h(
return withEscape(h(
'div',
{
ref: 'panel',
class: [
`fixed z-floating-tooltip text-xs ${transitionClass} w-max bg-glass:80 color-base border border-base rounded px2 p1`,
`fixed z-floating-tooltip text-xs ${transitionClass} w-max color-base border rounded ${surfaceClass}`,
props.item ? 'op100' : 'op0 pointer-events-none',
props.panelClass,
],
style,
},
content,
)
))
}
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,26 @@ export function resolveFloatingPosition(options: ResolveFloatingPositionOptions)

return { align, style }
}

/** Properties whose computed value, when not `none`, makes an element a containing block for `position: fixed` descendants. */
const FIXED_CONTAINING_BLOCK_PROPERTIES = ['transform', 'translate', 'rotate', 'scale', 'perspective', 'filter', 'backdropFilter'] as const

/**
* The element a fixed-position panel anchored to `anchor` must be `<Teleport>`ed into to
* avoid being positioned relative to — and clipped by — a transformed ancestor, or
* `undefined` when there is no such ancestor and the panel can stay in place.
*
* Returns the *outermost* offending ancestor's parent: escaping only the nearest one can
* land inside another, leaving the panel just as mispositioned.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed
*/
export function resolveFixedEscapeTarget(anchor: Element): HTMLElement | undefined {
let outermost: HTMLElement | undefined
for (let node = anchor.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node)
if (FIXED_CONTAINING_BLOCK_PROPERTIES.some(property => style[property] !== 'none') || /paint|layout|strict|content/.test(style.contain))
outermost = node
}
return outermost?.parentElement ?? undefined
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { VNode } from 'vue'
import { useBoundProp } from '@json-render/vue'
import { defineComponent, h, ref, useId, useTemplateRef, watch } from 'vue'
import DockIcon from '../../components/dock/DockIcon.vue'
import FloatingPopover from '../../components/floating/FloatingPopover'
import { bg, borderInput, borderSolid, surfaceSubtle } from './tokens'
import { bg, borderInput, borderSolid, primary, surfaceBadge } from './tokens'
import { registryProps } from './types'

// @unocss-include
Expand All @@ -26,6 +27,13 @@ export interface SelectProps {
disabled?: boolean
/** Adds a substring filter box at the top of the panel. */
searchable?: boolean
/**
* Renders a real `<select>` instead of the custom listbox. The browser draws its option
* list outside the page's layout, so it cannot be clipped or mispositioned by any
* ancestor — at the cost of `icon`, `description` and `searchable`, which have no
* native equivalent.
*/
native?: boolean
}

function normalizeOption(option: string | SelectOption): SelectOption {
Expand Down Expand Up @@ -62,7 +70,7 @@ export const Select = defineComponent({
})

return () => {
const { placeholder, label, disabled, searchable } = ctx.element.props
const { placeholder, label, disabled, searchable, native } = ctx.element.props
const options = (ctx.element.props.options ?? []).map(normalizeOption)
const [value, setValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
const change = ctx.on('change')
Expand All @@ -80,6 +88,47 @@ export const Select = defineComponent({
close({ refocus: true })
}

const withLabel = (control: VNode) => {
if (!label)
return control
return h('div', { style: { display: 'flex', flexDirection: 'column' as const, gap: '4px', flex: '1' } }, [
h('label', { style: { fontSize: '12px', fontWeight: '500' } }, label),
control,
])
}

if (native) {
return withLabel(h('select', {
'value': value ?? '',
'disabled': disabled,
'aria-label': label,
'style': {
flex: '1',
width: '100%',
padding: '6px 10px',
border: borderSolid(borderInput),
borderRadius: '4px',
fontSize: '12px',
backgroundColor: bg,
color: 'inherit',
opacity: disabled ? '0.5' : '1',
cursor: disabled ? 'not-allowed' : 'pointer',
},
'onChange': (e: Event) => {
const next = (e.target as HTMLSelectElement).value
const option = options.find(candidate => candidate.value === next)
if (option)
commit(option)
},
}, [
// Only while unset, so the placeholder can't be re-selected afterwards.
placeholder && value === undefined
? h('option', { value: '', disabled: true }, placeholder)
: null,
...options.map(option => h('option', { value: option.value }, option.label ?? option.value)),
]))
}

const moveActive = (delta: number) => {
if (filtered.length === 0)
return
Expand Down Expand Up @@ -174,7 +223,9 @@ export const Select = defineComponent({
borderRadius: '4px',
fontSize: '12px',
cursor: 'pointer',
backgroundColor: index === activeIndex.value ? surfaceSubtle : 'transparent',
/* The active row needs to read over the panel's own denser surface, where `surfaceSubtle` washes out. */
backgroundColor: index === activeIndex.value ? surfaceBadge : 'transparent',
color: option.value === value ? primary : 'inherit',
},
'onMouseenter': () => { activeIndex.value = index },
'onClick': () => commit(option),
Expand Down Expand Up @@ -225,20 +276,16 @@ export const Select = defineComponent({
const control = h('div', { style: { position: 'relative' as const, flex: '1' } }, [
triggerButton,
h(FloatingPopover, {
item: open.value && trigger.value ? { el: trigger.value, content: () => listbox, placement: 'bottom' as const } : null,
panelClass: ['!p0', 'overflow-hidden'],
/* `DEFAULT_GAP` is tooltip spacing; a menu should sit against the control it belongs to. */
item: open.value && trigger.value ? { el: trigger.value, content: () => listbox, placement: 'bottom' as const, gap: 4 } : null,
panelClass: ['overflow-hidden'],
surface: 'menu' as const,
ignore: [trigger],
onDismiss: () => close(),
}),
])

if (label) {
return h('div', { style: { display: 'flex', flexDirection: 'column' as const, gap: '4px', flex: '1' } }, [
h('label', { style: { fontSize: '12px', fontWeight: '500' } }, label),
control,
])
}
return control
return withLabel(control)
}
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface SelectProps {
label?: string;
disabled?: boolean;
searchable?: boolean;
native?: boolean;
}
export interface StackProps {
direction?: 'row' | 'column';
Expand Down
Loading