diff --git a/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts b/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts index e770fce1..8dd7f24e 100644 --- a/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts +++ b/packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts @@ -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` ``s the panel out to that + * ancestor's parent. + */ +export const EscapesTransformedAncestor: Story = { + render: () => defineComponent({ + setup() { + const triggerEl = ref(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() { diff --git a/packages/hub-ui/src/client/components/floating/FloatingPopover.ts b/packages/hub-ui/src/client/components/floating/FloatingPopover.ts index 5c1b63a2..79bc9074 100644 --- a/packages/hub-ui/src/client/components/floating/FloatingPopover.ts +++ b/packages/hub-ui/src/client/components/floating/FloatingPopover.ts @@ -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 @@ -34,6 +34,12 @@ const FloatingPopoverComponent = defineComponent({ const panel = useTemplateRef('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() + + 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 @@ -59,7 +65,10 @@ const FloatingPopoverComponent = defineComponent({ }) } - onMounted(measurePanel) + onMounted(() => { + refreshEscapeTarget(props.item?.el) + measurePanel() + }) onUpdated(measurePanel) useEventListener(window, 'resize', () => { @@ -97,6 +106,7 @@ const FloatingPopoverComponent = defineComponent({ el.value = value.el else renderCounter.value++ + refreshEscapeTarget(value.el) } else { clearThrottled() @@ -107,6 +117,10 @@ const FloatingPopoverComponent = defineComponent({ let previousContent: VNode | undefined let previousStyle: Record = {} + /** 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 @@ -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', @@ -132,7 +146,7 @@ const FloatingPopoverComponent = defineComponent({ style: previousStyle, }, previousContent, - ) + )) } const rect = el.value.getBoundingClientRect() @@ -157,7 +171,7 @@ const FloatingPopoverComponent = defineComponent({ previousContent = content - return h( + return withEscape(h( 'div', { ref: 'panel', @@ -169,7 +183,7 @@ const FloatingPopoverComponent = defineComponent({ style, }, content, - ) + )) } }, }) diff --git a/packages/hub-ui/src/client/components/floating/floating-position.ts b/packages/hub-ui/src/client/components/floating/floating-position.ts index 9fad0329..38dcc7b3 100644 --- a/packages/hub-ui/src/client/components/floating/floating-position.ts +++ b/packages/hub-ui/src/client/components/floating/floating-position.ts @@ -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 ``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 +} diff --git a/packages/json-render-ui/src/components/Select.ts b/packages/json-render-ui/src/components/Select.ts index 15bcb68b..74b3c7e4 100644 --- a/packages/json-render-ui/src/components/Select.ts +++ b/packages/json-render-ui/src/components/Select.ts @@ -21,6 +21,13 @@ interface SelectProps { disabled?: boolean /** Swap the plain select for a searchable combobox. */ searchable?: boolean + /** + * Renders a real `` fallback.', } /** diff --git a/packages/json-render/src/prop-schemas.ts b/packages/json-render/src/prop-schemas.ts index 8fd4a26f..76d27434 100644 --- a/packages/json-render/src/prop-schemas.ts +++ b/packages/json-render/src/prop-schemas.ts @@ -160,6 +160,7 @@ export const SelectPropsSchema = z.object({ label: str.optional(), disabled: bool.optional(), searchable: bool.optional(), + native: bool.optional(), }) /** diff --git a/packages/json-render/test/catalog.test.ts b/packages/json-render/test/catalog.test.ts index b487d5a0..6b828551 100644 --- a/packages/json-render/test/catalog.test.ts +++ b/packages/json-render/test/catalog.test.ts @@ -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) diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts index ec8e12bc..a77e49f2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts @@ -241,6 +241,7 @@ export declare const basePropSchemas: { label: z.ZodOptional, z.ZodRecord>]>>; disabled: z.ZodOptional, z.ZodRecord>]>>; searchable: z.ZodOptional, z.ZodRecord>]>>; + native: z.ZodOptional, z.ZodRecord>]>>; }, z.core.$strip>; }; export declare const baseSchema: import("@json-render/core").Schema<{ @@ -331,6 +332,7 @@ export declare const SelectPropsSchema: z.ZodObject<{ label: z.ZodOptional, z.ZodRecord>]>>; disabled: z.ZodOptional, z.ZodRecord>]>>; searchable: z.ZodOptional, z.ZodRecord>]>>; + native: z.ZodOptional, z.ZodRecord>]>>; }, z.core.$strip>; export declare const StackPropsSchema: z.ZodObject<{ direction: z.ZodOptional