fix(playground): keep model selector lists in sync
- constrain the desktop selector panel so long group and model lists scroll within a shared height. - make the selected model stand out with stronger row and label styling. - scroll both lists to the active selections after the popover opens.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export const modelGroupSelectorLayoutClasses = {
|
||||
desktopPanel: 'max-h-[min(50vh,28rem)] overflow-hidden',
|
||||
desktopContent:
|
||||
'grid h-[min(50vh,28rem)] max-h-[min(50vh,28rem)] min-h-0 gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]',
|
||||
groupColumn: 'flex h-full min-h-0 min-w-0 flex-col overflow-hidden',
|
||||
groupScroll: 'mt-2 grid min-h-0 flex-1 gap-1 overflow-y-auto pr-1',
|
||||
modelColumn: 'flex h-full min-h-0 min-w-0 overflow-hidden rounded-lg border',
|
||||
modelCommand: 'min-h-0 flex-1 rounded-lg border-0 bg-transparent p-1',
|
||||
modelList:
|
||||
'min-h-0 flex-1 max-h-none [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:block [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-track]:bg-transparent',
|
||||
modelItem:
|
||||
'relative mb-0.5 flex items-center justify-between rounded-md border border-transparent px-2 py-1.5 pl-3 text-[12px] leading-4 transition-colors before:absolute before:inset-y-2 before:left-1 before:w-1 before:rounded-full before:bg-transparent',
|
||||
selectedModelItem:
|
||||
'border-primary/40 bg-primary/12 text-foreground shadow-sm before:bg-primary',
|
||||
unselectedModelItem:
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
selectedModelText: 'font-semibold text-foreground',
|
||||
unselectedModelText: 'font-medium',
|
||||
} as const
|
||||
|
||||
type ScrollableOption = {
|
||||
offsetHeight?: number
|
||||
offsetTop?: number
|
||||
scrollIntoView: (options?: ScrollIntoViewOptions) => void
|
||||
}
|
||||
|
||||
type ScrollableOptionContainer = {
|
||||
clientHeight: number
|
||||
scrollTo?: (options: ScrollToOptions) => void
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
export function scrollSelectedOptionIntoView(
|
||||
selectedOption: ScrollableOption | null,
|
||||
scrollContainer?: ScrollableOptionContainer | null
|
||||
): void {
|
||||
if (
|
||||
scrollContainer &&
|
||||
selectedOption?.offsetTop !== undefined &&
|
||||
selectedOption.offsetHeight !== undefined
|
||||
) {
|
||||
const scrollTop = Math.max(
|
||||
0,
|
||||
selectedOption.offsetTop -
|
||||
(scrollContainer.clientHeight - selectedOption.offsetHeight) / 2
|
||||
)
|
||||
if (scrollContainer.scrollTo) {
|
||||
scrollContainer.scrollTo({ top: scrollTop, behavior: 'auto' })
|
||||
} else {
|
||||
scrollContainer.scrollTop = scrollTop
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectedOption?.scrollIntoView({
|
||||
block: 'center',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
+90
-10
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
modelGroupSelectorLayoutClasses,
|
||||
scrollSelectedOptionIntoView,
|
||||
} from './model-group-selector-layout'
|
||||
|
||||
interface ModelOption {
|
||||
label: string
|
||||
value: string
|
||||
@@ -565,6 +570,9 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const isMobile = useIsMobile()
|
||||
const groupScrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const selectedGroupOptionRef = useRef<HTMLButtonElement | null>(null)
|
||||
const selectedModelOptionRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => models.find((model) => model.value === selectedModel),
|
||||
@@ -610,6 +618,28 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
[onGroupChange]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
let secondFrameId = 0
|
||||
const firstFrameId = window.requestAnimationFrame(() => {
|
||||
secondFrameId = window.requestAnimationFrame(() => {
|
||||
scrollSelectedOptionIntoView(
|
||||
selectedGroupOptionRef.current,
|
||||
groupScrollContainerRef.current
|
||||
)
|
||||
scrollSelectedOptionIntoView(selectedModelOptionRef.current)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(firstFrameId)
|
||||
window.cancelAnimationFrame(secondFrameId)
|
||||
}
|
||||
}, [open, selectedGroup, selectedModel])
|
||||
|
||||
const renderTrigger = () => (
|
||||
<Button
|
||||
aria-expanded={open}
|
||||
@@ -636,11 +666,22 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
)
|
||||
|
||||
const renderGroupList = () => (
|
||||
<div className='min-w-0 space-y-2'>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 space-y-2',
|
||||
!isMobile && modelGroupSelectorLayoutClasses.groupColumn
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground px-1 text-[11px] leading-4 font-medium'>
|
||||
{t('Model Group')}
|
||||
</div>
|
||||
<div className='grid gap-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-1',
|
||||
!isMobile && modelGroupSelectorLayoutClasses.groupScroll
|
||||
)}
|
||||
ref={groupScrollContainerRef}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const isSelected = selectedGroup === group.value
|
||||
|
||||
@@ -655,6 +696,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
disabled={disabled}
|
||||
key={group.value}
|
||||
onClick={() => handleGroupChange(group.value)}
|
||||
ref={isSelected ? selectedGroupOptionRef : undefined}
|
||||
type='button'
|
||||
>
|
||||
<span className='min-w-0 truncate font-medium'>
|
||||
@@ -675,7 +717,10 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
|
||||
const renderModelList = () => (
|
||||
<Command
|
||||
className='min-w-0 rounded-lg border-0 bg-transparent p-1'
|
||||
className={cn(
|
||||
'min-w-0 rounded-lg border-0 bg-transparent p-1',
|
||||
!isMobile && modelGroupSelectorLayoutClasses.modelCommand
|
||||
)}
|
||||
filter={() => 1}
|
||||
shouldFilter={false}
|
||||
>
|
||||
@@ -685,7 +730,11 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
placeholder={t('Search models...')}
|
||||
value={searchQuery}
|
||||
/>
|
||||
<CommandList className={isMobile ? 'max-h-[45vh]' : 'max-h-[20rem]'}>
|
||||
<CommandList
|
||||
className={
|
||||
isMobile ? 'max-h-[45vh]' : modelGroupSelectorLayoutClasses.modelList
|
||||
}
|
||||
>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className='text-muted-foreground px-3 py-8 text-center text-[12px] leading-5'>
|
||||
{t('No model found.')}
|
||||
@@ -694,12 +743,29 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
<CommandGroup className='p-1'>
|
||||
{filteredModels.map((model) => (
|
||||
<CommandItem
|
||||
className='mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-[12px] leading-4 transition-colors'
|
||||
className={cn(
|
||||
modelGroupSelectorLayoutClasses.modelItem,
|
||||
selectedModel === model.value
|
||||
? modelGroupSelectorLayoutClasses.selectedModelItem
|
||||
: modelGroupSelectorLayoutClasses.unselectedModelItem
|
||||
)}
|
||||
key={model.value}
|
||||
onSelect={handleModelChange}
|
||||
ref={
|
||||
selectedModel === model.value
|
||||
? selectedModelOptionRef
|
||||
: undefined
|
||||
}
|
||||
value={model.value}
|
||||
>
|
||||
<span className='min-w-0 truncate font-medium'>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate',
|
||||
selectedModel === model.value
|
||||
? modelGroupSelectorLayoutClasses.selectedModelText
|
||||
: modelGroupSelectorLayoutClasses.unselectedModelText
|
||||
)}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
<Check
|
||||
@@ -717,9 +783,20 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
)
|
||||
|
||||
const renderContent = () => (
|
||||
<div className='grid gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]'>
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? 'grid gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]'
|
||||
: modelGroupSelectorLayoutClasses.desktopContent
|
||||
}
|
||||
>
|
||||
{renderGroupList()}
|
||||
<div className='min-w-0 overflow-hidden rounded-lg border'>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden rounded-lg border',
|
||||
!isMobile && modelGroupSelectorLayoutClasses.modelColumn
|
||||
)}
|
||||
>
|
||||
{renderModelList()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -742,7 +819,10 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
|
||||
<PopoverTrigger render={renderTrigger()} />
|
||||
<PopoverContent
|
||||
align='end'
|
||||
className='bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg'
|
||||
className={cn(
|
||||
'bg-popover z-50 w-[34rem] max-w-[calc(100vw-2rem)] rounded-xl border p-0 shadow-lg',
|
||||
modelGroupSelectorLayoutClasses.desktopPanel
|
||||
)}
|
||||
collisionPadding={8}
|
||||
side='top'
|
||||
sideOffset={8}
|
||||
|
||||
Reference in New Issue
Block a user