From 43b8386ad8013eade798af45ea52e29b8e0ef67a Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 14 Aug 2026 11:48:57 +0000 Subject: [PATCH] fix(hub-ui,json-render-ui): FloatingPopover escapes a transformed ancestor's containing block; native Select mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 ``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 `` 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 } { @@ -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 }, }, @@ -56,7 +64,30 @@ const SelectImpl = defineComponent({ props.onChange?.() } const options = computed(() => props.options.map(normalize)) + const withLabel = (control: ReturnType) => { + 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[0] const control = h(Comp, { 'options': options.value, @@ -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) } }, }) @@ -84,6 +109,7 @@ export const Select: JrComponent = ({ props, on, bindings }) => label: props.label, disabled: props.disabled, searchable: props.searchable, + native: props.native, bindingPath: bindings?.value, onChange: () => on('change').emit(), }) diff --git a/packages/json-render/src/catalog.ts b/packages/json-render/src/catalog.ts index d8f280c9..4143c824 100644 --- a/packages/json-render/src/catalog.ts +++ b/packages/json-render/src/catalog.ts @@ -45,7 +45,7 @@ const componentDescriptions: Record = { 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 `