mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 04:50:36 +03:00
F: Replace SelectSingle with combobox
This commit is contained in:
parent
c1d84dc490
commit
b78b7edceb
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
@ -79,6 +79,7 @@
|
|||
"Certbot",
|
||||
"CIHT",
|
||||
"clsx",
|
||||
"cmdk",
|
||||
"codemirror",
|
||||
"Constituenta",
|
||||
"corsheaders",
|
||||
|
|
117
rsconcept/frontend/package-lock.json
generated
117
rsconcept/frontend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
49
rsconcept/frontend/src/components/ui/button.tsx
Normal file
49
rsconcept/frontend/src/components/ui/button.tsx
Normal 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 };
|
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>
|
||||
);
|
||||
}
|
130
rsconcept/frontend/src/components/ui/command.tsx
Normal file
130
rsconcept/frontend/src/components/ui/command.tsx
Normal 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
|
||||
};
|
110
rsconcept/frontend/src/components/ui/dialog.tsx
Normal file
110
rsconcept/frontend/src/components/ui/dialog.tsx
Normal 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
|
||||
};
|
39
rsconcept/frontend/src/components/ui/popover.tsx
Normal file
39
rsconcept/frontend/src/components/ui/popover.tsx
Normal 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 };
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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' />}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}.
|
||||
*/
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
} */
|
||||
|
||||
: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 ============ */
|
||||
|
||||
: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);
|
||||
|
|
|
@ -228,7 +228,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
@utility cc-select-popover {
|
||||
@utility cc-animate-popover {
|
||||
transform-origin: var(--radix-select-content-transform-origin);
|
||||
|
||||
&[data-state='open'] {
|
||||
|
|
Loading…
Reference in New Issue
Block a user