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
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,48 @@ export const ToggleTrigger: Story = {
}),
}

/**
* A `transform` on an ancestor turns it into a containing block for `position: fixed`
* descendants — without the escape fix, the panel would be positioned relative to (and
* clipped by) the transformed box below rather than the viewport. It still lands on the
* trigger correctly here because `FloatingPopover` `<Teleport>`s the panel out to that
* ancestor's parent.
*/
export const EscapesTransformedAncestor: Story = {
render: () => defineComponent({
setup() {
const triggerEl = ref<HTMLElement | null>(null)
const open = ref(false)
const item = computed(() => (open.value && triggerEl.value)
? { el: triggerEl.value, content: () => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [
h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'),
...['Overview', 'Pages', 'Components'].map(label =>
h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)),
]) }
: null)
return () => h('div', { class: 'flex items-center justify-center p20 min-h-80 font-sans' }, [
h('div', {
class: 'p8 border-2 border-dashed border-red rounded of-hidden',
style: { transform: 'translateZ(0)' },
}, [
h('div', { class: 'text-xs op60 mb2' }, 'Transformed + clipping ancestor'),
h('button', {
ref: (el: any) => (triggerEl.value = el),
class: 'px3 py1.5 rounded border border-base bg-glass color-base shadow',
onClick: () => (open.value = !open.value),
}, 'Toggle menu'),
]),
h(FloatingPopover, {
item: item.value,
panelClass: '!p0',
ignore: [triggerEl],
onDismiss: () => (open.value = false),
}),
])
},
}),
}

