F: Replace SelectSingle with combobox

This commit is contained in:
Ivan 2025-04-11 20:00:42 +03:00
parent c1d84dc490
commit b78b7edceb
24 changed files with 681 additions and 353 deletions

View File

@ -79,6 +79,7 @@
"Certbot",
"CIHT",
"clsx",
"cmdk",
"codemirror",
"Constituenta",
"corsheaders",

View File

@ -11,7 +11,10 @@
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@lezer/lr": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@tanstack/react-query": "^5.71.10",
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-table": "^8.21.2",
@ -20,6 +23,7 @@
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.487.0",
@ -2593,6 +2597,42 @@
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.7.tgz",
"integrity": "sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.6",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.3",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.5",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-slot": "1.2.0",
"@radix-ui/react-use-controllable-state": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -2693,6 +2733,43 @@
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.7.tgz",
"integrity": "sha512-I38OYWDmJF2kbO74LX8UsFydSHWOJuQ7LxPnTefjxxvdvPLempvAnmsyX9UsBlywcbSGpRH7oMLfkUf+ij4nrw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.6",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.3",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.3",
"@radix-ui/react-portal": "1.1.5",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-slot": "1.2.0",
"@radix-ui/react-use-controllable-state": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.3.tgz",
@ -2749,6 +2826,30 @@
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
"integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
@ -5534,6 +5635,22 @@
"node": ">=6"
}
},
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",

View File

@ -17,7 +17,10 @@
"@dagrejs/dagre": "^1.1.4",
"@hookform/resolvers": "^5.0.1",
"@lezer/lr": "^1.4.2",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-slot": "^1.2.0",
"@tanstack/react-query": "^5.71.10",
"@tanstack/react-query-devtools": "^5.71.10",
"@tanstack/react-table": "^8.21.2",
@ -26,6 +29,7 @@
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"global": "^4.4.0",
"js-file-download": "^0.4.12",
"lucide-react": "^0.487.0",

View File

@ -5,7 +5,6 @@ export { FileInput } from './file-input';
export { Label } from './label';
export { SearchBar } from './search-bar';
export { SelectMulti, type SelectMultiProps } from './select-multi';
export { SelectSingle } from './select-single';
export { SelectTree } from './select-tree';
export { TextArea } from './text-area';
export { TextInput } from './text-input';

View File

@ -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}
/>
);
}

View File

@ -0,0 +1,49 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 cursor-pointer disabled:cursor-auto whitespace-nowrap font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return <Comp data-slot='button' className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };

View 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>
);
}

View File

@ -0,0 +1,130 @@
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className='overflow-hidden p-0'>
<Command className='[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5'>
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot='command-input-wrapper' className='flex h-9 items-center gap-2 border-b px-3'>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot='command-empty' className='py-6 text-center text-sm' {...props} />;
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
);
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
};

View File

