fix: combobox and driver properties

This commit is contained in:
Wroud
2025-09-24 18:48:08 +08:00
parent 01f162f73b
commit ae9793bce7
7 changed files with 159 additions and 164 deletions
@@ -1,4 +1,5 @@
@import './_base.css';
@import './_sizes.css';
@layer base {
.dbv-kit-combobox {
@@ -96,4 +97,25 @@
gap: 0.5rem;
padding: 0.5rem;
}
/* ---------------------------- */
/* Sizes */
.dbv-kit-combobox--small {
--dbv-kit-combobox-height: var(--dbv-kit-combobox-small-height);
--dbv-kit-combobox-padding: var(--dbv-kit-combobox-small-padding);
--dbv-kit-combobox-font-size: var(--dbv-kit-combobox-small-font-size);
}
.dbv-kit-combobox--large {
--dbv-kit-combobox-height: var(--dbv-kit-combobox-large-height);
--dbv-kit-combobox-padding: var(--dbv-kit-combobox-large-padding);
--dbv-kit-combobox-font-size: var(--dbv-kit-combobox-large-font-size);
}
.dbv-kit-combobox--xlarge {
--dbv-kit-combobox-height: var(--dbv-kit-combobox-xlarge-height);
--dbv-kit-combobox-padding: var(--dbv-kit-combobox-xlarge-padding);
--dbv-kit-combobox-font-size: var(--dbv-kit-combobox-xlarge-font-size);
}
}
@@ -25,8 +25,12 @@ import {
import clsx from 'clsx';
import './Combobox.css';
export function ComboboxInput(props: AriaComboboxProps) {
return <Combobox {...props} className={clsx('dbv-kit-combobox', props.className)} autoSelect />;
export interface ComboboxProps extends Omit<AriaComboboxProps, 'size'> {
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export function ComboboxInput({size, ...props}: ComboboxProps) {
return <Combobox {...props} className={clsx('dbv-kit-combobox', `dbv-kit-combobox--${size ?? 'medium'}`, props.className)} autoSelect />;
}
export interface ComboboxPopoverProps extends AriaComboboxPopoverProps {
@@ -0,0 +1,23 @@
@layer base {
:root {
/* Small combobox */
--dbv-kit-combobox-small-padding: calc(var(--dbv-kit-combobox-padding) * 0.5);
--dbv-kit-combobox-small-font-size: calc(var(--dbv-kit-combobox-font-size) * 0.875);
--dbv-kit-combobox-small-height: var(--dbv-kit-control-height-small);
/* Medium combobox */
--dbv-kit-combobox-medium-padding: var(--dbv-kit-combobox-padding);
--dbv-kit-combobox-medium-font-size: var(--dbv-kit-combobox-font-size);
--dbv-kit-combobox-medium-height: var(--dbv-kit-control-height-medium);
/* Large combobox */
--dbv-kit-combobox-large-padding: calc(var(--dbv-kit-combobox-padding) * 1.25);
--dbv-kit-combobox-large-font-size: calc(var(--dbv-kit-combobox-font-size) * 1.125);
--dbv-kit-combobox-large-height: var(--dbv-kit-control-height-large);
/* Extra large combobox */
--dbv-kit-combobox-xlarge-padding: calc(var(--dbv-kit-combobox-padding) * 1.5);
--dbv-kit-combobox-xlarge-font-size: calc(var(--dbv-kit-combobox-font-size) * 1.25);
--dbv-kit-combobox-xlarge-height: var(--dbv-kit-control-height-xlarge);
}
}
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useContext, useEffect, useId, useState } from 'react';
import { useCallback, useContext, useId, useState } from 'react';
import { ComboboxInput, ComboboxItem, clsx, Spinner, ComboboxPopover, ComboboxDisclosure, ComboboxProvider } from '@dbeaver/ui-kit';
import { filterLayoutFakeProps, getLayoutProps } from '../Containers/filterLayoutFakeProps.js';
@@ -21,7 +21,7 @@ import './Combobox.css';
export type ComboboxBaseProps<TKey, TValue> = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onSelect' | 'name' | 'value' | 'defaultValue'
'onChange' | 'onSelect' | 'name' | 'value' | 'defaultValue' | 'size'
> &
ILayoutSizeProps & {
items: TValue[];
@@ -35,6 +35,8 @@ export type ComboboxBaseProps<TKey, TValue> = Omit<
isDisabled?: (item: TValue) => boolean;
inline?: boolean;
allowCustomValue?: boolean;
size?: 'small' | 'medium' | 'large';
onChange?: (value: string | null) => void;
};
type ControlledProps<TKey, TValue> = ComboboxBaseProps<TKey, TValue> & {
@@ -70,13 +72,15 @@ export const Combobox: ComboboxType = observer(function Combobox({
disabled,
inline,
description,
allowCustomValue = false,
keySelector = v => v,
valueSelector = v => v,
iconSelector,
titleSelector,
isDisabled,
size,
onSelect,
allowCustomValue = false,
onChange,
...rest
}: ControlledProps<any, any> | ObjectProps<any, any, any>) {
const layoutProps = getLayoutProps(rest);
@@ -91,21 +95,11 @@ export const Combobox: ComboboxType = observer(function Combobox({
}
const selectedItem = items.find((item, index) => keySelector(item, index) === selectedKey);
const [inputValue, setInputValue] = useState<string | null>(null);
const [internalInputValue, setInternalInputValue] = useState<string | null>(null);
const selectedValue = (function () {
if (selectedItem) {
return valueSelector(selectedItem);
}
if (allowCustomValue) {
return String(selectedKey);
}
return '';
})();
const inputValue = allowCustomValue ? controlledValue : internalInputValue;
const selectedValue = selectedItem ? valueSelector(selectedItem) : '';
const displayValue = inputValue ?? selectedValue;
const isSelectedExistingItem = items.some((item, index) => keySelector(item, index) === selectedKey);
const filteredItems = items
.map((item, index) => {
@@ -116,7 +110,7 @@ export const Combobox: ComboboxType = observer(function Combobox({
const itemDisabled = isDisabled?.(item);
const isVisible =
!displayValue || isSelectedExistingItem || !displayValue.trim() || itemValue.toLowerCase().includes(displayValue.trim().toLowerCase());
allowCustomValue || inputValue === null || !inputValue.trim() || itemValue.toLowerCase().includes(inputValue.trim().toLowerCase());
return {
item,
@@ -133,10 +127,8 @@ export const Combobox: ComboboxType = observer(function Combobox({
const handleSelect = useCallback(
(selectedValue: string | string[]) => {
const isItemExists = !!items.find((item, idx) => keySelector(item, idx) === selectedValue);
const isSameSelected = selectedValue === selectedKey;
if (!allowCustomValue && (!isItemExists || isSameSelected)) {
const item = items.find((item, idx) => keySelector(item, idx) === selectedValue);
if (!item || selectedValue === selectedKey) {
return;
}
@@ -150,7 +142,7 @@ export const Combobox: ComboboxType = observer(function Combobox({
context.change(selectedValue as string, name);
}
},
[items, selectedKey, state, onSelect, allowCustomValue, context, keySelector, name],
[items, selectedKey, state, onSelect, context, keySelector, name],
);
const icon = selectedItem && iconSelector?.(selectedItem);
@@ -166,25 +158,26 @@ export const Combobox: ComboboxType = observer(function Combobox({
}
}
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
if (allowCustomValue) {
handleSelect(event.target.value);
function setInputValue(value: string | null) {
setInternalInputValue(value);
onChange?.(value);
}
function handleBlur() {
if (!allowCustomValue) {
setInputValue(null);
}
}
function onBlur() {
function handleSetValueOnClick() {
if (allowCustomValue) {
handleSelect(displayValue);
return;
return true;
}
setInputValue(null);
return false;
}
// Reset transient input when external selection changes
useEffect(() => {
setInputValue(null);
}, [selectedKey]);
const displayPopover = !allowCustomValue || items.length > 0;
return (
<Field {...layoutProps} className={clsx(className, inline && 'tw:flex tw:items-center')}>
@@ -215,51 +208,49 @@ export const Combobox: ComboboxType = observer(function Combobox({
className={clsx('theme-typography--caption tw:tracking-normal!', icon || loading ? 'tw:pl-8!' : '', 'tw:pr-6!')}
title={title}
id={inputId}
onBlur={onBlur}
onChange={onChange}
size={size}
onBlur={handleBlur}
{...rest}
/>
{loading ? (
<Spinner size="small" className="tw:absolute tw:right-2 tw:top-[50%] tw:-translate-y-1/2" />
) : (
<ComboboxDisclosure
disabled={disabled || loading || readOnly}
className="tw:absolute tw:right-2 tw:top-[50%] tw:-translate-y-1/2 tw:*:fill-none! tw:cursor-pointer"
/>
displayPopover && (
<ComboboxDisclosure
disabled={disabled || loading || readOnly}
className="tw:absolute tw:right-2 tw:top-[50%] tw:-translate-y-1/2 tw:*:fill-none! tw:cursor-pointer"
/>
)
)}
{icon && <div className="tw:absolute tw:left-3 tw:w-4 tw:h-4">{typeof icon === 'string' ? <IconOrImage icon={icon} /> : icon}</div>}
<ComboboxPopover className="theme-text-on-surface theme-background-surface theme-typography--caption">
{filteredItems.length > 0 ? (
filteredItems.map(({ itemKey, itemValue, itemTitle, itemIcon, itemDisabled }) => (
<ComboboxItem
key={itemKey}
value={itemKey}
disabled={itemDisabled}
title={itemTitle}
setValueOnClick={() => {
setInputValue(null);
return false;
}}
className={clsx({
'tw:cursor-pointer': !itemDisabled,
'tw:cursor-not-allowed': itemDisabled,
})}
onClick={() => {
handleSelect(itemKey);
}}
>
{iconSelector && (
<div className="tw:w-4 tw:h-4 tw:shrink-0">
{itemIcon && typeof itemIcon === 'string' ? <IconOrImage icon={itemIcon} /> : itemIcon}
</div>
)}
<div className="tw:truncate">{itemValue}</div>
</ComboboxItem>
))
) : (
<div className="tw:p-2">{translate('combobox_no_results_placeholder')}</div>
)}
</ComboboxPopover>
{displayPopover && (
<ComboboxPopover className="theme-text-on-surface theme-background-surface theme-typography--caption">
{filteredItems.length > 0 ? (
filteredItems.map(({ itemKey, itemValue, itemTitle, itemIcon, itemDisabled }) => (
<ComboboxItem
key={itemKey}
value={itemKey}
disabled={itemDisabled}
title={itemTitle}
setValueOnClick={handleSetValueOnClick}
className={clsx({
'tw:cursor-pointer': !itemDisabled,
'tw:cursor-not-allowed': itemDisabled,
})}
>
{iconSelector && (
<div className="tw:w-4 tw:h-4 tw:shrink-0">
{itemIcon && typeof itemIcon === 'string' ? <IconOrImage icon={itemIcon} /> : itemIcon}
</div>
)}
<div className="tw:truncate">{itemValue}</div>
</ComboboxItem>
))
) : (
<div className="tw:p-2">{translate('combobox_no_results_placeholder')}</div>
)}
</ComboboxPopover>
)}
</div>
</ComboboxProvider>
{description && <FieldDescription>{description}</FieldDescription>}
@@ -42,7 +42,7 @@
.propertiesHeaderAdd {
flex: 0 0 auto;
padding: 0px 28px;
padding: 0px 24px;
}
.propertiesList {
@@ -5,8 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
.container,
.button {
.container {
composes: theme-ripple from global;
}
@@ -14,6 +13,7 @@
box-sizing: border-box;
display: inline-flex;
padding: 0px 1px;
min-height: 36px;
}
.name,
@@ -25,11 +25,6 @@
box-sizing: border-box;
flex: 1;
padding: 4px 0;
& .shadowInput {
height: 24px;
padding: 0 36px 0 12px;
}
}
.value,
@@ -42,54 +37,9 @@
width: 276px;
}
.remove {
width: 24px;
height: 24px;
position: relative;
flex: 0 0 auto;
align-items: center;
display: flex;
opacity: 0;
}
.remove {
margin: 0 2px;
}
.container:hover .remove {
opacity: 1;
}
.shadowInput {
composes: theme-background-surface from global;
}
.name .shadowInput,
.value .shadowInput {
box-sizing: border-box;
font: inherit;
color: inherit;
width: 100%;
outline: none;
&:global([readonly]),
&:not(:focus):not([data-focus='true']) {
background: transparent !important;
border: solid 2px transparent !important;
}
}
.icon,
.iconOrImage {
height: 16px;
display: block;
}
.button {
background: transparent;
outline: none;
padding: 4px;
cursor: pointer;
.actions {
margin: 0 4px;
min-width: 28px;
}
.error {
@@ -7,10 +7,8 @@
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useLayoutEffect, useRef } from 'react';
import { Input } from '@dbeaver/ui-kit';
import { ShadowInput } from '../FormControls/ShadowInput.js';
import { Icon } from '../Icon.js';
import { IconOrImage } from '../IconOrImage.js';
import { useTranslate } from '../localization/useTranslate.js';
import { s } from '../s.js';
import { useS } from '../useS.js';
@@ -18,6 +16,7 @@ import type { IProperty } from './IProperty.js';
import classes from './PropertyItem.module.css';
import { Combobox } from '../FormControls/Combobox.js';
import { isNotNullDefined } from '@dbeaver/js-helpers';
import { ActionIconButton } from '../ActionIconButton.js';
interface Props {
property: IProperty;
@@ -38,7 +37,7 @@ export const PropertyItem = observer<Props>(function PropertyItem({ property, va
const keyInputRef = useRef<HTMLInputElement>(null);
const handleKeyChange = useCallback((key: string) => onNameChange(property.id, key), [property]);
const handleValueChange = useCallback((value: string) => onValueChange(property.id, value), [property]);
const handleValueChange = useCallback((value: string | null) => onValueChange(property.id, value), [property]);
const handleRemove = useCallback(() => onRemove(property.id), [property]);
function handleRevert() {
onValueChange(property.id, property.defaultValue ?? null);
@@ -56,46 +55,52 @@ export const PropertyItem = observer<Props>(function PropertyItem({ property, va
return (
<div className={s(styles, { container: true })}>
<div className={s(styles, { name: true, error })} title={property.description}>
<ShadowInput
<Input
ref={keyInputRef}
className={s(styles, { shadowInput: true })}
value={property.displayName || property.key}
type="text"
name={property.id}
placeholder={keyPlaceholder}
readOnly={!isDeletable}
autoComplete="none"
onChange={handleKeyChange}
>
{property.displayName || property.key}
</ShadowInput>
size="small"
onChange={e => handleKeyChange(e.target.value)}
/>
</div>
<div className={s(styles, { value: true })} title={String(propertyValue)}>
{!readOnly && property.validValues && property.validValues.length > 0 && (
<Combobox
value={value}
defaultValue={valuePlaceholder}
name={`${property.id}_value`}
placeholder={valuePlaceholder}
items={property.validValues}
readOnly={readOnly}
tiny
fill
allowCustomValue
onSelect={handleValueChange}
/>
)}
<div className={s(styles, { remove: true })} title={translate('core_blocks_properties_table_item_reset')}>
<button hidden={!edited || isDeletable} className={s(styles, { button: true })} type="button" onClick={handleRevert}>
<IconOrImage className={s(styles, { iconOrImage: true })} icon="/icons/data_revert_all_sm.svg" viewBox="0 0 16 16" />
</button>
<Combobox
value={value}
defaultValue={valuePlaceholder}
name={`${property.id}_value`}
placeholder={valuePlaceholder}
items={property.validValues || []}
readOnly={readOnly}
size="small"
tiny
fill
allowCustomValue
onChange={handleValueChange}
/>
<div className={s(styles, { actions: true })}>
{edited && !isDeletable && (
<ActionIconButton
title={translate('core_blocks_properties_table_item_reset')}
name="/icons/data_revert_all_sm.svg"
viewBox="0 0 16 16"
type="button"
onClick={handleRevert}
/>
)}
{isDeletable && (
<ActionIconButton
title={translate('core_blocks_properties_table_item_remove')}
name="reject"
viewBox="0 0 11 11"
type="button"
onClick={handleRemove}
/>
)}
</div>
{isDeletable && (
<div className={s(styles, { remove: true })} title={translate('core_blocks_properties_table_item_remove')}>
<button className={s(styles, { button: true })} type="button" onClick={handleRemove}>
<Icon className={s(styles, { icon: true })} name="reject" viewBox="0 0 11 11" />
</button>
</div>
)}
</div>
</div>
);