F: Improve shadcn Select integration
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-04-10 11:07:26 +03:00
parent 95e66fb836
commit c1d84dc490
15 changed files with 151 additions and 94 deletions

View File

@ -3,6 +3,7 @@ import clsx from 'clsx';
import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/icons'; import { IconLibrary2, IconManuals, IconNewItem2 } from '@/components/icons';
import { useWindowSize } from '@/hooks/use-window-size'; import { useWindowSize } from '@/hooks/use-window-size';
import { useAppLayoutStore } from '@/stores/app-layout'; import { useAppLayoutStore } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs';
import { urls } from '../urls'; import { urls } from '../urls';
@ -16,6 +17,7 @@ export function Navigation() {
const { push } = useConceptNavigation(); const { push } = useConceptNavigation();
const size = useWindowSize(); const size = useWindowSize();
const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation); const noNavigationAnimation = useAppLayoutStore(state => state.noNavigationAnimation);
const activeDialog = useDialogsStore(state => state.active);
const navigateHome = (event: React.MouseEvent<Element>) => const navigateHome = (event: React.MouseEvent<Element>) =>
push({ path: urls.home, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.home, newTab: event.ctrlKey || event.metaKey });
@ -27,7 +29,7 @@ export function Navigation() {
push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey }); push({ path: urls.create_schema, newTab: event.ctrlKey || event.metaKey });
return ( return (
<nav className='z-navigation sticky top-0 left-0 right-0 select-none bg-prim-100'> <nav className='z-navigation sticky top-0 left-0 right-0 select-none bg-prim-100' inert={activeDialog !== null}>
<ToggleNavigation /> <ToggleNavigation />
<div <div
className={clsx( className={clsx(

View File

@ -20,8 +20,10 @@ function SelectTrigger({
className, className,
size = 'default', size = 'default',
children, children,
noBorder,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
noBorder?: boolean;
size?: 'sm' | 'default'; size?: 'sm' | 'default';
}) { }) {
return ( return (
@ -29,7 +31,16 @@ function SelectTrigger({
data-slot='select-trigger' data-slot='select-trigger'
data-size={size} data-size={size}
className={cn( className={cn(
"bg-input border cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive disabled:cursor-auto flex w-fit items-center justify-between gap-2 px-3 py-2 whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 'data-[size=default]:h-9 data-[size=sm]:h-8',
'flex items-center justify-between gap-2 px-3 py-2',
'bg-input cursor-pointer disabled:cursor-auto disabled:opacity-50',
'data-[placeholder]:text-muted-foreground',
'whitespace-nowrap',
'outline-none focus-visible:ring-[2px] focus-visible:border-ring focus-visible:ring-ring aria-invalid:ring-destructive',
'*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2',
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
!noBorder && 'border aria-invalid:border-destructive',
noBorder && 'rounded-md',
className className
)} )}
{...props} {...props}
@ -53,7 +64,11 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot='select-content' data-slot='select-content'
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto border shadow-md', 'z-topmost relative max-h-(--radix-select-content-available-height) min-w-32',
'bg-popover text-sm text-popover-foreground',
'border shadow-md',
'overflow-x-hidden overflow-y-auto',
'cc-select-popover',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className className
@ -92,7 +107,12 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot='select-item' data-slot='select-item'
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", 'relative',
'flex py-1 pr-8 pl-2 items-center gap-2',
'cursor-default rounded-sm select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'outline-none focus:bg-accent focus:text-accent-foreground',
'*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...props} {...props}

View File

@ -1,14 +1,12 @@
'use client'; 'use client';
import clsx from 'clsx';
import { SelectSingle } from '@/components/input';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { type ILibraryItem } from '../backend/types'; import { type ILibraryItem } from '../backend/types';
import { matchLibraryItem } from '../models/library-api';
interface SelectLibraryItemProps extends Styling { interface SelectLibraryItemProps extends Styling {
id?: string;
value: ILibraryItem | null; value: ILibraryItem | null;
onChange: (newValue: ILibraryItem | null) => void; onChange: (newValue: ILibraryItem | null) => void;
@ -18,33 +16,30 @@ interface SelectLibraryItemProps extends Styling {
} }
export function SelectLibraryItem({ export function SelectLibraryItem({
className, id,
items, items,
value, value,
onChange, onChange,
placeholder = 'Выберите схему', placeholder = 'Выберите схему',
...restProps ...restProps
}: SelectLibraryItemProps) { }: SelectLibraryItemProps) {
const options = function handleSelect(newValue: string) {
items?.map(cst => ({ const newItem = items?.find(item => item.id === Number(newValue)) ?? null;
value: cst.id, onChange(newItem);
label: `${cst.alias}: ${cst.title}`
})) ?? [];
function filter(option: { value: string | undefined; label: string }, query: string) {
const item = items?.find(item => item.id === Number(option.value));
return !item ? false : matchLibraryItem(item, query);
} }
return ( return (
<SelectSingle <Select onValueChange={handleSelect} defaultValue={value ? String(value.id) : undefined}>
className={clsx('text-ellipsis', className)} <SelectTrigger id={id} {...restProps}>
options={options} <SelectValue placeholder={placeholder} />
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null} </SelectTrigger>
onChange={data => onChange(items?.find(cst => cst.id === data?.value) ?? null)} <SelectContent className='max-w-80'>
filterOption={filter} {items?.map(item => (
placeholder={placeholder} <SelectItem key={`${id ?? 'default'}-item-select-${item.id}`} value={String(item.id)}>
{...restProps} {`${item.alias}: ${item.title}`}
/> </SelectItem>
))}
</SelectContent>
</Select>
); );
} }

View File

@ -18,7 +18,15 @@ interface SelectVersionProps extends Styling {
noBorder?: boolean; noBorder?: boolean;
} }
export function SelectVersion({ id, className, items, value, placeholder, onChange }: SelectVersionProps) { export function SelectVersion({
id,
className,
items,
value,
placeholder,
onChange,
...restProps
}: SelectVersionProps) {
function handleSelect(newValue: string) { function handleSelect(newValue: string) {
if (newValue === 'latest') { if (newValue === 'latest') {
onChange(newValue); onChange(newValue);
@ -26,10 +34,9 @@ export function SelectVersion({ id, className, items, value, placeholder, onChan
onChange(Number(newValue)); onChange(Number(newValue));
} }
} }
return ( return (
<Select onValueChange={handleSelect} defaultValue={String(value)}> <Select onValueChange={handleSelect} defaultValue={String(value)}>
<SelectTrigger id={id} className={cn('min-w-48', className)}> <SelectTrigger id={id} className={cn('min-w-48', className)} {...restProps}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@ -231,8 +231,9 @@ export function PickSubstitutions({
return ( return (
<div className={clsx('flex flex-col', className)} {...restProps}> <div className={clsx('flex flex-col', className)} {...restProps}>
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
<div className='grow flex flex-col basis-1/2 gap-1 border-x border-t clr-input rounded-t-md'> <div className='w-60 grow flex flex-col basis-1/2 gap-1 border-x border-t clr-input rounded-t-md'>
<SelectLibraryItem <SelectLibraryItem
id='substitute-left-schema'
noBorder noBorder
placeholder='Выберите аргумент' placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)} items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== rightArgument?.id)}
@ -262,8 +263,9 @@ export function PickSubstitutions({
/> />
</div> </div>
<div className='grow basis-1/2 flex flex-col gap-1 border-x border-t clr-input rounded-t-md'> <div className='w-60 grow basis-1/2 flex flex-col gap-1 border-x border-t clr-input rounded-t-md'>
<SelectLibraryItem <SelectLibraryItem
id='substitute-right-schema'
noBorder noBorder
placeholder='Выберите аргумент' placeholder='Выберите аргумент'
items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)} items={allowSelfSubstitution ? schemas : schemas.filter(item => item.id !== leftArgument?.id)}

View File

@ -1,13 +1,11 @@
import { SelectSingle } from '@/components/input';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { CstType } from '../backend/types'; import { CstType } from '../backend/types';
import { labelCstType } from '../labels'; import { labelCstType } from '../labels';
const SelectorCstType = Object.values(CstType).map(typeStr => ({ import { IconCstType } from './icon-cst-type';
value: typeStr as CstType,
label: labelCstType(typeStr as CstType)
}));
interface SelectCstTypeProps extends Styling { interface SelectCstTypeProps extends Styling {
id?: string; id?: string;
@ -16,16 +14,20 @@ interface SelectCstTypeProps extends Styling {
onChange: (newValue: CstType) => void; onChange: (newValue: CstType) => void;
} }
export function SelectCstType({ value, onChange, disabled = false, ...restProps }: SelectCstTypeProps) { export function SelectCstType({ id, value, onChange, className, disabled = false, ...restProps }: SelectCstTypeProps) {
return ( return (
<SelectSingle <Select onValueChange={onChange} defaultValue={value} disabled={disabled}>
id='dlg_cst_type' <SelectTrigger id={id} className={cn('w-66', className)} {...restProps}>
placeholder='Выберите тип' <SelectValue />
options={SelectorCstType} </SelectTrigger>
value={{ value: value, label: labelCstType(value) }} <SelectContent>
onChange={data => onChange(data?.value ?? CstType.BASE)} {Object.values(CstType).map(typeStr => (
isDisabled={disabled} <SelectItem key={`csttype-${typeStr}`} value={typeStr}>
{...restProps} <IconCstType value={typeStr as CstType} />
/> <span>{labelCstType(typeStr as CstType)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
); );
} }

View File

@ -45,7 +45,6 @@ export function FormCreateCst({ schema }: FormCreateCstProps) {
<div className='flex items-center self-center gap-3'> <div className='flex items-center self-center gap-3'>
<SelectCstType <SelectCstType
id='dlg_cst_type' // id='dlg_cst_type' //
className='w-64'
value={cst_type} value={cst_type}
onChange={handleTypeChange} onChange={handleTypeChange}
/> />

View File

@ -56,8 +56,7 @@ export function DlgRenameCst() {
helpTopic={HelpTopic.CC_CONSTITUENTA} helpTopic={HelpTopic.CC_CONSTITUENTA}
> >
<SelectCstType <SelectCstType
id='dlg_cst_type' id='dlg_cst_type' //
className='w-64'
value={cst_type} value={cst_type}
onChange={handleChangeType} onChange={handleChangeType}
disabled={target.is_inherited} disabled={target.is_inherited}

View File

@ -1,38 +0,0 @@
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { SelectSingle } from '@/components/input';
import { mapLabelColoring } from '../../../labels';
import { type GraphColoring, useTermGraphStore } from '../../../stores/term-graph';
import { SchemasGuide } from './schemas-guide';
/**
* Represents options for {@link GraphColoring} selector.
*/
const SelectorGraphColoring: { value: GraphColoring; label: string }[] = //
[...mapLabelColoring.entries()].map(item => ({ value: item[0], label: item[1] }));
export function GraphSelectors() {
const coloring = useTermGraphStore(state => state.coloring);
const setColoring = useTermGraphStore(state => state.setColoring);
return (
<div className='relative border rounded-b-none select-none clr-input rounded-t-md pointer-events-auto'>
<div className='absolute z-pop right-10 h-10 flex items-center'>
{coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} contentClass='min-w-100' /> : null}
{coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} contentClass='min-w-100' /> : null}
{coloring === 'schemas' ? <SchemasGuide /> : null}
</div>
<SelectSingle
noBorder
placeholder='Цветовая схема'
options={SelectorGraphColoring}
isSearchable={false}
value={coloring ? { value: coloring, label: mapLabelColoring.get(coloring) } : null}
onChange={data => setColoring(data?.value ?? SelectorGraphColoring[0].value)}
/>
</div>
);
}

View File

@ -36,7 +36,7 @@ export function SchemasGuide() {
})(); })();
return ( return (
<div tabIndex={-1} id={globalIDs.graph_schemas}> <div className='p-1' tabIndex={-1} id={globalIDs.graph_schemas}>
<IconHelp size='1.25rem' className='icon-primary' /> <IconHelp size='1.25rem' className='icon-primary' />
<Tooltip anchorSelect={`#${globalIDs.graph_schemas}`} place='right' className='max-w-100 break-words text-base'> <Tooltip anchorSelect={`#${globalIDs.graph_schemas}`} place='right' className='max-w-100 break-words text-base'>
<div className='inline-flex items-center gap-2'> <div className='inline-flex items-center gap-2'>

View File

@ -0,0 +1,37 @@
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { mapLabelColoring } from '../../../labels';
import { useTermGraphStore } from '../../../stores/term-graph';
import { SchemasGuide } from './schemas-guide';
export function SelectColoring() {
const coloring = useTermGraphStore(state => state.coloring);
const setColoring = useTermGraphStore(state => state.setColoring);
return (
<div className='relative border rounded-b-none select-none clr-input rounded-t-md pointer-events-auto'>
<div className='absolute z-pop right-10 h-9 flex items-center'>
{coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} contentClass='min-w-100' /> : null}
{coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} contentClass='min-w-100' /> : null}
{coloring === 'schemas' ? <SchemasGuide /> : null}
</div>
<Select onValueChange={setColoring} defaultValue={coloring}>
<SelectTrigger noBorder className='w-full'>
<SelectValue placeholder='Цветовая схема' />
</SelectTrigger>
<SelectContent alignOffset={-1} sideOffset={-4}>
{[...mapLabelColoring.entries()].map(item => (
<SelectItem key={`coloring-${item[0]}`} value={item[0]}>
{item[1]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@ -26,7 +26,7 @@ import { useRSEdit } from '../rsedit-context';
import { TGEdgeTypes } from './graph/tg-edge-types'; import { TGEdgeTypes } from './graph/tg-edge-types';
import { applyLayout } from './graph/tg-layout'; import { applyLayout } from './graph/tg-layout';
import { TGNodeTypes } from './graph/tg-node-types'; import { TGNodeTypes } from './graph/tg-node-types';
import { GraphSelectors } from './graph-selectors'; import { SelectColoring } from './select-coloring';
import { ToolbarFocusedCst } from './toolbar-focused-cst'; import { ToolbarFocusedCst } from './toolbar-focused-cst';
import { ToolbarTermGraph } from './toolbar-term-graph'; import { ToolbarTermGraph } from './toolbar-term-graph';
import { useFilteredGraph } from './use-filtered-graph'; import { useFilteredGraph } from './use-filtered-graph';
@ -184,7 +184,7 @@ export function TGFlow() {
<span className='px-2 pb-1 select-none whitespace-nowrap backdrop-blur-xs rounded-xl'> <span className='px-2 pb-1 select-none whitespace-nowrap backdrop-blur-xs rounded-xl'>
Выбор {selected.length} из {schema.stats?.count_all ?? 0} Выбор {selected.length} из {schema.stats?.count_all ?? 0}
</span> </span>
<GraphSelectors /> <SelectColoring />
<ViewHidden items={hidden} /> <ViewHidden items={hidden} />
</div> </div>

View File

@ -47,11 +47,11 @@ export function ViewHidden({ items }: ViewHiddenProps) {
return ( return (
<div className='grid relative'> <div className='grid relative'>
<MiniButton <MiniButton
className='absolute right-[calc(0.75rem-2px)] top-2 pointer-events-auto' className='absolute right-[calc(1rem-4px)] top-3 pointer-events-auto'
noPadding noPadding
noHover noHover
title={!isFolded ? 'Свернуть' : 'Развернуть'} title={!isFolded ? 'Свернуть' : 'Развернуть'}
icon={!isFolded ? <IconDropArrowUp size='1.25rem' /> : <IconDropArrow size='1.25rem' />} icon={!isFolded ? <IconDropArrowUp size='1rem' /> : <IconDropArrow size='1rem' />}
onClick={toggleFolded} onClick={toggleFolded}
/> />

View File

@ -129,7 +129,7 @@
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--clr-prim-100); --color-muted: var(--clr-prim-100);
--color-muted-foreground: var(--clr-prim-600); --color-muted-foreground: var(--clr-prim-800);
--color-accent: var(--clr-sec-100); --color-accent: var(--clr-sec-100);
--color-accent-foreground: var(--clr-prim-999); --color-accent-foreground: var(--clr-prim-999);
--color-destructive: var(--clr-warn-600); --color-destructive: var(--clr-warn-600);

View File

@ -227,3 +227,35 @@
clip-path: inset(0% 0% 0% 0%); clip-path: inset(0% 0% 0% 0%);
} }
} }
@utility cc-select-popover {
transform-origin: var(--radix-select-content-transform-origin);
&[data-state='open'] {
--tw-enter-opacity: 0;
--tw-enter-scale: 0.95;
animation: enter var(--tw-duration, 0.15s) var(--tw-ease, ease);
}
&[data-state='closed'] {
--tw-exit-opacity: 0;
--tw-exit-scale: 0.95;
animation: exit var(--tw-duration, 0.15s) var(--tw-ease, ease);
}
&[data-side='bottom'] {
--tw-enter-translate-y: calc(-2 * var(--spacing));
}
&[data-side='left'] {
--tw-enter-translate-x: calc(2 * var(--spacing));
}
&[data-side='right'] {
--tw-enter-translate-x: calc(-2 * var(--spacing));
}
&[data-side='top'] {
--tw-enter-translate-y: calc(2 * var(--spacing));
}
}