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:
parent
af3fb8de13
commit
7325457242
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue