Skip to content

Commit 43b8386

Browse files
antfubotdvcolomban
andcommitted
fix(hub-ui,json-render-ui): FloatingPopover escapes a transformed ancestor's containing block; native Select mode
`FloatingPopover` (hub-ui) is positioned `fixed` against its anchor's viewport rect, but a `transform`/`filter`/`contain` ancestor makes itself the containing block for that `position: fixed`, so the panel ends up positioned relative to — and clipped by — that ancestor instead of the viewport. `resolveFixedEscapeTarget` walks up from the anchor to the outermost such ancestor (escaping only the nearest one can land inside another) and `<Teleport>`s the panel to its parent; walking `parentElement` stops at a shadow root's boundary, so a dock's popover never escapes the shadow root its stylesheet is scoped to. With no such ancestor, the panel renders in place as before. `Select` (json-render-ui) gains `native`, rendering 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 at all — a dependable escape hatch for a `Select` embedded in a host layout this component doesn't control, at the cost of `icon`, `description` and `searchable`, which have no native equivalent. Ports vitejs/devtools#518, adapted to this fork: that PR's `surface` prop and `Select`'s switch to it don't have an equivalent here — this fork's `Select` renders through @antfu/design's `FormSelect`/`FormCombobox` (reka-ui popovers), not through `FloatingPopover`, so there's no consumer to point at a menu surface. (Those reka-ui popovers default to portalling into `document.body`, which suffers the same class of bug inside a shadow root or a transformed ancestor — worth a follow-up, but out of scope for a first-party fix here.) Closes #205 Co-authored-by: dvcolomban <dinh-van.colomban@contentsquare.com>
1 parent 4e08b14 commit 43b8386

8 files changed

Lines changed: 129 additions & 15 deletions

File tree

packages/hub-ui/src/client/components/floating/FloatingPopover.stories.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,48 @@ export const ToggleTrigger: Story = {
9393
}),
9494
}
9595

