fix(kb): stop the collection dropdown being clipped, widen the modal (#1198)

Two layout defects in the Knowledge Base modal, both found during
v1.34.0-rc.4 QA.

The collection combobox list was clipped to a single row's height on
every row of the table. The cause is not stacking order - StyledTable
gives each cell `truncate` (overflow:hidden) plus `relative`, so an
absolutely-positioned child cannot escape the cell box. No z-index can
win against a clip. The modal body is also an `overflow-y-auto`
scroller, so simply opting the cell out of `truncate` still left rows
lower down clipped by the scroller instead.

Render the list in a portal with fixed positioning instead, measured
from the input's rect and flipped above when it would run off the
bottom of the viewport. Reposition on scroll and resize (capture
listener, so any ancestor scroller counts). The click-outside handler
now also checks the portaled list, otherwise mousedown on an option
would close it before the click landed.

Separately, the modal was capped at max-w-4xl (896px) while its table
needs 906px, so the Delete button lost its right edge for everyone
regardless of monitor size. max-w-5xl gives the table room with none to
spare wasted.

Verified against a real build on a test appliance.
This commit is contained in:
chriscrosstalk 2026-08-03 19:52:10 -07:00 committed by jakeaturner
parent af3fb8de13
commit 7325457242
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
2 changed files with 57 additions and 6 deletions

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
interface CollectionComboboxProps {
/** Current value. Empty string means "Uncategorized". */
@ -32,6 +33,8 @@ export default function CollectionCombobox({
const [query, setQuery] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
useEffect(() => {
setQuery(value)
@ -39,7 +42,12 @@ export default function CollectionCombobox({
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as Node
// The list is portaled to <body>, so it is NOT inside containerRef — check both,
// otherwise mousedown on an option closes the list before its click can land.
const insideInput = containerRef.current?.contains(target)
const insideList = dropdownRef.current?.contains(target)
if (!insideInput && !insideList) {
setIsOpen(false)
setQuery(value)
}
@ -48,6 +56,44 @@ export default function CollectionCombobox({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [value])
/**
* The list renders in a portal with fixed positioning rather than absolutely inside
* the input's own box. Every ancestor that sets `overflow` other than visible would
* otherwise clip it: in the Knowledge Base table each cell carries `truncate`
* (overflow:hidden), which cropped the list to a single row's height, and the modal
* body is an `overflow-y-auto` scroller the list could not escape either. Fixed
* positioning against the viewport sidesteps both.
*/
const reposition = useCallback(() => {
const input = containerRef.current
if (!input) return
const rect = input.getBoundingClientRect()
const MAX_HEIGHT = 224 // matches max-h-56
const GAP = 4
const spaceBelow = window.innerHeight - rect.bottom
// Flip above the input when the list would run off the bottom of the viewport
// and there is more headroom up top.
const flipUp = spaceBelow < MAX_HEIGHT + GAP && rect.top > spaceBelow
setPosition({
left: rect.left,
top: flipUp ? Math.max(GAP, rect.top - GAP - MAX_HEIGHT) : rect.bottom + GAP,
width: rect.width,
})
}, [])
useLayoutEffect(() => {
if (!isOpen) return
reposition()
// `true` for capture so scrolling in any ancestor scroller keeps the list attached
// to its input rather than leaving it stranded mid-air.
window.addEventListener('scroll', reposition, true)
window.addEventListener('resize', reposition)
return () => {
window.removeEventListener('scroll', reposition, true)
window.removeEventListener('resize', reposition)
}
}, [isOpen, reposition])
const normalizedQuery = query.trim().toLowerCase()
const filtered = normalizedQuery
? options.filter((o) => o.toLowerCase().includes(normalizedQuery))
@ -89,8 +135,12 @@ export default function CollectionCombobox({
placeholder={placeholder}
className="w-full rounded border border-border-subtle bg-surface-primary px-2 py-1 text-sm text-text-primary disabled:opacity-50"
/>
{isOpen && !disabled && (
<div className="absolute z-20 mt-1 w-full max-h-56 overflow-auto rounded border border-border-subtle bg-surface-primary shadow-lg">
{isOpen && !disabled && position && createPortal(
<div
ref={dropdownRef}
style={{ left: position.left, top: position.top, width: position.width }}
className="fixed z-[60] max-h-56 overflow-auto rounded border border-border-subtle bg-surface-primary shadow-lg"
>
{allowUncategorized && (
<button
type="button"
@ -122,7 +172,8 @@ export default function CollectionCombobox({
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-1.5 text-sm text-text-muted">No matches</div>
)}
</div>
</div>,
document.body
)}
</div>
)

View File

@ -453,7 +453,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-surface-primary rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
<button