export const CornerAnchors: Story = {
render: () => defineComponent({
setup() {
Expand Down
28 changes: 21 additions & 7 deletions packages/hub-ui/src/client/components/floating/FloatingPopover.ts
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 Down Expand Up @@ -34,6 +34,12 @@ const FloatingPopoverComponent = defineComponent({
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 +65,10 @@ const FloatingPopoverComponent = defineComponent({
})
}

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

useEventListener(window, 'resize', () => {
Expand Down Expand Up @@ -97,6 +106,7 @@ const FloatingPopoverComponent = defineComponent({
el.value = value.el
else
renderCounter.value++
refreshEscapeTarget(value.el)
}
else {
clearThrottled()
Expand All @@ -107,6 +117,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 = (node: VNode) =>
escapeTarget.value ? h(Teleport, { to: escapeTarget.value }, [node]) : node

return () => {
// Force re-render to update the position
// eslint-disable-next-line ts/no-unused-expressions
Expand All @@ -120,7 +134,7 @@ const FloatingPopoverComponent = defineComponent({
// 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',
Expand All @@ -132,7 +146,7 @@ const FloatingPopoverComponent = defineComponent({
style: previousStyle,
},
previousContent,
)
))
}

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

previousContent = content

return h(
return withEscape(h(
'div',
{
ref: 'panel',
Expand All @@ -169,7 +183,7 @@ const FloatingPopoverComponent = defineComponent({
style,
},
content,
)
))
}
},
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,28 @@ 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. Walking `parentElement`
* (rather than `parentNode`) naturally stops at a shadow root's boundary — a dock's popover
* never escapes the shadow root that its stylesheet is scoped to.
*
* @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
}
40 changes: 33 additions & 7 deletions packages/json-render-ui/src/components/Select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ interface SelectProps {
disabled?: boolean
/** Swap the plain select for a searchable combobox. */
searchable?: boolean
/**
* Renders a real `<select>` instead of `FormSelect`/`FormCombobox`. The browser draws its
* option list outside the page's layout, so no ancestor can clip or reposition it — the
* dependable choice for a `Select` embedded in a host layout this component doesn't
* control. Takes priority over `searchable`, which has no native equivalent.
*/
native?: boolean
}

function normalize(option: string | SelectOption): { value: string, label?: string } {
Expand All @@ -39,6 +46,7 @@ const SelectImpl = defineComponent({
label: { type: String, default: undefined },
disabled: { type: Boolean, default: undefined },
searchable: { type: Boolean, default: undefined },
native: { type: Boolean, default: undefined },
bindingPath: { type: String, default: undefined },
onChange: { type: Function as PropType<() => void>, default: undefined },
},
Expand All @@ -56,7 +64,30 @@ const SelectImpl = defineComponent({
props.onChange?.()
}
const options = computed(() => props.options.map(normalize))
const withLabel = (control: ReturnType<typeof h>) => {
if (!props.label)
return control
return h('div', { class: 'flex flex-col gap-1' }, [
h('label', { class: 'text-sm font-medium' }, props.label),
control,
])
}
return () => {
if (props.native) {
return withLabel(h('select', {
'value': model.value ?? '',
'disabled': props.disabled,
'aria-label': props.label,
'class': 'text-sm px2.5 h-9 min-w-40 border border-base rounded bg-base color-base outline-none transition disabled:op50 disabled:pointer-events-none focus-visible:ring-2 focus-visible:ring-primary-500/40',
'onChange': (e: Event) => setModel((e.target as HTMLSelectElement).value),
}, [
// Only while unset, so the placeholder can't be re-selected afterwards.
props.placeholder && model.value === undefined
? h('option', { value: '', disabled: true }, props.placeholder)
: null,
...options.value.map(option => h('option', { value: option.value }, option.label ?? option.value)),
]))
}
const Comp = (props.searchable ? FormCombobox : FormSelect) as unknown as Parameters<typeof h>[0]
const control = h(Comp, {
'options': options.value,
Expand All @@ -65,13 +96,7 @@ const SelectImpl = defineComponent({
'modelValue': model.value,
'onUpdate:modelValue': (next: string) => setModel(next),
})
if (props.label) {
return h('div', { class: 'flex flex-col gap-1' }, [
h('label', { class: 'text-sm font-medium' }, props.label),
control,
])
}
return control
return withLabel(control)
}
},
})
Expand All @@ -84,6 +109,7 @@ export const Select: JrComponent<SelectProps> = ({ props, on, bindings }) =>
label: props.label,
disabled: props.disabled,
searchable: props.searchable,
native: props.native,
bindingPath: bindings?.value,
onChange: () => on('change').emit(),
})
2 changes: 1 addition & 1 deletion packages/json-render/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const componentDescriptions: Record<keyof typeof basePropSchemas, string> = {
Tree: 'Recursive object/array viewer with expandable nodes.',
Tabs: 'Tabbed container; each child renders under the positionally-matching tab.',
Link: 'Hyperlink to a safe-scheme URL with an optional icon.',
Select: 'Single-select dropdown bound to a state value, with optional search.',
Select: 'Single-select dropdown bound to a state value, with optional search or a native `<select>` fallback.',
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/json-render/src/prop-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const SelectPropsSchema = z.object({
label: str.optional(),
disabled: bool.optional(),
searchable: bool.optional(),
native: bool.optional(),
})

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/json-render/test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ describe('per-component prop validation', () => {
expect(basePropSchemas.Progress.safeParse({ value: 40, max: 100 }).success).toBe(true)
})

it('accepts Select.native alongside the rest of its props', () => {
expect(basePropSchemas.Select.safeParse({ options: ['a', 'b'], native: true }).success).toBe(true)
})

it('rejects an out-of-set enum value', () => {
expect(basePropSchemas.Button.safeParse({ variant: 'nope' }).success).toBe(false)
expect(basePropSchemas.Badge.safeParse({ variant: 'purple' }).success).toBe(false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export declare const basePropSchemas: {
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
disabled: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
searchable: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
native: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
}, z.core.$strip>;
};
export declare const baseSchema: import("@json-render/core").Schema<{
Expand Down Expand Up @@ -331,6 +332,7 @@ export declare const SelectPropsSchema: z.ZodObject<{
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
disabled: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
searchable: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
native: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
}, z.core.$strip>;
export declare const StackPropsSchema: z.ZodObject<{
direction: z.ZodOptional<z.ZodEnum<{
Expand Down
Loading