96+
/**
97+
* A `transform` on an ancestor turns it into a containing block for `position: fixed`
98+
* descendants — without the escape fix, the panel would be positioned relative to (and
99+
* clipped by) the transformed box below rather than the viewport. It still lands on the
100+
* trigger correctly here because `FloatingPopover` `<Teleport>`s the panel out to that
101+
* ancestor's parent.
102+
*/
103+
export const EscapesTransformedAncestor: Story = {
104+
render: () => defineComponent({
105+
setup() {
106+
const triggerEl = ref<HTMLElement | null>(null)
107+
const open = ref(false)
108+
const item = computed(() => (open.value && triggerEl.value)
109+
? { el: triggerEl.value, content: () => h('div', { class: 'flex flex-col gap-0.5 min-w-40' }, [
110+
h('div', { class: 'px2 pt1 pb1.5 op60 text-2.75 uppercase tracking-wide font-medium' }, 'Menu'),
111+
...['Overview', 'Pages', 'Components'].map(label =>
112+
h('button', { class: 'px2 py1.5 rounded text-sm text-left op80 hover:op100 hover:bg-active transition' }, label)),
113+
]) }
114+
: null)
115+
return () => h('div', { class: 'flex items-center justify-center p20 min-h-80 font-sans' }, [
116+
h('div', {
117+
class: 'p8 border-2 border-dashed border-red rounded of-hidden',
118+
style: { transform: 'translateZ(0)' },
119+
}, [
120+
h('div', { class: 'text-xs op60 mb2' }, 'Transformed + clipping ancestor'),
121+
h('button', {
122+
ref: (el: any) => (triggerEl.value = el),
123+
class: 'px3 py1.5 rounded border border-base bg-glass color-base shadow',
124+
onClick: () => (open.value = !open.value),
125+
}, 'Toggle menu'),
126+
]),
127+
h(FloatingPopover, {
128+
item: item.value,
129+
panelClass: '!p0',
130+
ignore: [triggerEl],
131+
onDismiss: () => (open.value = false),
132+
}),
133+
])
134+
},
135+
}),
136+
}
137+
96138
export const CornerAnchors: Story = {
97139
render: () => defineComponent({
98140
setup() {

packages/hub-ui/src/client/components/floating/FloatingPopover.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import type { MaybeElementRef } from '@vueuse/core'
22
import type { PropType, VNode } from 'vue'
33
import type { FloatingPopoverProps } from '../../state/floating-tooltip'
44
import { onClickOutside, useDebounceFn, useEventListener } from '@vueuse/core'
5-
import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, useTemplateRef, watch } from 'vue'
6-
import { resolveFloatingPosition } from './floating-position'
5+
import { defineComponent, h, nextTick, onMounted, onUpdated, reactive, ref, Teleport, useTemplateRef, watch } from 'vue'
6+
import { resolveFixedEscapeTarget, resolveFloatingPosition } from './floating-position'
77

88
// @unocss-include
99

@@ -34,6 +34,12 @@ const FloatingPopoverComponent = defineComponent({
3434
const panel = useTemplateRef<HTMLDivElement>('panel')
3535
const el = ref(props.item?.el)
3636
const renderCounter = ref(0)
37+
/** Resolved from the anchor rather than the panel, which may not be in the document yet. */
38+
const escapeTarget = ref<HTMLElement | undefined>()
39+
40+
function refreshEscapeTarget(anchor: Element | undefined) {
41+
escapeTarget.value = anchor ? resolveFixedEscapeTarget(anchor) : undefined
42+
}
3743

3844
const panelSize = reactive({ width: 0, height: 0 })
3945
// Before the first measurement, `resolveFloatingPosition` centers the panel
@@ -59,7 +65,10 @@ const FloatingPopoverComponent = defineComponent({
5965
})
6066
}
6167

62-
onMounted(measurePanel)
68+
onMounted(() => {
69+
refreshEscapeTarget(props.item?.el)
70+
measurePanel()
71+
})
6372
onUpdated(measurePanel)
6473

6574
useEventListener(window, 'resize', () => {
@@ -97,6 +106,7 @@ const FloatingPopoverComponent = defineComponent({
97106
el.value = value.el
98107
else
99108
renderCounter.value++
109+
refreshEscapeTarget(value.el)
100110
}
101111
else {
102112
clearThrottled()
@@ -107,6 +117,10 @@ const FloatingPopoverComponent = defineComponent({
107117
let previousContent: VNode | undefined
108118
let previousStyle: Record<string, string> = {}
109119

120+
/** Escapes the anchor's containing block when there is one, otherwise renders in place. */
121+
const withEscape = (node: VNode) =>
122+
escapeTarget.value ? h(Teleport, { to: escapeTarget.value }, [node]) : node
123+
110124
return () => {
111125
// Force re-render to update the position
112126
// eslint-disable-next-line ts/no-unused-expressions
@@ -120,7 +134,7 @@ const FloatingPopoverComponent = defineComponent({
120134
// When dismissing (item is null), keep the last known position
121135
// so the popover fades out in place instead of jumping
122136
if (!props.item) {
123-
return h(
137+
return withEscape(h(
124138
'div',
125139
{
126140
ref: 'panel',
@@ -132,7 +146,7 @@ const FloatingPopoverComponent = defineComponent({
132146
style: previousStyle,
133147
},
134148
previousContent,
135-
)
149+
))
136150
}
137151

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

158172
previousContent = content
159173

160-
return h(
174+
return withEscape(h(
161175
'div',
162176
{
163177
ref: 'panel',
@@ -169,7 +183,7 @@ const FloatingPopoverComponent = defineComponent({
169183
style,
170184
},
171185
content,
172-
)
186+
))
173187
}
174188
},
175189
})

packages/hub-ui/src/client/components/floating/floating-position.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,28 @@ export function resolveFloatingPosition(options: ResolveFloatingPositionOptions)
100100

101101
return { align, style }
102102
}
103+
104+
/** Properties whose computed value, when not `none`, makes an element a containing block for `position: fixed` descendants. */
105+
const FIXED_CONTAINING_BLOCK_PROPERTIES = ['transform', 'translate', 'rotate', 'scale', 'perspective', 'filter', 'backdropFilter'] as const
106+
107+
/**
108+
* The element a fixed-position panel anchored to `anchor` must be `<Teleport>`ed into to
109+
* avoid being positioned relative to — and clipped by — a transformed ancestor, or
110+
* `undefined` when there is no such ancestor and the panel can stay in place.
111+
*
112+
* Returns the *outermost* offending ancestor's parent: escaping only the nearest one can
113+
* land inside another, leaving the panel just as mispositioned. Walking `parentElement`
114+
* (rather than `parentNode`) naturally stops at a shadow root's boundary — a dock's popover
115+
* never escapes the shadow root that its stylesheet is scoped to.
116+
*
117+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed
118+
*/
119+
export function resolveFixedEscapeTarget(anchor: Element): HTMLElement | undefined {
120+
let outermost: HTMLElement | undefined
121+
for (let node = anchor.parentElement; node; node = node.parentElement) {
122+
const style = getComputedStyle(node)
123+
if (FIXED_CONTAINING_BLOCK_PROPERTIES.some(property => style[property] !== 'none') || /paint|layout|strict|content/.test(style.contain))
124+
outermost = node
125+
}
126+
return outermost?.parentElement ?? undefined
127+
}

packages/json-render-ui/src/components/Select.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ interface SelectProps {
2121
disabled?: boolean
2222
/** Swap the plain select for a searchable combobox. */
2323
searchable?: boolean
24+
/**
25+
* Renders a real `<select>` instead of `FormSelect`/`FormCombobox`. The browser draws its
26+
* option list outside the page's layout, so no ancestor can clip or reposition it — the
27+
* dependable choice for a `Select` embedded in a host layout this component doesn't
28+
* control. Takes priority over `searchable`, which has no native equivalent.
29+
*/
30+
native?: boolean
2431
}
2532

2633
function normalize(option: string | SelectOption): { value: string, label?: string } {
@@ -39,6 +46,7 @@ const SelectImpl = defineComponent({
3946
label: { type: String, default: undefined },
4047
disabled: { type: Boolean, default: undefined },
4148
searchable: { type: Boolean, default: undefined },
49+
native: { type: Boolean, default: undefined },
4250
bindingPath: { type: String, default: undefined },
4351
onChange: { type: Function as PropType<() => void>, default: undefined },
4452
},
@@ -56,7 +64,30 @@ const SelectImpl = defineComponent({
5664
props.onChange?.()
5765
}
5866
const options = computed(() => props.options.map(normalize))
67+
const withLabel = (control: ReturnType<typeof h>) => {
68+
if (!props.label)
69+
return control
70+
return h('div', { class: 'flex flex-col gap-1' }, [
71+
h('label', { class: 'text-sm font-medium' }, props.label),
72+
control,
73+
])
74+
}
5975
return () => {
76+
if (props.native) {
77+
return withLabel(h('select', {
78+
'value': model.value ?? '',
79+
'disabled': props.disabled,
80+
'aria-label': props.label,
81+
'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',
82+
'onChange': (e: Event) => setModel((e.target as HTMLSelectElement).value),
83+
}, [
84+
// Only while unset, so the placeholder can't be re-selected afterwards.
85+
props.placeholder && model.value === undefined
86+
? h('option', { value: '', disabled: true }, props.placeholder)
87+
: null,
88+
...options.value.map(option => h('option', { value: option.value }, option.label ?? option.value)),
89+
]))
90+
}
6091
const Comp = (props.searchable ? FormCombobox : FormSelect) as unknown as Parameters<typeof h>[0]
6192
const control = h(Comp, {
6293
'options': options.value,
@@ -65,13 +96,7 @@ const SelectImpl = defineComponent({
6596
'modelValue': model.value,
6697
'onUpdate:modelValue': (next: string) => setModel(next),
6798
})
68-
if (props.label) {
69-
return h('div', { class: 'flex flex-col gap-1' }, [
70-
h('label', { class: 'text-sm font-medium' }, props.label),
71-
control,
72-
])
73-
}
74-
return control
99+
return withLabel(control)
75100
}
76101
},
77102
})
@@ -84,6 +109,7 @@ export const Select: JrComponent<SelectProps> = ({ props, on, bindings }) =>
84109
label: props.label,
85110
disabled: props.disabled,
86111
searchable: props.searchable,
112+
native: props.native,
87113
bindingPath: bindings?.value,
88114
onChange: () => on('change').emit(),
89115
})

packages/json-render/src/catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const componentDescriptions: Record<keyof typeof basePropSchemas, string> = {
4545
Tree: 'Recursive object/array viewer with expandable nodes.',
4646
Tabs: 'Tabbed container; each child renders under the positionally-matching tab.',
4747
Link: 'Hyperlink to a safe-scheme URL with an optional icon.',
48-
Select: 'Single-select dropdown bound to a state value, with optional search.',
48+
Select: 'Single-select dropdown bound to a state value, with optional search or a native `<select>` fallback.',
4949
}
5050

5151
/**

packages/json-render/src/prop-schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export const SelectPropsSchema = z.object({
160160
label: str.optional(),
161161
disabled: bool.optional(),
162162
searchable: bool.optional(),
163+
native: bool.optional(),
163164
})
164165

165166
/**

packages/json-render/test/catalog.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ describe('per-component prop validation', () => {
4040
expect(basePropSchemas.Progress.safeParse({ value: 40, max: 100 }).success).toBe(true)
4141
})
4242

43+
it('accepts Select.native alongside the rest of its props', () => {
44+
expect(basePropSchemas.Select.safeParse({ options: ['a', 'b'], native: true }).success).toBe(true)
45+
})
46+
4347
it('rejects an out-of-set enum value', () => {
4448
expect(basePropSchemas.Button.safeParse({ variant: 'nope' }).success).toBe(false)
4549
expect(basePropSchemas.Badge.safeParse({ variant: 'purple' }).success).toBe(false)

tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export declare const basePropSchemas: {
241241
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
242242
disabled: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
243243
searchable: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
244+
native: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
244245
}, z.core.$strip>;
245246
};
246247
export declare const baseSchema: import("@json-render/core").Schema<{
@@ -331,6 +332,7 @@ export declare const SelectPropsSchema: z.ZodObject<{
331332
label: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
332333
disabled: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
333334
searchable: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
335+
native: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodIntersection<z.ZodObject<{}, z.core.$loose>, z.ZodRecord<z.ZodString, z.ZodUnknown>>]>>;
334336
}, z.core.$strip>;
335337
export declare const StackPropsSchema: z.ZodObject<{
336338
direction: z.ZodOptional<z.ZodEnum<{

0 commit comments

Comments
 (0)