F: Replace SelectSingle with combobox
This commit is contained in:
parent
bcd54d22b6
commit
b91ec793e9
|
@ -1,93 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Check, ChevronDownIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../ui/command';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
||||||
|
|
||||||
interface ComboBoxProps<Option> {
|
|
||||||
items?: Option[];
|
|
||||||
value: Option | null;
|
|
||||||
onChange: (newValue: Option | null) => void;
|
|
||||||
|
|
||||||
filterFunc: (item: Option, query: string) => boolean;
|
|
||||||
|
|
||||||
idFunc: (item: Option) => string;
|
|
||||||
labelValueFunc: (item: Option) => string;
|
|
||||||
labelOptionFunc: (item: Option) => string;
|
|
||||||
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
noBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a combo-select component.
|
|
||||||
*/
|
|
||||||
export function ComboBox<Option>({
|
|
||||||
items,
|
|
||||||
filterFunc,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
labelValueFunc,
|
|
||||||
labelOptionFunc,
|
|
||||||
idFunc,
|
|
||||||
noBorder,
|
|
||||||
placeholder,
|
|
||||||
className
|
|
||||||
}: ComboBoxProps<Option>) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
|
||||||
const filtered = items?.filter(item => filterFunc(item, query)) ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
role='combobox'
|
|
||||||
aria-expanded={open}
|
|
||||||
className={cn(
|
|
||||||
'justify-between font-normal hover:bg-input',
|
|
||||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
|
||||||
open && 'cursor-auto',
|
|
||||||
!noBorder && 'border',
|
|
||||||
!value && 'text-muted-foreground hover:text-muted-foreground',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className='truncate'>{value ? labelValueFunc(value) : placeholder}</span>
|
|
||||||
<ChevronDownIcon />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className='p-0'>
|
|
||||||
<Command>
|
|
||||||
<CommandInput value={query} onValueChange={setQuery} placeholder='Поиск...' className='h-9' />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>Список пуст</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{filtered.map(item => (
|
|
||||||
<CommandItem
|
|
||||||
key={idFunc(item)}
|
|
||||||
value={idFunc(item)}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(item);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{labelOptionFunc(item)}
|
|
||||||
<Check className={cn('ml-auto', value === item ? 'opacity-100' : 'opacity-0')} />
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -5,7 +5,6 @@ export { FileInput } from './file-input';
|
||||||
export { Label } from './label';
|
export { Label } from './label';
|
||||||
export { SearchBar } from './search-bar';
|
export { SearchBar } from './search-bar';
|
||||||
export { SelectMulti, type SelectMultiProps } from './select-multi';
|
export { SelectMulti, type SelectMultiProps } from './select-multi';
|
||||||
export { SelectSingle } from './select-single';
|
|
||||||
export { SelectTree } from './select-tree';
|
export { SelectTree } from './select-tree';
|
||||||
export { TextArea } from './text-area';
|
export { TextArea } from './text-area';
|
||||||
export { TextInput } from './text-input';
|
export { TextInput } from './text-input';
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import Select, {
|
|
||||||
type ClearIndicatorProps,
|
|
||||||
components,
|
|
||||||
type DropdownIndicatorProps,
|
|
||||||
type GroupBase,
|
|
||||||
type Props,
|
|
||||||
type StylesConfig
|
|
||||||
} from 'react-select';
|
|
||||||
|
|
||||||
import { useWindowSize } from '@/hooks/use-window-size';
|
|
||||||
import { APP_COLORS, SELECT_THEME } from '@/styling/colors';
|
|
||||||
|
|
||||||
import { IconClose, IconDropArrow, IconDropArrowUp } from '../icons';
|
|
||||||
|
|
||||||
function DropdownIndicator<Option, Group extends GroupBase<Option> = GroupBase<Option>>(
|
|
||||||
props: DropdownIndicatorProps<Option, false, Group>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
components.DropdownIndicator && (
|
|
||||||
<components.DropdownIndicator {...props}>
|
|
||||||
{props.selectProps.menuIsOpen ? <IconDropArrowUp size='1.25rem' /> : <IconDropArrow size='1.25rem' />}
|
|
||||||
</components.DropdownIndicator>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClearIndicator<Option, Group extends GroupBase<Option> = GroupBase<Option>>(
|
|
||||||
props: ClearIndicatorProps<Option, false, Group>
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
components.ClearIndicator && (
|
|
||||||
<components.ClearIndicator {...props}>
|
|
||||||
<IconClose size='1.25rem' />
|
|
||||||
</components.ClearIndicator>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectSingleProps<Option, Group extends GroupBase<Option> = GroupBase<Option>>
|
|
||||||
extends Omit<Props<Option, false, Group>, 'theme' | 'menuPortalTarget'> {
|
|
||||||
noPortal?: boolean;
|
|
||||||
noBorder?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays a single-select component.
|
|
||||||
*/
|
|
||||||
export function SelectSingle<Option, Group extends GroupBase<Option> = GroupBase<Option>>({
|
|
||||||
noPortal,
|
|
||||||
noBorder,
|
|
||||||
...restProps
|
|
||||||
}: SelectSingleProps<Option, Group>) {
|
|
||||||
const size = useWindowSize();
|
|
||||||
|
|
||||||
const adjustedStyles: StylesConfig<Option, false, Group> = {
|
|
||||||
container: defaultStyles => ({
|
|
||||||
...defaultStyles,
|
|
||||||
borderRadius: '0.25rem'
|
|
||||||
}),
|
|
||||||
control: (defaultStyles, { isDisabled }) => ({
|
|
||||||
...defaultStyles,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
...(noBorder ? { borderWidth: 0 } : {}),
|
|
||||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
|
||||||
boxShadow: 'none'
|
|
||||||
}),
|
|
||||||
menuPortal: defaultStyles => ({
|
|
||||||
...defaultStyles,
|
|
||||||
zIndex: 9999
|
|
||||||
}),
|
|
||||||
menuList: defaultStyles => ({
|
|
||||||
...defaultStyles,
|
|
||||||
padding: 0
|
|
||||||
}),
|
|
||||||
option: (defaultStyles, { isSelected }) => ({
|
|
||||||
...defaultStyles,
|
|
||||||
padding: '0.25rem 0.75rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
lineHeight: '1.25rem',
|
|
||||||
backgroundColor: isSelected ? APP_COLORS.bgSelected : defaultStyles.backgroundColor,
|
|
||||||
color: isSelected ? APP_COLORS.fgSelected : defaultStyles.color,
|
|
||||||
borderWidth: '1px',
|
|
||||||
borderColor: APP_COLORS.border
|
|
||||||
}),
|
|
||||||
input: defaultStyles => ({ ...defaultStyles }),
|
|
||||||
placeholder: defaultStyles => ({ ...defaultStyles }),
|
|
||||||
singleValue: defaultStyles => ({ ...defaultStyles }),
|
|
||||||
dropdownIndicator: base => ({
|
|
||||||
...base,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0
|
|
||||||
}),
|
|
||||||
clearIndicator: base => ({
|
|
||||||
...base,
|
|
||||||
paddingTop: 0,
|
|
||||||
paddingBottom: 0
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
noOptionsMessage={() => 'Список пуст'}
|
|
||||||
components={{ DropdownIndicator, ClearIndicator }}
|
|
||||||
theme={theme => ({
|
|
||||||
...theme,
|
|
||||||
borderRadius: 0,
|
|
||||||
spacing: {
|
|
||||||
...theme.spacing,
|
|
||||||
baseUnit: size.isSmall ? 2 : 4,
|
|
||||||
menuGutter: 2,
|
|
||||||
controlHeight: size.isSmall ? 28 : 38
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
...theme.colors,
|
|
||||||
...SELECT_THEME
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
menuPortalTarget={!noPortal ? document.body : null}
|
|
||||||
styles={adjustedStyles}
|
|
||||||
classNames={{ container: () => 'focus-frame' }}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,4 +1,3 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
|
128
rsconcept/frontend/src/components/ui/combo-box.tsx
Normal file
128
rsconcept/frontend/src/components/ui/combo-box.tsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { MiniButton } from '../control';
|
||||||
|
import { IconRemove } from '../icons';
|
||||||
|
import { type Styling } from '../props';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
|
interface ComboBoxProps<Option> extends Styling {
|
||||||
|
id?: string;
|
||||||
|
items?: Option[];
|
||||||
|
value: Option | null;
|
||||||
|
onChange: (newValue: Option | null) => void;
|
||||||
|
|
||||||
|
idFunc: (item: Option) => string;
|
||||||
|
labelValueFunc: (item: Option) => string;
|
||||||
|
labelOptionFunc: (item: Option) => string;
|
||||||
|
|
||||||
|
placeholder?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
noBorder?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
noSearch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a combo-select component.
|
||||||
|
*/
|
||||||
|
export function ComboBox<Option>({
|
||||||
|
id,
|
||||||
|
items,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
labelValueFunc,
|
||||||
|
labelOptionFunc,
|
||||||
|
idFunc,
|
||||||
|
noBorder,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
hidden,
|
||||||
|
clearable,
|
||||||
|
noSearch
|
||||||
|
}: ComboBoxProps<Option>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
setPopoverWidth(triggerRef.current.offsetWidth);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function handleChangeValue(newValue: Option | null) {
|
||||||
|
onChange(newValue);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(event: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleChangeValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
id={id}
|
||||||
|
ref={triggerRef}
|
||||||
|
variant='ghost'
|
||||||
|
role='combobox'
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
'relative justify-between font-normal bg-input hover:bg-input',
|
||||||
|
open && 'cursor-auto',
|
||||||
|
!noBorder && 'border',
|
||||||
|
noBorder && 'rounded-md',
|
||||||
|
!value && 'text-muted-foreground hover:text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
hidden={hidden && !open}
|
||||||
|
>
|
||||||
|
<span className='truncate'>{value ? labelValueFunc(value) : placeholder}</span>
|
||||||
|
<ChevronDownIcon className={cn('text-muted-foreground', clearable && !!value && 'opacity-0')} />
|
||||||
|
{clearable && !!value ? (
|
||||||
|
<MiniButton
|
||||||
|
noHover
|
||||||
|
title='Очистить'
|
||||||
|
aria-label='Очистить'
|
||||||
|
className='absolute right-2 text-muted-foreground hover:text-warn-600'
|
||||||
|
icon={<IconRemove size='1rem' />}
|
||||||
|
onClick={handleClear}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent sideOffset={-1} className='p-0' style={{ width: popoverWidth }}>
|
||||||
|
<Command>
|
||||||
|
{!noSearch ? <CommandInput placeholder='Поиск...' className='h-9' /> : null}
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Список пуст</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{items?.map(item => (
|
||||||
|
<CommandItem
|
||||||
|
key={idFunc(item)}
|
||||||
|
value={labelOptionFunc(item)}
|
||||||
|
onSelect={() => handleChangeValue(item)}
|
||||||
|
className={cn(value === item && 'bg-selected text-selected-foreground')}
|
||||||
|
>
|
||||||
|
{labelOptionFunc(item)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
import * as React from 'react';
|
|
||||||
import { Command as CommandPrimitive } from 'cmdk';
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
import { SearchIcon } from 'lucide-react';
|
import { SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import * as React from 'react';
|
|
||||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from 'react';
|
'use client';
|
||||||
|
|
||||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
@ -114,15 +115,11 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
|
||||||
'outline-none focus:bg-accent focus:text-accent-foreground',
|
'outline-none focus:bg-accent focus:text-accent-foreground',
|
||||||
'*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
'*:[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",
|
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"data-[state='checked']:not-[:hover]:bg-selected data-[state='checked']:not-[:hover]:text-selected-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className='absolute right-2 flex size-3.5 items-center justify-center'>
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className='size-4' />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
|
|
|
@ -102,11 +102,13 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
|
<div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
|
||||||
{ownerSelector.isOpen ? (
|
<SelectUser
|
||||||
<div className='absolute -top-2 right-0'>
|
className='absolute -top-2 right-0 w-100 text-sm'
|
||||||
<SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />
|
value={schema.owner}
|
||||||
</div>
|
onChange={user => user && onSelectUser(user)}
|
||||||
) : null}
|
hidden={!ownerSelector.isOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<ValueIcon
|
<ValueIcon
|
||||||
className='sm:mb-1'
|
className='sm:mb-1'
|
||||||
icon={<IconOwner size='1.25rem' className='icon-primary' />}
|
icon={<IconOwner size='1.25rem' className='icon-primary' />}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { type Styling } from '@/components/props';
|
import { type Styling } from '@/components/props';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { ComboBox } from '@/components/ui/combo-box';
|
||||||
|
|
||||||
import { type ILibraryItem } from '../backend/types';
|
import { type ILibraryItem } from '../backend/types';
|
||||||
|
|
||||||
|
@ -15,31 +13,15 @@ interface SelectLibraryItemProps extends Styling {
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectLibraryItem({
|
export function SelectLibraryItem({ items, placeholder = 'Выберите схему', ...restProps }: SelectLibraryItemProps) {
|
||||||
id,
|
|
||||||
items,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Выберите схему',
|
|
||||||
...restProps
|
|
||||||
}: SelectLibraryItemProps) {
|
|
||||||
function handleSelect(newValue: string) {
|
|
||||||
const newItem = items?.find(item => item.id === Number(newValue)) ?? null;
|
|
||||||
onChange(newItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select onValueChange={handleSelect} defaultValue={value ? String(value.id) : undefined}>
|
<ComboBox
|
||||||
<SelectTrigger id={id} {...restProps}>
|
items={items}
|
||||||
<SelectValue placeholder={placeholder} />
|
placeholder={placeholder}
|
||||||
</SelectTrigger>
|
idFunc={item => String(item.id)}
|
||||||
<SelectContent className='max-w-80'>
|
labelValueFunc={item => `${item.alias}: ${item.title}`}
|
||||||
{items?.map(item => (
|
labelOptionFunc={item => `${item.alias}: ${item.title}`}
|
||||||
<SelectItem key={`${id ?? 'default'}-item-select-${item.id}`} value={String(item.id)}>
|
{...restProps}
|
||||||
{`${item.alias}: ${item.title}`}
|
/>
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,7 +63,8 @@ export function DlgEditEditors() {
|
||||||
<SelectUser
|
<SelectUser
|
||||||
filter={id => !selected.includes(id)} //
|
filter={id => !selected.includes(id)} //
|
||||||
value={null}
|
value={null}
|
||||||
onChange={onAddEditor}
|
noAnonymous
|
||||||
|
onChange={user => user && onAddEditor(user)}
|
||||||
className='w-100'
|
className='w-100'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -112,7 +112,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
|
||||||
aria-label='Выбор пользователя для фильтра по владельцу'
|
aria-label='Выбор пользователя для фильтра по владельцу'
|
||||||
placeholder='Выберите владельца'
|
placeholder='Выберите владельца'
|
||||||
noBorder
|
noBorder
|
||||||
className='min-w-60 text-sm mx-1 mb-1'
|
className='min-w-60 mx-1 mb-1'
|
||||||
value={filterUser}
|
value={filterUser}
|
||||||
onChange={setFilterUser}
|
onChange={setFilterUser}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,49 +1,27 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
import { SelectSingle } from '@/components/input';
|
|
||||||
import { type Styling } from '@/components/props';
|
import { type Styling } from '@/components/props';
|
||||||
|
import { ComboBox } from '@/components/ui/combo-box';
|
||||||
|
|
||||||
import { type IOperation } from '../models/oss';
|
import { type IOperation } from '../models/oss';
|
||||||
import { matchOperation } from '../models/oss-api';
|
|
||||||
|
|
||||||
interface SelectOperationProps extends Styling {
|
interface SelectOperationProps extends Styling {
|
||||||
|
id?: string;
|
||||||
value: IOperation | null;
|
value: IOperation | null;
|
||||||
onChange: (newValue: IOperation | null) => void;
|
onChange: (newValue: IOperation | null) => void;
|
||||||
|
|
||||||
items?: IOperation[];
|
items?: IOperation[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
|
popoverClassname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectOperation({
|
export function SelectOperation({ items, placeholder = 'Выберите операцию', ...restProps }: SelectOperationProps) {
|
||||||
className,
|
|
||||||
items,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Выберите операцию',
|
|
||||||
...restProps
|
|
||||||
}: SelectOperationProps) {
|
|
||||||
const options =
|
|
||||||
items?.map(cst => ({
|
|
||||||
value: cst.id,
|
|
||||||
label: `${cst.alias}: ${cst.title}`
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
function filter(option: { value: string | undefined; label: string }, query: string) {
|
|
||||||
const operation = items?.find(item => item.id === Number(option.value));
|
|
||||||
return !operation ? false : matchOperation(operation, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectSingle
|
<ComboBox
|
||||||
className={clsx('text-ellipsis', className)}
|
items={items}
|
||||||
options={options}
|
|
||||||
value={value ? { value: value.id, label: `${value.alias}: ${value.title}` } : null}
|
|
||||||
onChange={data => onChange(items?.find(cst => cst.id === data?.value) ?? null)}
|
|
||||||
filterOption={filter}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
idFunc={operation => String(operation.id)}
|
||||||
|
labelValueFunc={operation => `${operation.alias}: ${operation.title}`}
|
||||||
|
labelOptionFunc={operation => `${operation.alias}: ${operation.title}`}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -20,13 +20,12 @@ import {
|
||||||
} from '@/features/rsform/models/rslang-api';
|
} from '@/features/rsform/models/rslang-api';
|
||||||
|
|
||||||
import { infoMsg } from '@/utils/labels';
|
import { infoMsg } from '@/utils/labels';
|
||||||
import { TextMatcher } from '@/utils/utils';
|
|
||||||
|
|
||||||
import { Graph } from '../../../models/graph';
|
import { Graph } from '../../../models/graph';
|
||||||
import { type IOssLayout } from '../backend/types';
|
import { type IOssLayout } from '../backend/types';
|
||||||
import { describeSubstitutionError } from '../labels';
|
import { describeSubstitutionError } from '../labels';
|
||||||
|
|
||||||
import { type IOperation, type IOperationSchema, SubstitutionErrorType } from './oss';
|
import { type IOperationSchema, SubstitutionErrorType } from './oss';
|
||||||
import { type Position2D } from './oss-layout';
|
import { type Position2D } from './oss-layout';
|
||||||
|
|
||||||
export const GRID_SIZE = 10; // pixels - size of OSS grid
|
export const GRID_SIZE = 10; // pixels - size of OSS grid
|
||||||
|
@ -36,17 +35,6 @@ const DISTANCE_Y = 100; // pixels - insert y-distance between node centers
|
||||||
|
|
||||||
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
|
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a given target {@link IOperation} matches the specified query using.
|
|
||||||
*
|
|
||||||
* @param target - The target object to be matched.
|
|
||||||
* @param query - The query string used for matching.
|
|
||||||
*/
|
|
||||||
export function matchOperation(target: IOperation, query: string): boolean {
|
|
||||||
const matcher = new TextMatcher(query);
|
|
||||||
return matcher.test(target.alias) || matcher.test(target.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts library items relevant for the specified {@link IOperationSchema}.
|
* Sorts library items relevant for the specified {@link IOperationSchema}.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { ComboBox } from '@/components/input/combo-box';
|
import { ComboBox } from '@/components/ui/combo-box';
|
||||||
|
|
||||||
import { describeConstituenta, describeConstituentaTerm } from '../labels';
|
import { describeConstituenta, describeConstituentaTerm } from '../labels';
|
||||||
import { type IConstituenta } from '../models/rsform';
|
import { type IConstituenta } from '../models/rsform';
|
||||||
import { matchConstituenta } from '../models/rsform-api';
|
|
||||||
import { CstMatchMode } from '../stores/cst-search';
|
|
||||||
|
|
||||||
interface SelectConstituentaProps {
|
interface SelectConstituentaProps {
|
||||||
|
id?: string;
|
||||||
value: IConstituenta | null;
|
value: IConstituenta | null;
|
||||||
onChange: (newValue: IConstituenta | null) => void;
|
onChange: (newValue: IConstituenta | null) => void;
|
||||||
|
|
||||||
|
@ -24,7 +23,6 @@ export function SelectConstituenta({
|
||||||
<ComboBox
|
<ComboBox
|
||||||
items={items}
|
items={items}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
filterFunc={(item, query) => matchConstituenta(item, query, CstMatchMode.ALL)}
|
|
||||||
idFunc={cst => String(cst.id)}
|
idFunc={cst => String(cst.id)}
|
||||||
labelValueFunc={cst => `${cst.alias}: ${describeConstituentaTerm(cst)}`}
|
labelValueFunc={cst => `${cst.alias}: ${describeConstituentaTerm(cst)}`}
|
||||||
labelOptionFunc={cst => `${cst.alias}${cst.is_inherited ? '*' : ''}: ${describeConstituenta(cst)}`}
|
labelOptionFunc={cst => `${cst.alias}${cst.is_inherited ? '*' : ''}: ${describeConstituenta(cst)}`}
|
||||||
|
|
|
@ -20,6 +20,7 @@ export function SelectMultiGrammeme({ value, onChange, ...restProps }: SelectMul
|
||||||
<SelectMulti
|
<SelectMulti
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
|
isSearchable={false}
|
||||||
onChange={newValue => onChange([...newValue].sort((left, right) => grammemeCompare(left.value, right.value)))}
|
onChange={newValue => onChange([...newValue].sort((left, right) => grammemeCompare(left.value, right.value)))}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import { useTemplatesSuspense } from '@/features/library/backend/use-templates';
|
import { useTemplatesSuspense } from '@/features/library/backend/use-templates';
|
||||||
|
|
||||||
import { SelectSingle, TextArea } from '@/components/input';
|
import { TextArea } from '@/components/input';
|
||||||
|
import { ComboBox } from '@/components/ui/combo-box';
|
||||||
|
|
||||||
import { useRSForm } from '../../backend/use-rsform';
|
import { useRSForm } from '../../backend/use-rsform';
|
||||||
import { PickConstituenta } from '../../components/pick-constituenta';
|
import { PickConstituenta } from '../../components/pick-constituenta';
|
||||||
|
@ -24,6 +25,7 @@ export function TabTemplate() {
|
||||||
|
|
||||||
const { templates } = useTemplatesSuspense();
|
const { templates } = useTemplatesSuspense();
|
||||||
const { schema: templateSchema } = useRSForm({ itemID: templateID ?? undefined });
|
const { schema: templateSchema } = useRSForm({ itemID: templateID ?? undefined });
|
||||||
|
const selectedTemplate = templates.find(item => item.id === templateID);
|
||||||
|
|
||||||
if (!templateID) {
|
if (!templateID) {
|
||||||
onChangeTemplateID(templates[0].id);
|
onChangeTemplateID(templates[0].id);
|
||||||
|
@ -40,47 +42,37 @@ export function TabTemplate() {
|
||||||
? ''
|
? ''
|
||||||
: `${prototype?.term_raw}${prototype?.definition_raw ? ` — ${prototype?.definition_raw}` : ''}`;
|
: `${prototype?.term_raw}${prototype?.definition_raw ? ` — ${prototype?.definition_raw}` : ''}`;
|
||||||
|
|
||||||
const templateSelector = templates.map(template => ({
|
const categorySelector = !templateSchema
|
||||||
value: template.id,
|
|
||||||
label: template.title
|
|
||||||
}));
|
|
||||||
|
|
||||||
const categorySelector: { value: number; label: string }[] = !templateSchema
|
|
||||||
? []
|
? []
|
||||||
: templateSchema.items
|
: templateSchema.items.filter(cst => cst.cst_type === CATEGORY_CST_TYPE);
|
||||||
.filter(cst => cst.cst_type === CATEGORY_CST_TYPE)
|
|
||||||
.map(cst => ({
|
|
||||||
value: cst.id,
|
|
||||||
label: cst.term_raw
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='cc-fade-in'>
|
<div className='cc-fade-in'>
|
||||||
<div className='flex border-t border-x rounded-t-md clr-input'>
|
<div className='flex gap-1 border-t border-x rounded-t-md clr-input'>
|
||||||
<SelectSingle
|
<ComboBox
|
||||||
|
value={selectedTemplate ?? null}
|
||||||
|
items={templates}
|
||||||
noBorder
|
noBorder
|
||||||
|
noSearch
|
||||||
placeholder='Источник'
|
placeholder='Источник'
|
||||||
className='w-48'
|
className='w-48'
|
||||||
options={templateSelector}
|
idFunc={item => String(item.id)}
|
||||||
value={templateID ? { value: templateID, label: templates.find(item => item.id == templateID)!.title } : null}
|
labelValueFunc={item => item.title}
|
||||||
onChange={data => onChangeTemplateID(data ? data.value : null)}
|
labelOptionFunc={item => item.title}
|
||||||
|
onChange={item => onChangeTemplateID(item?.id ?? null)}
|
||||||
/>
|
/>
|
||||||
<SelectSingle
|
<ComboBox
|
||||||
|
value={filterCategory}
|
||||||
|
items={categorySelector}
|
||||||
noBorder
|
noBorder
|
||||||
isSearchable={false}
|
noSearch
|
||||||
|
clearable
|
||||||
placeholder='Выберите категорию'
|
placeholder='Выберите категорию'
|
||||||
className='grow ml-1 border-none'
|
className='grow'
|
||||||
options={categorySelector}
|
idFunc={cst => String(cst.id)}
|
||||||
value={
|
labelValueFunc={cst => cst.term_raw}
|
||||||
filterCategory && templateSchema
|
labelOptionFunc={cst => cst.term_raw}
|
||||||
? {
|
onChange={cst => onChangeFilterCategory(cst)}
|
||||||
value: filterCategory.id,
|
|
||||||
label: filterCategory.term_raw
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onChange={data => onChangeFilterCategory(data ? templateSchema?.cstByID.get(data?.value) ?? null : null)}
|
|
||||||
isClearable
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PickConstituenta
|
<PickConstituenta
|
||||||
|
|
|
@ -1,22 +1,21 @@
|
||||||
'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 { ComboBox } from '@/components/ui/combo-box';
|
||||||
|
|
||||||
import { type IUserInfo } from '../backend/types';
|
import { type IUserInfo } from '../backend/types';
|
||||||
import { useLabelUser } from '../backend/use-label-user';
|
import { useLabelUser } from '../backend/use-label-user';
|
||||||
import { useUsers } from '../backend/use-users';
|
import { useUsers } from '../backend/use-users';
|
||||||
import { matchUser } from '../models/user-api';
|
|
||||||
|
|
||||||
interface SelectUserProps extends Styling {
|
interface SelectUserProps extends Styling {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
onChange: (newValue: number) => void;
|
onChange: (newValue: number | null) => void;
|
||||||
filter?: (userID: number) => boolean;
|
filter?: (userID: number) => boolean;
|
||||||
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
|
noAnonymous?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareUsers(a: IUserInfo, b: IUserInfo) {
|
function compareUsers(a: IUserInfo, b: IUserInfo) {
|
||||||
|
@ -33,10 +32,8 @@ function compareUsers(a: IUserInfo, b: IUserInfo) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectUser({
|
export function SelectUser({
|
||||||
className,
|
|
||||||
filter,
|
filter,
|
||||||
value,
|
noAnonymous,
|
||||||
onChange,
|
|
||||||
placeholder = 'Выберите пользователя',
|
placeholder = 'Выберите пользователя',
|
||||||
...restProps
|
...restProps
|
||||||
}: SelectUserProps) {
|
}: SelectUserProps) {
|
||||||
|
@ -46,28 +43,16 @@ export function SelectUser({
|
||||||
const items = filter ? users.filter(user => filter(user.id)) : users;
|
const items = filter ? users.filter(user => filter(user.id)) : users;
|
||||||
const sorted = [
|
const sorted = [
|
||||||
...items.filter(user => !!user.first_name || !!user.last_name).sort(compareUsers),
|
...items.filter(user => !!user.first_name || !!user.last_name).sort(compareUsers),
|
||||||
...items.filter(user => !user.first_name && !user.last_name)
|
...(!noAnonymous ? items.filter(user => !user.first_name && !user.last_name) : [])
|
||||||
];
|
].map(user => user.id);
|
||||||
const options = sorted.map(user => ({
|
|
||||||
value: user.id,
|
|
||||||
label: getUserLabel(user.id)
|
|
||||||
}));
|
|
||||||
|
|
||||||
function filterLabel(option: { value: string | undefined; label: string }, query: string) {
|
|
||||||
const user = items.find(item => item.id === Number(option.value));
|
|
||||||
return !user ? false : matchUser(user, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectSingle
|
<ComboBox
|
||||||
className={clsx('text-ellipsis', className)}
|
items={sorted}
|
||||||
options={options}
|
|
||||||
value={value ? { value: value, label: getUserLabel(value) } : null}
|
|
||||||
onChange={data => {
|
|
||||||
if (data?.value !== undefined) onChange(data.value);
|
|
||||||
}}
|
|
||||||
filterOption={filterLabel}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
idFunc={user => String(user)}
|
||||||
|
labelValueFunc={user => getUserLabel(user)}
|
||||||
|
labelOptionFunc={user => getUserLabel(user)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
/**
|
|
||||||
* Module: API for formal representation for Users.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { TextMatcher } from '@/utils/utils';
|
|
||||||
|
|
||||||
import { type IUserInfo } from '../backend/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a given target {@link IConstituenta} matches the specified query using the provided matching mode.
|
|
||||||
*
|
|
||||||
* @param target - The target object to be matched.
|
|
||||||
* @param query - The query string used for matching.
|
|
||||||
* @param mode - The matching mode to determine which properties to include in the matching process.
|
|
||||||
*/
|
|
||||||
export function matchUser(target: IUserInfo, query: string): boolean {
|
|
||||||
const matcher = new TextMatcher(query);
|
|
||||||
return matcher.test(target.last_name) || matcher.test(target.first_name);
|
|
||||||
}
|
|
|
@ -26,6 +26,21 @@
|
||||||
outline: 2px solid hotpink !important;
|
outline: 2px solid hotpink !important;
|
||||||
} */
|
} */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --radius: 0.625rem; */
|
||||||
|
--primary: oklch(20.5% 0 0deg);
|
||||||
|
--primary-foreground: oklch(98.5% 0 0deg);
|
||||||
|
--secondary: oklch(97% 0 0deg);
|
||||||
|
--secondary-foreground: oklch(20.5% 0 0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--primary: oklch(92.2% 0 0deg);
|
||||||
|
--primary-foreground: oklch(20.5% 0 0deg);
|
||||||
|
--secondary: oklch(26.9% 0 0deg);
|
||||||
|
--secondary-foreground: oklch(98.5% 0 0deg);
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* stylelint-disable-next-line custom-property-pattern */
|
/* stylelint-disable-next-line custom-property-pattern */
|
||||||
--color-*: initial;
|
--color-*: initial;
|
||||||
|
@ -54,6 +69,9 @@
|
||||||
|
|
||||||
--color-ok-600: var(--clr-ok-600);
|
--color-ok-600: var(--clr-ok-600);
|
||||||
|
|
||||||
|
--color-selected: var(--clr-sec-200);
|
||||||
|
--color-selected-foreground: var(--clr-prim-999);
|
||||||
|
|
||||||
/* stylelint-disable-next-line custom-property-pattern */
|
/* stylelint-disable-next-line custom-property-pattern */
|
||||||
--z-index-*: initial;
|
--z-index-*: initial;
|
||||||
--z-index-bottom: 0;
|
--z-index-bottom: 0;
|
||||||
|
@ -80,38 +98,9 @@
|
||||||
--duration-fade: 300ms;
|
--duration-fade: 300ms;
|
||||||
--duration-dropdown: 200ms;
|
--duration-dropdown: 200ms;
|
||||||
--duration-select: 100ms;
|
--duration-select: 100ms;
|
||||||
}
|
|
||||||
|
|
||||||
/* ========= shadcn theme ============ */
|
/* ========= shadcn theme ============ */
|
||||||
|
|
||||||
:root {
|
|
||||||
/* --radius: 0.625rem; */
|
|
||||||
--primary: oklch(20.5% 0 0deg);
|
|
||||||
--primary-foreground: oklch(98.5% 0 0deg);
|
|
||||||
--secondary: oklch(97% 0 0deg);
|
|
||||||
--secondary-foreground: oklch(20.5% 0 0deg);
|
|
||||||
|
|
||||||
--chart-1: oklch(64.6% 0.222 41.116deg);
|
|
||||||
--chart-2: oklch(60% 0.118 184.704deg);
|
|
||||||
--chart-3: oklch(39.8% 0.07 227.392deg);
|
|
||||||
--chart-4: oklch(82.8% 0.189 84.429deg);
|
|
||||||
--chart-5: oklch(76.9% 0.188 70.08deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--primary: oklch(92.2% 0 0deg);
|
|
||||||
--primary-foreground: oklch(20.5% 0 0deg);
|
|
||||||
--secondary: oklch(26.9% 0 0deg);
|
|
||||||
--secondary-foreground: oklch(98.5% 0 0deg);
|
|
||||||
|
|
||||||
--chart-1: oklch(48.8% 0.243 264.376deg);
|
|
||||||
--chart-2: oklch(69.6% 0.17 162.48deg);
|
|
||||||
--chart-3: oklch(76.9% 0.188 70.08deg);
|
|
||||||
--chart-4: oklch(62.7% 0.265 303.9deg);
|
|
||||||
--chart-5: oklch(64.5% 0.246 16.439deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
/* --radius-sm: calc(var(--radius) - 4px);
|
/* --radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
|
Loading…
Reference in New Issue
Block a user