@ -0,0 +1,110 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot='dialog-overlay'
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-topmost bg-black/50',
className
)}
{...props}
/>
);
}
function DialogContent({ className, children, ...props }: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot='dialog-content'
className={cn(
'bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View File

@ -0,0 +1,39 @@
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />;
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'z-topmost bg-popover text-popover-foreground cc-animate-popover w-72 border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />;
}
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };

View File

@ -1,6 +1,7 @@
import * as React from 'react';
'use client';
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';
@ -33,7 +34,8 @@ function SelectTrigger({
className={cn(
'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',
'bg-input disabled:opacity-50',
'cursor-pointer disabled:cursor-auto',
'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',
@ -68,7 +70,7 @@ function SelectContent({
'bg-popover text-sm text-popover-foreground',
'border shadow-md',
'overflow-x-hidden overflow-y-auto',
'cc-select-popover',
'cc-animate-popover',
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',
className
@ -113,15 +115,11 @@ function SelectItem({ className, children, ...props }: React.ComponentProps<type
'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",
"data-[state='checked']:not-[:hover]:bg-selected data-[state='checked']:not-[:hover]:text-selected-foreground",
className
)}
{...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.Item>
);

View File

@ -102,11 +102,13 @@ export function EditorLibraryItem({ schema, isAttachedToOSS }: EditorLibraryItem
</div>
<div className='relative' ref={ownerSelector.ref} onBlur={ownerSelector.handleBlur}>
{ownerSelector.isOpen ? (
<div className='absolute -top-2 right-0'>
<SelectUser className='w-100 text-sm' value={schema.owner} onChange={onSelectUser} />
</div>
) : null}
<SelectUser
className='absolute -top-2 right-0 w-100 text-sm'
value={schema.owner}
onChange={user => user && onSelectUser(user)}
hidden={!ownerSelector.isOpen}
/>
<ValueIcon
className='sm:mb-1'
icon={<IconOwner size='1.25rem' className='icon-primary' />}

View File

@ -1,7 +1,5 @@
'use client';
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';
@ -15,31 +13,15 @@ interface SelectLibraryItemProps extends Styling {
noBorder?: boolean;
}
export function SelectLibraryItem({
id,
items,
value,
onChange,
placeholder = 'Выберите схему',
...restProps
}: SelectLibraryItemProps) {
function handleSelect(newValue: string) {
const newItem = items?.find(item => item.id === Number(newValue)) ?? null;
onChange(newItem);
}
export function SelectLibraryItem({ items, placeholder = 'Выберите схему', ...restProps }: SelectLibraryItemProps) {
return (
<Select onValueChange={handleSelect} defaultValue={value ? String(value.id) : undefined}>
<SelectTrigger id={id} {...restProps}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className='max-w-80'>
{items?.map(item => (
<SelectItem key={`${id ?? 'default'}-item-select-${item.id}`} value={String(item.id)}>
{`${item.alias}: ${item.title}`}
</SelectItem>
))}
</SelectContent>
</Select>
<ComboBox
items={items}
placeholder={placeholder}
idFunc={item => String(item.id)}
labelValueFunc={item => `${item.alias}: ${item.title}`}
labelOptionFunc={item => `${item.alias}: ${item.title}`}
{...restProps}
/>
);
}

View File

@ -63,7 +63,8 @@ export function DlgEditEditors() {
<SelectUser
filter={id => !selected.includes(id)} //
value={null}
onChange={onAddEditor}
noAnonymous
onChange={user => user && onAddEditor(user)}
className='w-100'
/>
</div>

View File

@ -112,7 +112,7 @@ export function ToolbarSearch({ className, total, filtered }: ToolbarSearchProps
aria-label='Выбор пользователя для фильтра по владельцу'
placeholder='Выберите владельца'
noBorder
className='min-w-60 text-sm mx-1 mb-1'
className='min-w-60 mx-1 mb-1'
value={filterUser}
onChange={setFilterUser}
/>

View File

@ -1,49 +1,27 @@
'use client';
import clsx from 'clsx';
import { SelectSingle } from '@/components/input';
import { type Styling } from '@/components/props';
import { ComboBox } from '@/components/ui/combo-box';
import { type IOperation } from '../models/oss';
import { matchOperation } from '../models/oss-api';
interface SelectOperationProps extends Styling {
id?: string;
value: IOperation | null;
onChange: (newValue: IOperation | null) => void;
items?: IOperation[];
placeholder?: string;
noBorder?: boolean;
popoverClassname?: string;
}
export function SelectOperation({
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);
}
export function SelectOperation({ items, placeholder = 'Выберите операцию', ...restProps }: SelectOperationProps) {
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
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}
<ComboBox
items={items}
placeholder={placeholder}
idFunc={operation => String(operation.id)}
labelValueFunc={operation => `${operation.alias}: ${operation.title}`}
labelOptionFunc={operation => `${operation.alias}: ${operation.title}`}
{...restProps}
/>
);

View File

@ -20,13 +20,12 @@ import {
} from '@/features/rsform/models/rslang-api';
import { infoMsg } from '@/utils/labels';
import { TextMatcher } from '@/utils/utils';
import { Graph } from '../../../models/graph';
import { type IOssLayout } from '../backend/types';
import { describeSubstitutionError } from '../labels';
import { type IOperation, type IOperationSchema, SubstitutionErrorType } from './oss';
import { type IOperationSchema, SubstitutionErrorType } from './oss';
import { type Position2D } from './oss-layout';
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
/**
* 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}.
*/

View File

@ -1,51 +1,31 @@
'use client';
import clsx from 'clsx';
import { SelectSingle } from '@/components/input';
import { type Styling } from '@/components/props';
import { ComboBox } from '@/components/ui/combo-box';
import { describeConstituenta, describeConstituentaTerm } from '../labels';
import { type IConstituenta } from '../models/rsform';
import { matchConstituenta } from '../models/rsform-api';
import { CstMatchMode } from '../stores/cst-search';
interface SelectConstituentaProps extends Styling {
interface SelectConstituentaProps {
id?: string;
value: IConstituenta | null;
onChange: (newValue: IConstituenta | null) => void;
className?: string;
items?: IConstituenta[];
placeholder?: string;
noBorder?: boolean;
}
export function SelectConstituenta({
className,
items,
value,
onChange,
placeholder = 'Выберите конституенту',
...restProps
}: SelectConstituentaProps) {
const options =
items?.map(cst => ({
value: cst.id,
label: `${cst.alias}${cst.is_inherited ? '*' : ''}: ${describeConstituenta(cst)}`
})) ?? [];
function filter(option: { value: string | undefined; label: string }, query: string) {
const cst = items?.find(item => item.id === Number(option.value));
return !cst ? false : matchConstituenta(cst, query, CstMatchMode.ALL);
}
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value.id, label: `${value.alias}: ${describeConstituentaTerm(value)}` } : null}
onChange={data => onChange(items?.find(cst => cst.id === data?.value) ?? null)}
filterOption={filter}
<ComboBox
items={items}
placeholder={placeholder}
idFunc={cst => String(cst.id)}
labelValueFunc={cst => `${cst.alias}: ${describeConstituentaTerm(cst)}`}
labelOptionFunc={cst => `${cst.alias}${cst.is_inherited ? '*' : ''}: ${describeConstituenta(cst)}`}
{...restProps}
/>
);

View File

@ -20,6 +20,7 @@ export function SelectMultiGrammeme({ value, onChange, ...restProps }: SelectMul
<SelectMulti
options={options}
value={value}
isSearchable={false}
onChange={newValue => onChange([...newValue].sort((left, right) => grammemeCompare(left.value, right.value)))}
{...restProps}
/>

View File

@ -2,7 +2,8 @@
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 { PickConstituenta } from '../../components/pick-constituenta';
@ -24,6 +25,7 @@ export function TabTemplate() {
const { templates } = useTemplatesSuspense();
const { schema: templateSchema } = useRSForm({ itemID: templateID ?? undefined });
const selectedTemplate = templates.find(item => item.id === templateID);
if (!templateID) {
onChangeTemplateID(templates[0].id);
@ -40,47 +42,37 @@ export function TabTemplate() {
? ''
: `${prototype?.term_raw}${prototype?.definition_raw ? `${prototype?.definition_raw}` : ''}`;
const templateSelector = templates.map(template => ({
value: template.id,
label: template.title
}));
const categorySelector: { value: number; label: string }[] = !templateSchema
const categorySelector = !templateSchema
? []
: templateSchema.items
.filter(cst => cst.cst_type === CATEGORY_CST_TYPE)
.map(cst => ({
value: cst.id,
label: cst.term_raw
}));
: templateSchema.items.filter(cst => cst.cst_type === CATEGORY_CST_TYPE);
return (
<div className='cc-fade-in'>
<div className='flex border-t border-x rounded-t-md clr-input'>
<SelectSingle
<div className='flex gap-1 border-t border-x rounded-t-md clr-input'>
<ComboBox
value={selectedTemplate ?? null}
items={templates}
noBorder
noSearch
placeholder='Источник'
className='w-48'
options={templateSelector}
value={templateID ? { value: templateID, label: templates.find(item => item.id == templateID)!.title } : null}
onChange={data => onChangeTemplateID(data ? data.value : null)}
idFunc={item => String(item.id)}
labelValueFunc={item => item.title}
labelOptionFunc={item => item.title}
onChange={item => onChangeTemplateID(item?.id ?? null)}
/>
<SelectSingle
<ComboBox
value={filterCategory}
items={categorySelector}
noBorder
isSearchable={false}
noSearch
clearable
placeholder='Выберите категорию'
className='grow ml-1 border-none'
options={categorySelector}
value={
filterCategory && templateSchema
? {
value: filterCategory.id,
label: filterCategory.term_raw
}
: null
}
onChange={data => onChangeFilterCategory(data ? templateSchema?.cstByID.get(data?.value) ?? null : null)}
isClearable
className='grow'
idFunc={cst => String(cst.id)}
labelValueFunc={cst => cst.term_raw}
labelOptionFunc={cst => cst.term_raw}
onChange={cst => onChangeFilterCategory(cst)}
/>
</div>
<PickConstituenta

View File

@ -1,22 +1,21 @@
'use client';
import clsx from 'clsx';
import { SelectSingle } from '@/components/input';
import { type Styling } from '@/components/props';
import { ComboBox } from '@/components/ui/combo-box';
import { type IUserInfo } from '../backend/types';
import { useLabelUser } from '../backend/use-label-user';
import { useUsers } from '../backend/use-users';
import { matchUser } from '../models/user-api';
interface SelectUserProps extends Styling {
value: number | null;
onChange: (newValue: number) => void;
onChange: (newValue: number | null) => void;
filter?: (userID: number) => boolean;
placeholder?: string;
noBorder?: boolean;
noAnonymous?: boolean;
hidden?: boolean;
}
function compareUsers(a: IUserInfo, b: IUserInfo) {
@ -33,10 +32,8 @@ function compareUsers(a: IUserInfo, b: IUserInfo) {
}
export function SelectUser({
className,
filter,
value,
onChange,
noAnonymous,
placeholder = 'Выберите пользователя',
...restProps
}: SelectUserProps) {
@ -46,28 +43,16 @@ export function SelectUser({
const items = filter ? users.filter(user => filter(user.id)) : users;
const sorted = [
...items.filter(user => !!user.first_name || !!user.last_name).sort(compareUsers),
...items.filter(user => !user.first_name && !user.last_name)
];
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);
}
...(!noAnonymous ? items.filter(user => !user.first_name && !user.last_name) : [])
].map(user => user.id);
return (
<SelectSingle
className={clsx('text-ellipsis', className)}
options={options}
value={value ? { value: value, label: getUserLabel(value) } : null}
onChange={data => {
if (data?.value !== undefined) onChange(data.value);
}}
filterOption={filterLabel}
<ComboBox
items={sorted}
placeholder={placeholder}
idFunc={user => String(user)}
labelValueFunc={user => getUserLabel(user)}
labelOptionFunc={user => getUserLabel(user)}
{...restProps}
/>
);

View File

@ -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);
}

View File

@ -26,6 +26,21 @@
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 {
/* stylelint-disable-next-line custom-property-pattern */
--color-*: initial;
@ -54,6 +69,9 @@
--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 */
--z-index-*: initial;
--z-index-bottom: 0;
@ -80,38 +98,9 @@
--duration-fade: 300ms;
--duration-dropdown: 200ms;
--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-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);

View File

@ -228,7 +228,7 @@
}
}
@utility cc-select-popover {
@utility cc-animate-popover {
transform-origin: var(--radix-select-content-transform-origin);
&[data-state='open'] {